diff --git a/Harbour.xcodeproj/project.pbxproj b/Harbour.xcodeproj/project.pbxproj index 2bfd64f..e843b91 100644 --- a/Harbour.xcodeproj/project.pbxproj +++ b/Harbour.xcodeproj/project.pbxproj @@ -53,10 +53,13 @@ E7BF9E8D2672F4B300AAB6A1 /* DecreasesOnPressButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7BF9E8C2672F4B300AAB6A1 /* DecreasesOnPressButtonStyle.swift */; }; E7BF9E8F2672F4C700AAB6A1 /* Globals.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7BF9E8E2672F4C700AAB6A1 /* Globals.swift */; }; E7BF9E912672F55700AAB6A1 /* PrimaryButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7BF9E902672F55700AAB6A1 /* PrimaryButtonStyle.swift */; }; + E7C43C402718219B007A0678 /* Date+.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7C43C3F2718219B007A0678 /* Date+.swift */; }; E7C84B792708BB170071DE06 /* Localization.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7C84B782708BB170071DE06 /* Localization.swift */; }; E7DC3BA82708FB8A00F32F8B /* TransparentButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7DC3BA72708FB8A00F32F8B /* TransparentButtonStyle.swift */; }; E7DD4CC6267650CB002709F0 /* ContainerConsoleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7DD4CC5267650CB002709F0 /* ContainerConsoleView.swift */; }; E7EAE5CC270B6234008CFD20 /* CustomSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7EAE5CB270B6234008CFD20 /* CustomSection.swift */; }; + E7F46732271C95C600A616E7 /* PseudoLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7F46731271C95C600A616E7 /* PseudoLogger.swift */; }; + E7F46734271C98EB00A616E7 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7F46733271C98EB00A616E7 /* AppDelegate.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -119,10 +122,13 @@ E7BF9E8C2672F4B300AAB6A1 /* DecreasesOnPressButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecreasesOnPressButtonStyle.swift; sourceTree = ""; }; E7BF9E8E2672F4C700AAB6A1 /* Globals.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Globals.swift; sourceTree = ""; }; E7BF9E902672F55700AAB6A1 /* PrimaryButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryButtonStyle.swift; sourceTree = ""; }; + E7C43C3F2718219B007A0678 /* Date+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+.swift"; sourceTree = ""; }; E7C84B782708BB170071DE06 /* Localization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Localization.swift; sourceTree = ""; }; E7DC3BA72708FB8A00F32F8B /* TransparentButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransparentButtonStyle.swift; sourceTree = ""; }; E7DD4CC5267650CB002709F0 /* ContainerConsoleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerConsoleView.swift; sourceTree = ""; }; E7EAE5CB270B6234008CFD20 /* CustomSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomSection.swift; sourceTree = ""; }; + E7F46731271C95C600A616E7 /* PseudoLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PseudoLogger.swift; sourceTree = ""; }; + E7F46733271C98EB00A616E7 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -228,7 +234,9 @@ isa = PBXGroup; children = ( E7BF9E502672B5B000AAB6A1 /* HarbourApp.swift */, + E7F46733271C98EB00A616E7 /* AppDelegate.swift */, E7BF9E8E2672F4C700AAB6A1 /* Globals.swift */, + E7F46731271C95C600A616E7 /* PseudoLogger.swift */, E7BF9E762672E15700AAB6A1 /* Data */, E7BF9E832672EF4200AAB6A1 /* Views */, E7BF9E7E2672E60E00AAB6A1 /* Extensions+Modifiers */, @@ -317,6 +325,7 @@ children = ( E751F062270B37DA00980DCA /* Array+.swift */, E75A51E12673A1C100857D2B /* Bundle+.swift */, + E7C43C3F2718219B007A0678 /* Date+.swift */, E7748E7E267E835600456BFD /* Notification+.swift */, E7339C572674262500A55B5C /* String+.swift */, E7BF9E892672F2F600AAB6A1 /* UIDevice+.swift */, @@ -466,7 +475,10 @@ E7BF9E912672F55700AAB6A1 /* PrimaryButtonStyle.swift in Sources */, E7748E7D267E831B00456BFD /* UIWindow+.swift in Sources */, E7BF9E8D2672F4B300AAB6A1 /* DecreasesOnPressButtonStyle.swift in Sources */, + E7C43C402718219B007A0678 /* Date+.swift in Sources */, + E7F46734271C98EB00A616E7 /* AppDelegate.swift in Sources */, E7B10B7026CD80C3005B82BC /* SettingsView+Components.swift in Sources */, + E7F46732271C95C600A616E7 /* PseudoLogger.swift in Sources */, E7B10B7226CD80EB005B82BC /* SettingsView+OtherSection.swift in Sources */, E7DC3BA82708FB8A00F32F8B /* TransparentButtonStyle.swift in Sources */, E7272E8A26736CCF00228494 /* PortainerKit+.swift in Sources */, @@ -643,12 +655,13 @@ INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; - LD_RUNPATH_SEARCH_PATHS = ( + IPHONEOS_DEPLOYMENT_TARGET = 14.3; + LIBRARY_SEARCH_PATHS = ( "$(inherited)", - "@executable_path/Frameworks", + "$(SDKROOT)/usr/lib/swift", ); - MARKETING_VERSION = 2.0.0; + MARKETING_VERSION = "2.0.0-legacy"; + OTHER_SWIFT_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = xyz.shameful.Harbour; PRODUCT_NAME = Harbour; SDKROOT = iphoneos; @@ -684,12 +697,13 @@ INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; - LD_RUNPATH_SEARCH_PATHS = ( + IPHONEOS_DEPLOYMENT_TARGET = 14.3; + LIBRARY_SEARCH_PATHS = ( "$(inherited)", - "@executable_path/Frameworks", + "$(SDKROOT)/usr/lib/swift", ); - MARKETING_VERSION = 2.0.0; + MARKETING_VERSION = "2.0.0-legacy"; + OTHER_SWIFT_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = xyz.shameful.Harbour; PRODUCT_NAME = Harbour; SDKROOT = iphoneos; diff --git a/Harbour.xcodeproj/xcshareddata/xcschemes/Harbour.xcscheme b/Harbour.xcodeproj/xcshareddata/xcschemes/Harbour.xcscheme index 6df1d0c..e22b153 100644 --- a/Harbour.xcodeproj/xcshareddata/xcschemes/Harbour.xcscheme +++ b/Harbour.xcodeproj/xcshareddata/xcschemes/Harbour.xcscheme @@ -72,7 +72,7 @@ buildConfiguration = "Debug"> diff --git a/Harbour/AppDelegate.swift b/Harbour/AppDelegate.swift new file mode 100644 index 0000000..94cd4ee --- /dev/null +++ b/Harbour/AppDelegate.swift @@ -0,0 +1,34 @@ +// +// AppDelegate.swift +// Harbour +// +// Created by royal on 17/10/2021. +// + +import UIKit +import PortainerKit + +class AppDelegate: NSObject, UIApplicationDelegate { + let inputPipe = Pipe() + let outputPipe = Pipe() + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { + inputPipe.fileHandleForReading.readabilityHandler = { [weak self] fileHandle in + let data = fileHandle.availableData + if let string = String(data: data, encoding: .utf8) { + _LOGS.append(string.trimmingCharacters(in: .whitespacesAndNewlines)) + } + + // Write input back to stdout + self?.outputPipe.fileHandleForWriting.write(data) + } + + dup2(STDOUT_FILENO, outputPipe.fileHandleForWriting.fileDescriptor) + dup2(STDERR_FILENO, outputPipe.fileHandleForWriting.fileDescriptor) + + dup2(inputPipe.fileHandleForWriting.fileDescriptor, STDOUT_FILENO) + dup2(inputPipe.fileHandleForWriting.fileDescriptor, STDERR_FILENO) + + return true + } +} diff --git a/Harbour/Data/AppState.swift b/Harbour/Data/AppState.swift index ae046d1..92783e0 100644 --- a/Harbour/Data/AppState.swift +++ b/Harbour/Data/AppState.swift @@ -22,7 +22,7 @@ class AppState: ObservableObject { public let indicators: Indicators = Indicators() - private let logger: Logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "AppState") + private let logger: PseudoLogger = PseudoLogger(subsystem: Bundle.main.bundleIdentifier!, category: "AppState") private var autoRefreshTimer: AnyCancellable? = nil @@ -51,26 +51,19 @@ class AppState: ObservableObject { autoRefreshTimer = Timer.publish(every: interval, on: .current, in: .common) .autoconnect() - .sink { _ in - Task { [weak self] in - DispatchQueue.main.async { [weak self] in - self?.fetchingMainScreenData = true - } - - do { - guard let selectedEndpointID = Portainer.shared.selectedEndpoint?.id else { - return - } - - try await Portainer.shared.getContainers(endpointID: selectedEndpointID) - } catch { - await UIDevice.current.generateHaptic(.error) - self?.handle(error) - } - - DispatchQueue.main.async { [weak self] in - self?.fetchingMainScreenData = false - } + .sink { [weak self] _ in + DispatchQueue.main.async { [weak self] in + self?.fetchingMainScreenData = true + } + + guard let selectedEndpointID = Portainer.shared.selectedEndpoint?.id else { + return + } + + Portainer.shared.getContainers(endpointID: selectedEndpointID) + + DispatchQueue.main.async { [weak self] in + self?.fetchingMainScreenData = false } } } @@ -90,7 +83,7 @@ class AppState: ObservableObject { logger.error("\(String(describing: error)) [\(_fileID):\(_line)]") if displayIndicator { - let style: Indicators.Indicator.Style = .init(subheadlineColor: .red, subheadlineStyle: .primary, iconColor: .red, iconStyle: .primary) + let style: Indicators.Indicator.Style = .init(subheadlineColor: .red, iconColor: .red) let indicator: Indicators.Indicator = .init(id: UUID().uuidString, icon: "exclamationmark.triangle.fill", headline: "Error!", subheadline: error.localizedDescription, expandedText: error.localizedDescription, dismissType: .after(5), style: style) DispatchQueue.main.async { self.indicators.display(indicator) diff --git a/Harbour/Data/Portainer/Portainer+AttachedContainer.swift b/Harbour/Data/Portainer/Portainer+AttachedContainer.swift index bde6538..d3e3e6f 100644 --- a/Harbour/Data/Portainer/Portainer+AttachedContainer.swift +++ b/Harbour/Data/Portainer/Portainer+AttachedContainer.swift @@ -16,9 +16,9 @@ extension Portainer { public let container: PortainerKit.Container public let messagePassthroughSubject: PortainerKit.WebSocketPassthroughSubject - @Published public private(set) var attributedString: AttributedString = "" + @Published public private(set) var string: String = "" - private let logger: Logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "Portainer.AttachedContainer") + private let logger: PseudoLogger = PseudoLogger(subsystem: Bundle.main.bundleIdentifier!, category: "Portainer.AttachedContainer") private var messageCancellable: AnyCancellable? = nil public init(container: PortainerKit.Container, messagePassthroughSubject: PortainerKit.WebSocketPassthroughSubject) { @@ -73,9 +73,8 @@ extension Portainer { } private func update(_ string: String) { - let attributedString: AttributedString = AttributedString(string) DispatchQueue.main.async { [weak self] in - self?.attributedString.append(attributedString) + self?.string.append(string) } } } diff --git a/Harbour/Data/Portainer/Portainer.swift b/Harbour/Data/Portainer/Portainer.swift index 1bb5e96..96e7e01 100644 --- a/Harbour/Data/Portainer/Portainer.swift +++ b/Harbour/Data/Portainer/Portainer.swift @@ -5,11 +5,11 @@ // Created by royal on 11/06/2021. // +import Foundation import Combine import KeychainAccess import os.log import PortainerKit -import SwiftUI final class Portainer: ObservableObject { // MARK: - Public properties @@ -27,13 +27,7 @@ final class Portainer: ObservableObject { Preferences.shared.selectedEndpointID = selectedEndpoint?.id if let endpointID = selectedEndpoint?.id { - Task { - do { - try await getContainers(endpointID: endpointID) - } catch { - AppState.shared.handle(error) - } - } + getContainers(endpointID: endpointID) } else { containers = [] } @@ -68,8 +62,8 @@ final class Portainer: ObservableObject { // MARK: - Private util - private let logger: Logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "Portainer") - private let keychain: Keychain = Keychain(service: Bundle.main.bundleIdentifier!, accessGroup: "\(Bundle.main.appIdentifierPrefix)group.\(Bundle.main.bundleIdentifier!)").synchronizable(true).accessibility(.afterFirstUnlock) + private let logger: PseudoLogger = PseudoLogger(subsystem: Bundle.main.bundleIdentifier!, category: "Portainer") + private let keychain: Keychain = Keychain(service: Bundle.main.bundleIdentifier!).synchronizable(true).accessibility(.afterFirstUnlock) private let ud: UserDefaults = Preferences.shared.ud private var api: PortainerKit? @@ -77,18 +71,12 @@ final class Portainer: ObservableObject { private init() { if let urlString = Preferences.shared.endpointURL, let url = URL(string: urlString) { - logger.debug("Has saved URL: \(url, privacy: .sensitive)") + logger.debug("Has saved URL: \(url)") if let token = try? keychain.get(KeychainKeys.token) { logger.debug("Has token, cool! Using it 😊") api = PortainerKit(url: url, token: token) - DispatchQueue.main.async { - Task { - AppState.shared.fetchingMainScreenData = true - _ = try? await self.getEndpoints() - AppState.shared.fetchingMainScreenData = false - } - } + getEndpoints(completionHandler: { _ in }) } } } @@ -101,26 +89,33 @@ final class Portainer: ObservableObject { /// - username: Username /// - password: Password /// - savePassword: Should password be saved? - /// - Returns: Result containing JWT token or error. - public func login(url: URL, username: String, password: String, savePassword: Bool) async throws { - logger.debug("Logging in! URL: \(url.absoluteString, privacy: .sensitive)") + public func login(url: URL, username: String, password: String, savePassword: Bool, completionHandler: @escaping (Result) -> ()) { + logger.debug("Logging in! URL: \(url.absoluteString)") let api = PortainerKit(url: url) self.api = api - let token = try await api.login(username: username, password: password) - - logger.debug("Successfully logged in!") - - DispatchQueue.main.async { - self.isLoggedIn = true - Preferences.shared.endpointURL = url.absoluteString - } - - try keychain.comment(Localization.KEYCHAIN_TOKEN_COMMENT.localizedString).label("Harbour (token)").set(token, key: KeychainKeys.token) - if savePassword { - let keychain = self.keychain.comment(Localization.KEYCHAIN_CREDS_COMMENT.localizedString) - try keychain.label("Harbour (username)").set(username, key: KeychainKeys.username) - try keychain.label("Harbour (password)").set(password, key: KeychainKeys.password) + api.login(username: username, password: password) { result in + switch result { + case .success(let token): + self.logger.debug("Successfully logged in!") + + DispatchQueue.main.async { + self.isLoggedIn = true + Preferences.shared.endpointURL = url.absoluteString + } + + try? self.keychain.comment(Localization.KEYCHAIN_TOKEN_COMMENT.localizedString).label("Harbour (token)").set(token, key: KeychainKeys.token) + if savePassword { + let keychain = self.keychain.comment(Localization.KEYCHAIN_CREDS_COMMENT.localizedString) + try? keychain.label("Harbour (username)").set(username, key: KeychainKeys.username) + try? keychain.label("Harbour (password)").set(password, key: KeychainKeys.password) + } + + completionHandler(.success(())) + case .failure(let error): + self.handle(error) + completionHandler(.failure(error)) + } } } @@ -143,87 +138,102 @@ final class Portainer: ObservableObject { /// Fetches available endpoints. /// - Returns: `[PortainerKit.Endpoint]` - @discardableResult - public func getEndpoints() async throws -> [PortainerKit.Endpoint] { + public func getEndpoints(completionHandler: @escaping (Result<[PortainerKit.Endpoint], Error>) -> ()) { logger.debug("Getting endpoints...") - guard let api = api else { throw PortainerError.noAPI } + guard let api = api else { + handle(PortainerError.noAPI) + return + } - do { - let endpoints = try await api.getEndpoints() + api.getEndpoints() { result in + completionHandler(result) - logger.debug("Got \(endpoints.count) endpoint(s).") - DispatchQueue.main.async { [weak self] in - self?.endpoints = endpoints - self?.isLoggedIn = true + switch result { + case .success(let endpoints): + self.logger.debug("Got \(endpoints.count) endpoint(s).") + DispatchQueue.main.async { [weak self] in + self?.endpoints = endpoints + self?.isLoggedIn = true + } + case .failure(let error): + self.handle(error) } - - return endpoints - } catch { - handle(error) - throw error } } /// Fetches available containers for selected endpoint ID. /// - Parameter endpointID: Endpoint ID /// - Returns: `[PortainerKit.Container]` - @discardableResult - public func getContainers(endpointID: Int) async throws -> [PortainerKit.Container] { + public func getContainers(endpointID: Int) { logger.debug("Getting containers for endpointID: \(endpointID)...") - guard let api = api else { throw PortainerError.noAPI } + guard let api = api else { + handle(PortainerError.noAPI) + return + } - do { - let containers = try await api.getContainers(for: endpointID) - - logger.debug("Got \(containers.count) container(s) for endpointID: \(endpointID).") - DispatchQueue.main.async { [weak self] in - self?.containers = containers + api.getContainers(for: endpointID) { result in + switch result { + case .success(let containers): + self.logger.debug("Got \(containers.count) container(s) for endpointID: \(endpointID).") + DispatchQueue.main.async { [weak self] in + self?.containers = containers + } + case .failure(let error): + self.handle(error) } - - return containers - } catch { - handle(error) - throw error } } /// Fetches container details. /// - Parameter container: Container to be inspected /// - Returns: `PortainerKit.ContainerDetails` - public func inspectContainer(_ container: PortainerKit.Container) async throws -> PortainerKit.ContainerDetails { + public func inspectContainer(_ container: PortainerKit.Container, completionHandler: @escaping (Result) -> ()) { logger.debug("Inspecting container with ID: \(container.id), endpointID: \(self.selectedEndpoint?.id ?? -1)...") - guard let api = api else { throw PortainerError.noAPI } - guard let endpointID = selectedEndpoint?.id else { throw PortainerError.noEndpoint } - - do { - let containerDetails = try await api.inspectContainer(container.id, endpointID: endpointID) - logger.debug("Got details for containerID: \(container.id), endpointID: \(endpointID).") - return containerDetails - } catch { - handle(error) - throw error + guard let api = api else { + completionHandler(.failure(PortainerError.noAPI)) + handle(PortainerError.noAPI) + return + } + guard let endpointID = selectedEndpoint?.id else { + completionHandler(.failure(PortainerError.noEndpoint)) + handle(PortainerError.noEndpoint) + return } + + api.inspectContainer(container.id, endpointID: endpointID, completionHandler: completionHandler) + logger.debug("Got details for containerID: \(container.id), endpointID: \(endpointID).") } /// Executes an action on selected container. /// - Parameters: /// - action: Action to be executed /// - container: Container, where the action will be executed - public func execute(_ action: PortainerKit.ExecuteAction, on container: PortainerKit.Container) async throws { + public func execute(_ action: PortainerKit.ExecuteAction, on container: PortainerKit.Container, completionHandler: @escaping (Result) -> ()) { logger.debug("Executing action \(action.rawValue) for containerID: \(container.id), endpointID: \(self.selectedEndpoint?.id ?? -1)...") - guard let api = api else { throw PortainerError.noAPI } - guard let endpointID = selectedEndpoint?.id else { throw PortainerError.noEndpoint } + guard let api = api else { + completionHandler(.failure(PortainerError.noAPI)) + handle(PortainerError.noAPI) + return + } + guard let endpointID = selectedEndpoint?.id else { + completionHandler(.failure(PortainerError.noEndpoint)) + handle(PortainerError.noEndpoint) + return + } - do { - try await api.execute(action, containerID: container.id, endpointID: endpointID) - logger.debug("Executed action \(action.rawValue) for containerID: \(container.id), endpointID: \(endpointID).") - } catch { - handle(error) - throw error + api.execute(action, containerID: container.id, endpointID: endpointID) { result in + switch result { + case .success: + self.logger.debug("Executed action \(action.rawValue) for containerID: \(container.id), endpointID: \(endpointID).") + completionHandler(.success(())) + case .failure(let error): + self.handle(error) + completionHandler(.failure(error)) + } } } @@ -234,20 +244,21 @@ final class Portainer: ObservableObject { /// - tail: Number of lines /// - displayTimestamps: Display timestamps? /// - Returns: `String` logs - public func getLogs(from container: PortainerKit.Container, since: TimeInterval = 0, tail: Int = 100, displayTimestamps: Bool = false) async throws -> String { + public func getLogs(from container: PortainerKit.Container, since: TimeInterval = 0, tail: Int = 100, displayTimestamps: Bool = false, completionHandler: @escaping (Result) -> ()) { logger.debug("Getting logs from containerID: \(container.id), endpointID: \(self.selectedEndpoint?.id ?? -1)...") - guard let api = api else { throw PortainerError.noAPI } - guard let endpointID = selectedEndpoint?.id else { throw PortainerError.noEndpoint } - - do { - let logs = try await api.getLogs(containerID: container.id, endpointID: endpointID, since: since, tail: tail, displayTimestamps: displayTimestamps) - logger.debug("Got logs from containerID: \(container.id), endpointID: \(self.selectedEndpoint?.id ?? -1)!") - return logs - } catch { - handle(error) - throw error + guard let api = api else { + completionHandler(.failure(PortainerError.noAPI)) + handle(PortainerError.noAPI) + return + } + guard let endpointID = selectedEndpoint?.id else { + completionHandler(.failure(PortainerError.noEndpoint)) + handle(PortainerError.noEndpoint) + return } + + api.getLogs(containerID: container.id, endpointID: endpointID, since: since, tail: tail, displayTimestamps: displayTimestamps, completionHandler: completionHandler) } /// Attaches to container through a WebSocket connection. @@ -292,13 +303,13 @@ final class Portainer: ObservableObject { // Check if has stored creds if let url = api?.url, let username = try? keychain.get(KeychainKeys.username), let password = try? keychain.get(KeychainKeys.password) { logger.debug("Received `invalidJWTToken`, but has credentials!") - Task { - do { - try await login(url: url, username: username, password: password, savePassword: true) - try await self.getEndpoints() - } catch { - logger.debug("Credentials invalid, logging out :(") - logOut() + login(url: url, username: username, password: password, savePassword: true) { result in + switch result { + case .success: + self.getEndpoints(completionHandler: { _ in }) + case .failure: + self.logger.debug("Credentials invalid, logging out :(") + self.logOut() } } } diff --git a/Harbour/Extensions+Modifiers/Date+.swift b/Harbour/Extensions+Modifiers/Date+.swift new file mode 100644 index 0000000..dbc277f --- /dev/null +++ b/Harbour/Extensions+Modifiers/Date+.swift @@ -0,0 +1,23 @@ +// +// Date+.swift +// Harbour +// +// Created by royal on 14/10/2021. +// + +import Foundation + +extension Date { + func formatted() -> String { + let formatter = DateFormatter() + formatter.timeZone = TimeZone.autoupdatingCurrent + formatter.calendar = Calendar.autoupdatingCurrent + formatter.locale = Locale.autoupdatingCurrent + formatter.dateStyle = .medium + formatter.timeStyle = .medium + formatter.doesRelativeDateFormatting = false + formatter.formattingContext = .standalone + + return formatter.string(from: self) + } +} diff --git a/Harbour/HarbourApp.swift b/Harbour/HarbourApp.swift index 4831e7b..337e9f1 100644 --- a/Harbour/HarbourApp.swift +++ b/Harbour/HarbourApp.swift @@ -10,21 +10,39 @@ import Indicators @main struct HarbourApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate: AppDelegate @StateObject var appState: AppState = .shared @StateObject var portainer: Portainer = .shared @StateObject var preferences: Preferences = .shared - + + @State private var isLegacyBuildPromptPresented: Bool = (UIDevice.current.systemVersion as NSString).floatValue >= 15 + var body: some Scene { WindowGroup { ContentView() .indicatorOverlay(model: appState.indicators) - .sheet(isPresented: $appState.isSettingsSheetPresented) { - SettingsView() - .environmentObject(portainer) - .environmentObject(preferences) - } - .sheet(isPresented: $appState.isSetupSheetPresented, onDismiss: { Preferences.shared.finishedSetup = true }) { - SetupView() + .sheet(isPresented: $isLegacyBuildPromptPresented) { + VStack(spacing: 15) { + Spacer() + + Text("This is a legacy build made only to support iOS 14, but you're running iOS >= 15!") + .font(.title.weight(.bold)) + + Text("You can continue using this version, but it won't receive any future updates.") + + Link(destination: URL(string: "https://github.com/rrroyal/Harbour/releases/latest")!) { + Text("Visit GitHub to update - @rrroyal/Harbour") + } + .font(.body.weight(.semibold)) + + Spacer() + + Text("Harbour v\(Bundle.main.buildVersion) (#\(Bundle.main.buildNumber))") + .font(.subheadline) + .foregroundColor(.secondary) + } + .multilineTextAlignment(.center) + .padding() } .sheet(isPresented: $appState.isContainerConsoleSheetPresented, onDismiss: onContainerConsoleViewDismissed) { ContainerConsoleView() diff --git a/Harbour/PseudoLogger.swift b/Harbour/PseudoLogger.swift new file mode 100644 index 0000000..cf56827 --- /dev/null +++ b/Harbour/PseudoLogger.swift @@ -0,0 +1,50 @@ +// +// PseudoLogger.swift +// Harbour +// +// Created by royal on 17/10/2021. +// + +import Foundation +import os.log + +public var _LOGS: [String] = [] + +class PseudoLogger { + let subsystem: String + let category: String + + private let logger: Logger + + init(subsystem: String, category: String) { + self.subsystem = subsystem + self.category = category + + self.logger = Logger(subsystem: subsystem, category: category) + } + + public func log(_ message: String) { + logger.log("\(message)") + addToGlobalLogs(message) + } + + public func info(_ message: String) { + logger.info("\(message)") + addToGlobalLogs(message) + } + + public func debug(_ message: String) { + logger.debug("\(message)") + addToGlobalLogs(message) + } + + public func error(_ message: String) { + logger.error("\(message)") + addToGlobalLogs(message) + } + + private func addToGlobalLogs(_ message: String) { + let str = "(\(category)) \(message)".trimmingCharacters(in: .whitespacesAndNewlines) + _LOGS.append(str) + } +} diff --git a/Harbour/Views/Components/ContainerContextMenu.swift b/Harbour/Views/Components/ContainerContextMenu.swift index 6e76cbe..22e5cb8 100644 --- a/Harbour/Views/Components/ContainerContextMenu.swift +++ b/Harbour/Views/Components/ContainerContextMenu.swift @@ -48,10 +48,11 @@ struct ContainerContextMenu: View { } var killButton: some View { - Button(role: .destructive, action: { execute(.kill, haptic: .heavy) }) { + Button(action: { execute(.kill, haptic: .heavy) }) { Text(PortainerKit.ExecuteAction.kill.label) Image(systemName: PortainerKit.ExecuteAction.kill.icon) } + .accentColor(.red) } var body: some View { @@ -120,24 +121,24 @@ struct ContainerContextMenu: View { private func execute(_ action: PortainerKit.ExecuteAction, haptic: UIDevice.FeedbackStyle = .medium) { UIDevice.current.generateHaptic(haptic) - let style: Indicators.Indicator.Style = .init(subheadlineColor: action.color, subheadlineStyle: .primary, iconColor: action.color, iconStyle: .primary, iconVariants: .fill) + let style: Indicators.Indicator.Style = .init(subheadlineColor: action.color, iconColor: action.color) let indicator: Indicators.Indicator = .init(id: "ContainerActionExecution-\(container.id)", icon: action.icon, headline: container.displayName ?? "Container", subheadline: action.label, dismissType: .after(3), style: style) AppState.shared.indicators.display(indicator) - - Task { - do { - try await Portainer.shared.execute(action, on: container) - - DispatchQueue.main.async { - container.state = action.expectedState - Portainer.shared.refreshCurrentContainerPassthroughSubject.send() - } - - if let endpointID = Portainer.shared.selectedEndpoint?.id { - try await Portainer.shared.getContainers(endpointID: endpointID) - } - } catch { - AppState.shared.handle(error) + + Portainer.shared.execute(action, on: container) { result in + switch result { + case .success: + DispatchQueue.main.async { + container.state = action.expectedState + Portainer.shared.refreshCurrentContainerPassthroughSubject.send() + } + + if let endpointID = Portainer.shared.selectedEndpoint?.id { + Portainer.shared.getContainers(endpointID: endpointID) + } + + case .failure(let error): + AppState.shared.handle(error) } } } diff --git a/Harbour/Views/Components/CustomSection.swift b/Harbour/Views/Components/CustomSection.swift index 5f44343..b4270a5 100644 --- a/Harbour/Views/Components/CustomSection.swift +++ b/Harbour/Views/Components/CustomSection.swift @@ -17,7 +17,7 @@ struct CustomSection: View { VStack(alignment: .leading, spacing: 6) { Text(LocalizedStringKey(label)) .font(.footnote) - .foregroundStyle(.secondary) + .foregroundColor(.secondary) .textCase(.uppercase) .padding(.horizontal) @@ -26,7 +26,7 @@ struct CustomSection: View { .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: Globals.Views.cornerRadius, style: .continuous) - .fill(Color(uiColor: .secondarySystemGroupedBackground)) + .fill(Color(UIColor.secondarySystemGroupedBackground)) ) } } diff --git a/Harbour/Views/Components/Labeled.swift b/Harbour/Views/Components/Labeled.swift index cca65cf..d3fb02b 100644 --- a/Harbour/Views/Components/Labeled.swift +++ b/Harbour/Views/Components/Labeled.swift @@ -48,7 +48,6 @@ struct Labeled: View { .foregroundColor(content != nil ? .primary : .secondary) .lineLimit(lineLimit) .multilineTextAlignment(.trailing) - .textSelection(.enabled) } } } diff --git a/Harbour/Views/Components/LabeledSection.swift b/Harbour/Views/Components/LabeledSection.swift index 62c38ea..4daa6d5 100644 --- a/Harbour/Views/Components/LabeledSection.swift +++ b/Harbour/Views/Components/LabeledSection.swift @@ -30,7 +30,6 @@ struct LabeledSection: View { .foregroundColor(content != nil ? .primary : .secondary) .lineLimit(nil) .contentShape(Rectangle()) - .textSelection(.enabled) } } } diff --git a/Harbour/Views/Components/NavigationLinkLabel.swift b/Harbour/Views/Components/NavigationLinkLabel.swift index cec1aab..21f53b7 100644 --- a/Harbour/Views/Components/NavigationLinkLabel.swift +++ b/Harbour/Views/Components/NavigationLinkLabel.swift @@ -12,7 +12,7 @@ struct NavigationLinkLabel: View { let symbolName: String let backgroundColor: Color - public init(label: String, symbolName: String, backgroundColor: Color = Color(uiColor: .secondarySystemGroupedBackground)) { + public init(label: String, symbolName: String, backgroundColor: Color = Color(UIColor.secondarySystemGroupedBackground)) { self.label = label self.symbolName = symbolName self.backgroundColor = backgroundColor @@ -30,7 +30,7 @@ struct NavigationLinkLabel: View { Image(systemName: "chevron.forward") .font(.subheadline.weight(.bold)) - .foregroundStyle(.secondary) + .foregroundColor(.secondary) .opacity(Globals.Views.secondaryOpacity) } .padding(.medium) diff --git a/Harbour/Views/Containers List/Grid/ContainerGridView+ContainerCell.swift b/Harbour/Views/Containers List/Grid/ContainerGridView+ContainerCell.swift index 4ad4556..427ce3b 100644 --- a/Harbour/Views/Containers List/Grid/ContainerGridView+ContainerCell.swift +++ b/Harbour/Views/Containers List/Grid/ContainerGridView+ContainerCell.swift @@ -21,7 +21,7 @@ extension ContainerGridView { if let state = container.state { Text(state.rawValue.capitalizingFirstLetter()) .font(.footnote.weight(.medium)) - .foregroundStyle(.secondary) + .foregroundColor(.secondary) .lineLimit(1) .frame(maxWidth: .infinity, alignment: .leading) } @@ -39,7 +39,7 @@ extension ContainerGridView { if let status = container.status { Text(status) .font(.caption.weight(.medium)) - .foregroundStyle(.secondary) + .foregroundColor(.secondary) .lineLimit(1) .minimumScaleFactor(0.8) .multilineTextAlignment(.leading) @@ -56,7 +56,7 @@ extension ContainerGridView { } .padding(.medium) .aspectRatio(1, contentMode: .fill) - .background(Color(uiColor: .secondarySystemBackground), in: backgroundRectangle) + .background(backgroundRectangle.fill(Color(UIColor.secondarySystemBackground))) .contentShape(backgroundRectangle) .animation(.easeInOut, value: container.state) .animation(.easeInOut, value: container.status) diff --git a/Harbour/Views/Containers List/List/ContainerListView+ContainerCell.swift b/Harbour/Views/Containers List/List/ContainerListView+ContainerCell.swift index 630cb47..9ab12b6 100644 --- a/Harbour/Views/Containers List/List/ContainerListView+ContainerCell.swift +++ b/Harbour/Views/Containers List/List/ContainerListView+ContainerCell.swift @@ -19,14 +19,15 @@ extension ContainerListView { var containerStatusSubheadline: some View { Group { if let status = container.status, - let state = container.state { - Text("\(status) • \(state.rawValue.capitalizingFirstLetter())") + let state = container.state?.rawValue.capitalizingFirstLetter(), + status != state { + Text("\(status) • \(state)") } else if let fallback = container.status ?? container.state?.rawValue.capitalizingFirstLetter() { Text(fallback) } } .font(.subheadline.weight(.medium)) - .foregroundStyle(.secondary) + .foregroundColor(.secondary) .lineLimit(1) .minimumScaleFactor(0.8) .multilineTextAlignment(.leading) @@ -55,7 +56,7 @@ extension ContainerListView { .animation(.easeInOut, value: container.state.color) } .padding() - .background(Color(uiColor: .secondarySystemBackground), in: backgroundRectangle) + .background(backgroundRectangle.fill(Color(UIColor.secondarySystemBackground))) .contentShape(backgroundRectangle) .animation(.easeInOut, value: container.state) .animation(.easeInOut, value: container.status) diff --git a/Harbour/Views/ContentView.swift b/Harbour/Views/ContentView.swift index 92b6f44..6b8d13d 100644 --- a/Harbour/Views/ContentView.swift +++ b/Harbour/Views/ContentView.swift @@ -38,23 +38,27 @@ struct ContentView: View { Button(action: { UIDevice.current.generateHaptic(.light) appState.fetchingMainScreenData = true - Task { - do { - try await portainer.getEndpoints() - if let endpointID = portainer.selectedEndpoint?.id { - try await portainer.getContainers(endpointID: endpointID) - } - } catch { - AppState.shared.handle(error) + + portainer.getEndpoints() { result in + DispatchQueue.main.async { + appState.fetchingMainScreenData = false + } + + switch result { + case .success: + if let endpointID = portainer.selectedEndpoint?.id { + portainer.getContainers(endpointID: endpointID) + } + + case .failure(let error): + AppState.shared.handle(error) } } - appState.fetchingMainScreenData = false }) { Label("Refresh", systemImage: "arrow.clockwise") } }) { - Image(systemName: "tag") - .symbolVariant(portainer.selectedEndpoint != nil ? .fill : (!portainer.endpoints.isEmpty ? .none : .slash)) + Image(systemName: portainer.selectedEndpoint != nil ? "tag.fill" : (!portainer.endpoints.isEmpty ? "tag" : "tag.slash")) } .disabled(!portainer.isLoggedIn) } @@ -70,7 +74,6 @@ struct ContentView: View { ContainerListView(containers: portainer.containers.filtered(query: searchQuery)) } } - .searchable(text: $searchQuery) } else { Text("No containers") .opacity(Globals.Views.secondaryOpacity) @@ -86,19 +89,6 @@ struct ContentView: View { Group { if portainer.isLoggedIn { loggedInView - .refreshable { - if let endpointID = portainer.selectedEndpoint?.id { - appState.fetchingMainScreenData = true - - do { - try await portainer.getContainers(endpointID: endpointID) - } catch { - AppState.shared.handle(error) - } - - appState.fetchingMainScreenData = false - } - } } else { Text("Not logged in") .opacity(Globals.Views.secondaryOpacity) @@ -107,18 +97,15 @@ struct ContentView: View { .navigationTitle("Harbour") .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .navigation) { - Button(action: { - UIDevice.current.generateHaptic(.soft) - appState.isSettingsSheetPresented = true - }) { + ToolbarItem(placement: .navigationBarLeading) { + NavigationLink(destination: SettingsView().environmentObject(portainer).environmentObject(preferences)) { Image(systemName: "gear") } } ToolbarTitle(title: "Harbour", subtitle: appState.fetchingMainScreenData ? "Refreshing..." : nil) - ToolbarItem(placement: .primaryAction, content: { toolbarMenu }) + ToolbarItem(placement: .navigationBarTrailing, content: { toolbarMenu }) } } .transition(.opacity) diff --git a/Harbour/Views/DebugView.swift b/Harbour/Views/DebugView.swift index d7c9da7..c97d373 100644 --- a/Harbour/Views/DebugView.swift +++ b/Harbour/Views/DebugView.swift @@ -5,8 +5,6 @@ // Created by royal on 19/06/2021. // -#if DEBUG - import SwiftUI import OSLog import Indicators @@ -14,12 +12,12 @@ import Indicators struct DebugView: View { var body: some View { List { - Section("Build info") { + Section(header: Text("Build info")) { Labeled(label: "Bundle ID", content: Bundle.main.bundleIdentifier, monospace: true) Labeled(label: "App prefix", content: Bundle.main.appIdentifierPrefix, monospace: true) } - Section("UserDefaults") { + Section(header: Text("UserDefaults")) { Button("Reset finishedSetup") { UIDevice.current.generateHaptic(.light) Preferences.shared.finishedSetup = false @@ -33,7 +31,7 @@ struct DebugView: View { .accentColor(.red) } - Section("Indicators") { + Section(header: Text("Indicators")) { Button("Display manual indicator") { let indicator: Indicators.Indicator = .init(id: "manual", icon: "bolt", headline: "Headline", subheadline: "Subheadline", expandedText: "Expanded text that is really long and should be truncated normally", dismissType: .manual) UIDevice.current.generateHaptic(.light) @@ -48,63 +46,18 @@ struct DebugView: View { } Section { - NavigationLink(destination: LogsView()) { - Text("Logs") + Button("Copy logs") { + UIDevice.current.generateHaptic(.light) + UIPasteboard.general.string = _LOGS.joined(separator: "\n") } - } + } } .navigationTitle("🤫") } } -extension DebugView { - struct LogsView: View { - @State private var logs: [String] = [] - - var body: some View { - List(logs, id: \.self) { entry in - Text(entry) - .lineLimit(nil) - .frame(maxWidth: .infinity, alignment: .topLeading) - .contentShape(Rectangle()) - .textSelection(.enabled) - } - .font(.system(.footnote, design: .monospaced)) - .listStyle(.plain) - .navigationTitle("Logs") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button(action: { - UIDevice.current.generateHaptic(.light) - getLogs() - }) { - Image(systemName: "arrow.clockwise") - } - } - } - .onAppear(perform: getLogs) - } - - func getLogs() { - do { - let logStore = try OSLogStore(scope: .currentProcessIdentifier) - let entries = try logStore.getEntries() - logs = entries - .compactMap { $0 as? OSLogEntryLog } - .filter { $0.subsystem.contains(Bundle.main.bundleIdentifier!) } - .map { "[\($0.level.rawValue)] \($0.date): \($0.category): \($0.composedMessage)" } - } catch { - logs = [String(describing: error)] - } - } - } -} - struct DebugView_Previews: PreviewProvider { static var previews: some View { DebugView() } } - -#endif diff --git a/Harbour/Views/Details/Container/ContainerConfigDetailsView.swift b/Harbour/Views/Details/Container/ContainerConfigDetailsView.swift index 838ff4a..d95facd 100644 --- a/Harbour/Views/Details/Container/ContainerConfigDetailsView.swift +++ b/Harbour/Views/Details/Container/ContainerConfigDetailsView.swift @@ -21,7 +21,7 @@ struct ContainerConfigDetailsView: View { } var configSection: some View { - Section("Config") { + Section(header: Text("Config")) { if let config = config { Text(String(describing: config)) } else { @@ -31,7 +31,7 @@ struct ContainerConfigDetailsView: View { } var hostConfigSection: some View { - Section("Host config") { + Section(header: Text("Host config")) { if let hostConfig = hostConfig { Text(String(describing: hostConfig)) } else { diff --git a/Harbour/Views/Details/Container/ContainerConsoleView.swift b/Harbour/Views/Details/Container/ContainerConsoleView.swift index 098a000..ec2723d 100644 --- a/Harbour/Views/Details/Container/ContainerConsoleView.swift +++ b/Harbour/Views/Details/Container/ContainerConsoleView.swift @@ -16,17 +16,16 @@ struct ContainerConsoleView: View { if let attachedContainer = portainer.attachedContainer { ScrollView { LazyVStack { - Text(attachedContainer.attributedString) + Text(attachedContainer.string) .font(.system(.footnote, design: .monospaced)) .lineLimit(nil) - .textSelection(.enabled) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } .padding(.small) } } else { Text("how did you get here? ಠ_ಠ") - .foregroundStyle(.secondary) + .foregroundColor(.secondary) } } } diff --git a/Harbour/Views/Details/Container/ContainerDetailView.swift b/Harbour/Views/Details/Container/ContainerDetailView.swift index 9eddb66..6edf971 100644 --- a/Harbour/Views/Details/Container/ContainerDetailView.swift +++ b/Harbour/Views/Details/Container/ContainerDetailView.swift @@ -64,7 +64,6 @@ struct ContainerDetailView: View { .lineLimit(nil) .contentShape(Rectangle()) .frame(maxWidth: .infinity, alignment: .topLeading) - .textSelection(.enabled) } else { ProgressView() .padding() @@ -89,7 +88,7 @@ struct ContainerDetailView: View { } .padding() } - .background(Color(uiColor: .systemGroupedBackground).edgesIgnoringSafeArea(.all)) + .background(Color(UIColor.systemGroupedBackground).edgesIgnoringSafeArea(.all)) .animation(.easeInOut, value: lastLogsSnippet) .animation(.easeInOut, value: container.details) .navigationTitle(container.displayName ?? container.id) @@ -105,9 +104,7 @@ struct ContainerDetailView: View { Button(action: { UIDevice.current.generateHaptic(.light) - Task { - await refresh() - } + refresh() }) { Label("Refresh", systemImage: "arrow.clockwise") } @@ -119,35 +116,36 @@ struct ContainerDetailView: View { } } } - .refreshable { await refresh() } - .task { await refresh() } - .onReceive(portainer.refreshCurrentContainerPassthroughSubject) { - Task { await refresh() } - } + .onAppear(perform: refresh) + .onReceive(portainer.refreshCurrentContainerPassthroughSubject, perform: refresh) } - private func refresh() async { + private func refresh() { loading = true - Task { - do { - let logs = try await portainer.getLogs(from: container, tail: lastLogsTailCount, displayTimestamps: true) - self.lastLogsSnippet = logs.trimmingCharacters(in: .whitespacesAndNewlines) - } catch { - AppState.shared.handle(error) + portainer.getLogs(from: container, tail: lastLogsTailCount, displayTimestamps: true) { result in + switch result { + case .success(let logs): + self.lastLogsSnippet = logs.trimmingCharacters(in: .whitespacesAndNewlines) + + case .failure(let error): + AppState.shared.handle(error) } } - do { - let containerDetails = try await portainer.inspectContainer(container) - withAnimation { - container.update(from: containerDetails) + portainer.inspectContainer(container) { result in + switch result { + case .success(let details): + withAnimation { + container.update(from: details) + } + + case .failure(let error): + AppState.shared.handle(error) } - } catch { - AppState.shared.handle(error) + + loading = false } - - loading = false } } diff --git a/Harbour/Views/Details/Container/ContainerLogsView.swift b/Harbour/Views/Details/Container/ContainerLogsView.swift index 14958d8..f67a632 100644 --- a/Harbour/Views/Details/Container/ContainerLogsView.swift +++ b/Harbour/Views/Details/Container/ContainerLogsView.swift @@ -16,19 +16,13 @@ struct ContainerLogsView: View { @State private var logs: String = "" @State private var tail: Int = 100 { - didSet { - Task { await refresh() } - } + didSet { refresh() } } @State private var since: TimeInterval = 0 { - didSet { - Task { await refresh() } - } + didSet { refresh() } } @State private var displayTimestamps: Bool = false { - didSet { - Task { await refresh() } - } + didSet { refresh() } } let logsLabelID: String = "LogsLabel" @@ -49,7 +43,6 @@ struct ContainerLogsView: View { Text(logs) .font(.system(.footnote, design: .monospaced)) .lineLimit(nil) - .textSelection(.enabled) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .id(logsLabelID) } @@ -119,9 +112,7 @@ struct ContainerLogsView: View { // Refresh Button(action: { UIDevice.current.generateHaptic(.light) - Task { - await refresh() - } + refresh() }) { Label("Refresh", systemImage: "arrow.clockwise") } @@ -138,19 +129,21 @@ struct ContainerLogsView: View { .toolbar { ToolbarTitle(title: "Logs", subtitle: loading ? "Refreshing..." : nil) } - .task { await refresh() } + .onAppear(perform: refresh) } - private func refresh() async { + private func refresh() { loading = true - do { - let logs = try await portainer.getLogs(from: container, since: since, tail: tail, displayTimestamps: displayTimestamps) - self.logs = logs - } catch { - AppState.shared.handle(error) + portainer.getLogs(from: container, since: since, tail: tail, displayTimestamps: displayTimestamps) { result in + loading = false + + switch result { + case .success(let logs): + self.logs = logs + case .failure(let error): + AppState.shared.handle(error) + } } - - loading = false } } diff --git a/Harbour/Views/Details/Container/ContainerMountsDetailsView.swift b/Harbour/Views/Details/Container/ContainerMountsDetailsView.swift index f86ac4b..68e56c1 100644 --- a/Harbour/Views/Details/Container/ContainerMountsDetailsView.swift +++ b/Harbour/Views/Details/Container/ContainerMountsDetailsView.swift @@ -45,7 +45,7 @@ struct ContainerMountsDetailsView: View { var detailSection: some View { if let details = details { ForEach(details.sorted(by: { $0.destination > $1.destination }), id: \.self) { mount in - Section(mount.destination) { + Section(header: Text(mount.destination)) { Labeled(label: "Name", content: mount.name, monospace: true) Labeled(label: "Source", content: mount.source, monospace: true) Labeled(label: "Destination", content: mount.destination, monospace: true) diff --git a/Harbour/Views/LoginView.swift b/Harbour/Views/LoginView.swift index a478f96..65da28b 100644 --- a/Harbour/Views/LoginView.swift +++ b/Harbour/Views/LoginView.swift @@ -18,7 +18,6 @@ struct LoginView: View { @State private var savePassword: Bool = false - @FocusState private var focusedField: FocusField? // @State private var showLoginHelpMessage: Bool = false @State private var loading: Bool = false @@ -44,26 +43,17 @@ struct LoginView: View { UIDevice.current.generateHaptic(.selectionChanged) endpoint = "http://\(endpoint)" } - - focusedField = .username }) .keyboardType(.URL) .disableAutocorrection(true) .autocapitalization(.none) .textFieldStyle(RoundedTextFieldStyle(fontDesign: .monospaced)) - .focused($focusedField, equals: .endpoint) - TextField("garyhost", text: $username, onCommit: { - guard !username.isEmpty else { return } - - UIDevice.current.generateHaptic(.selectionChanged) - focusedField = .password - }) + TextField("garyhost", text: $username) .keyboardType(.default) .disableAutocorrection(true) .autocapitalization(.none) .textFieldStyle(RoundedTextFieldStyle(fontDesign: .monospaced)) - .focused($focusedField, equals: .username) SecureField("hunter2", text: $password, onCommit: { guard !(loading || endpoint.isReallyEmpty || username.isEmpty || password.isEmpty) else { return } @@ -75,7 +65,6 @@ struct LoginView: View { .disableAutocorrection(true) .autocapitalization(.none) .textFieldStyle(RoundedTextFieldStyle(fontDesign: .monospaced)) - .focused($focusedField, equals: .password) } Spacer() @@ -110,8 +99,7 @@ struct LoginView: View { savePassword.toggle() }) { HStack { - Image(systemName: savePassword ? "checkmark" : "circle.dashed") - .symbolVariant(savePassword ? .circle.fill : .none) + Image(systemName: savePassword ? "checkmark.circle.fill" : "circle.dashed") .id("SavePasswordIcon:\(savePassword)") Text("Save password") @@ -153,41 +141,34 @@ struct LoginView: View { return } - focusedField = nil - - Task { - do { - loading = true - try await portainer.login(url: url, username: username, password: password, savePassword: savePassword) - - UIDevice.current.generateHaptic(.success) - - loading = false - buttonColor = .green - buttonLabel = "Success!" - presentationMode.wrappedValue.dismiss() - - do { - try await portainer.getEndpoints() - } catch { - AppState.shared.handle(error) - } - } catch { - UIDevice.current.generateHaptic(.error) - - loading = false - buttonColor = .red - if let error = error as? PortainerKit.APIError { - buttonLabel = error.description - } else { - buttonLabel = error.localizedDescription - } - - errorTimer?.invalidate() - errorTimer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { _ in - buttonLabel = nil - buttonColor = nil - } + loading = true + portainer.login(url: url, username: username, password: password, savePassword: savePassword) { result in + switch result { + case .success: + UIDevice.current.generateHaptic(.success) + + loading = false + buttonColor = .green + buttonLabel = "Success!" + presentationMode.wrappedValue.dismiss() + portainer.getEndpoints(completionHandler: { _ in }) + + case .failure(let error): + UIDevice.current.generateHaptic(.error) + + loading = false + buttonColor = .red + if let error = error as? PortainerKit.APIError { + buttonLabel = error.description + } else { + buttonLabel = error.localizedDescription + } + + errorTimer?.invalidate() + errorTimer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { _ in + buttonLabel = nil + buttonColor = nil + } } } } diff --git a/Harbour/Views/Settings/SettingsView+Components.swift b/Harbour/Views/Settings/SettingsView+Components.swift index 3ba8dbe..cb83400 100644 --- a/Harbour/Views/Settings/SettingsView+Components.swift +++ b/Harbour/Views/Settings/SettingsView+Components.swift @@ -30,7 +30,7 @@ extension SettingsView { if let description = description { Text(LocalizedStringKey(description)) .font(.body) - .foregroundStyle(.secondary) + .foregroundColor(.secondary) } } .opacity(isEnabled ? 1 : Globals.Views.secondaryOpacity) @@ -62,7 +62,7 @@ extension SettingsView { if let description = description { Text(LocalizedStringKey(description)) .font(.subheadline) - .foregroundStyle(.secondary) + .foregroundColor(.secondary) .frame(maxWidth: .infinity, alignment: .leading) } } diff --git a/Harbour/Views/Settings/SettingsView+InterfaceSection.swift b/Harbour/Views/Settings/SettingsView+InterfaceSection.swift index d29556a..b480ce3 100644 --- a/Harbour/Views/Settings/SettingsView+InterfaceSection.swift +++ b/Harbour/Views/Settings/SettingsView+InterfaceSection.swift @@ -12,7 +12,7 @@ extension SettingsView { @EnvironmentObject var preferences: Preferences var body: some View { - Section("Interface") { + Section(header: Text("Interface")) { /// Enable haptics ToggleOption(label: Localization.SETTINGS_ENABLE_HAPTICS_TITLE.localizedString, description: Localization.SETTINGS_ENABLE_HAPTICS_DESCRIPTION.localizedString, isOn: $preferences.enableHaptics) diff --git a/Harbour/Views/Settings/SettingsView+OtherSection.swift b/Harbour/Views/Settings/SettingsView+OtherSection.swift index f55f701..05df6f8 100644 --- a/Harbour/Views/Settings/SettingsView+OtherSection.swift +++ b/Harbour/Views/Settings/SettingsView+OtherSection.swift @@ -29,11 +29,9 @@ extension SettingsView { LibrariesView() } - #if DEBUG NavigationLink("🤫") { DebugView() } - #endif Link(destination: URL(string: "https://harbour.shameful.xyz/docs")!) { HStack { diff --git a/Harbour/Views/Settings/SettingsView+PortainerSection.swift b/Harbour/Views/Settings/SettingsView+PortainerSection.swift index d47245d..55b494b 100644 --- a/Harbour/Views/Settings/SettingsView+PortainerSection.swift +++ b/Harbour/Views/Settings/SettingsView+PortainerSection.swift @@ -33,10 +33,11 @@ extension SettingsView { SliderOption(label: Localization.SETTINGS_AUTO_REFRESH_TITLE.localizedString, description: autoRefreshIntervalDescription, value: $preferences.autoRefreshInterval, range: 0...60, step: 1, onEditingChanged: setupAutoRefreshTimer) .disabled(!Portainer.shared.isLoggedIn) - Button("Log out", role: .destructive) { + Button("Log out") { UIDevice.current.generateHaptic(.warning) isLogoutWarningPresented = true } + .accentColor(.red) .alert(isPresented: $isLogoutWarningPresented) { Alert(title: Text("Are you sure?"), primaryButton: .destructive(Text("Yes"), action: { @@ -56,7 +57,7 @@ extension SettingsView { } var body: some View { - Section("Portainer") { + Section(header: Text("Portainer")) { /// Endpoint URL if let endpointURL = Preferences.shared.endpointURL { Labeled(label: "URL", content: endpointURL, monospace: true, lineLimit: 1) diff --git a/Harbour/Views/Settings/SettingsView.swift b/Harbour/Views/Settings/SettingsView.swift index d41868c..7a57834 100644 --- a/Harbour/Views/Settings/SettingsView.swift +++ b/Harbour/Views/Settings/SettingsView.swift @@ -12,14 +12,13 @@ struct SettingsView: View { @EnvironmentObject var preferences: Preferences var body: some View { - NavigationView { - List { - PortainerSection() - InterfaceSection() - OtherSection() - } - .navigationTitle("Settings") + List { + PortainerSection() + InterfaceSection() + OtherSection() } + .listStyle(InsetGroupedListStyle()) + .navigationTitle("Settings") } } diff --git a/Harbour/Views/SetupView.swift b/Harbour/Views/SetupView.swift index ee7e55b..6c742df 100644 --- a/Harbour/Views/SetupView.swift +++ b/Harbour/Views/SetupView.swift @@ -41,8 +41,8 @@ fileprivate struct WelcomeView: View { VStack(spacing: 20) { FeatureCell(image: "power", headline: Localization.SETUP_FEATURE1_TITLE.localizedString, description: Localization.SETUP_FEATURE1_DESCRIPTION.localizedString) - FeatureCell(image: "doc.plaintext", headline: Localization.SETUP_FEATURE2_TITLE.localizedString, description: Localization.SETUP_FEATURE2_DESCRIPTION.localizedString) - FeatureCell(image: "terminal", headline: Localization.SETUP_FEATURE3_TITLE.localizedString, description: Localization.SETUP_FEATURE3_DESCRIPTION.localizedString) + FeatureCell(image: "doc.plaintext.fill", headline: Localization.SETUP_FEATURE2_TITLE.localizedString, description: Localization.SETUP_FEATURE2_DESCRIPTION.localizedString) + FeatureCell(image: "terminal.fill", headline: Localization.SETUP_FEATURE3_TITLE.localizedString, description: Localization.SETUP_FEATURE3_DESCRIPTION.localizedString) } Spacer() @@ -73,9 +73,7 @@ fileprivate extension WelcomeView { HStack(spacing: 10) { Image(systemName: image) .font(.title.weight(.semibold)) - .foregroundStyle(Color.accentColor) - .symbolVariant(.fill) - .symbolRenderingMode(.hierarchical) + .foregroundColor(Color.accentColor) .frame(width: imageWidth) VStack(alignment: .leading, spacing: 2) { @@ -85,7 +83,7 @@ fileprivate extension WelcomeView { Text(LocalizedStringKey(description)) .font(.subheadline) - .foregroundStyle(.secondary) + .foregroundColor(.secondary) .frame(maxWidth: .infinity, alignment: .leading) } } diff --git a/Harbour/Views/Styles/Button/PrimaryButtonStyle.swift b/Harbour/Views/Styles/Button/PrimaryButtonStyle.swift index 2390357..a2daa55 100644 --- a/Harbour/Views/Styles/Button/PrimaryButtonStyle.swift +++ b/Harbour/Views/Styles/Button/PrimaryButtonStyle.swift @@ -26,7 +26,7 @@ struct PrimaryButtonStyle: ButtonStyle { .font(font) .padding() .frame(maxWidth: Globals.Views.maxButtonWidth, alignment: .center) - .background(isEnabled ? backgroundColor : Color(uiColor: .systemGray5)) + .background(isEnabled ? backgroundColor : Color(UIColor.systemGray5)) .cornerRadius(Globals.Views.cornerRadius) // .compositingGroup() .opacity(configuration.isPressed ? Globals.Buttons.pressedOpacity : 1) diff --git a/Harbour/Views/Styles/Button/TransparentButtonStyle.swift b/Harbour/Views/Styles/Button/TransparentButtonStyle.swift index f61f835..847f170 100644 --- a/Harbour/Views/Styles/Button/TransparentButtonStyle.swift +++ b/Harbour/Views/Styles/Button/TransparentButtonStyle.swift @@ -12,7 +12,7 @@ struct TransparentButtonStyle: ButtonStyle { configuration.label .multilineTextAlignment(.center) .padding() - .background(Color(uiColor: .systemGray5).opacity(configuration.isPressed ? Globals.Views.secondaryOpacity : 0)) + .background(Color(UIColor.systemGray5).opacity(configuration.isPressed ? Globals.Views.secondaryOpacity : 0)) .cornerRadius(Globals.Views.cornerRadius) .opacity(configuration.isPressed ? Globals.Buttons.pressedOpacity : 1) .scaleEffect(configuration.isPressed ? Globals.Buttons.pressedSize : 1) diff --git a/Harbour/Views/Styles/TextField/RoundedTextFieldStyle.swift b/Harbour/Views/Styles/TextField/RoundedTextFieldStyle.swift index 1b2c505..8916838 100644 --- a/Harbour/Views/Styles/TextField/RoundedTextFieldStyle.swift +++ b/Harbour/Views/Styles/TextField/RoundedTextFieldStyle.swift @@ -21,7 +21,7 @@ struct RoundedTextFieldStyle: TextFieldStyle { .padding(.medium) .background( RoundedRectangle(cornerRadius: Globals.Views.cornerRadius, style: .continuous) - .fill(Color(uiColor: .secondarySystemBackground)) + .fill(Color(UIColor.secondarySystemBackground)) ) } } diff --git a/Modules/Indicators/Package.swift b/Modules/Indicators/Package.swift index fd7c103..1626292 100644 --- a/Modules/Indicators/Package.swift +++ b/Modules/Indicators/Package.swift @@ -6,7 +6,7 @@ import PackageDescription let package = Package( name: "Indicators", platforms: [ - .iOS(.v15) + .iOS(.v14) ], products: [ .library(name: "Indicators", targets: ["Indicators"]) diff --git a/Modules/Indicators/Sources/Indicators/Indicators+Indicator.swift b/Modules/Indicators/Sources/Indicators/Indicators+Indicator.swift index 6ce37e3..9696751 100644 --- a/Modules/Indicators/Sources/Indicators/Indicators+Indicator.swift +++ b/Modules/Indicators/Sources/Indicators/Indicators+Indicator.swift @@ -57,32 +57,15 @@ public extension Indicators.Indicator { struct Style { public var headlineColor: Color? - public var headlineStyle: HierarchicalShapeStyle - public var subheadlineColor: Color? - public var subheadlineStyle: HierarchicalShapeStyle - public var iconColor: Color? - public var iconStyle: HierarchicalShapeStyle - public var iconVariants: SymbolVariants - public init(headlineColor: Color? = nil, - headlineStyle: HierarchicalShapeStyle = .primary, - subheadlineColor: Color? = nil, - subheadlineStyle: HierarchicalShapeStyle = .primary, - iconColor: Color? = nil, - iconStyle: HierarchicalShapeStyle = .primary, - iconVariants: SymbolVariants = .none - ) { + public init(headlineColor: Color? = nil, subheadlineColor: Color? = nil, iconColor: Color? = nil) { self.headlineColor = headlineColor - self.headlineStyle = headlineStyle self.subheadlineColor = subheadlineColor - self.subheadlineStyle = subheadlineStyle self.iconColor = iconColor - self.iconStyle = iconStyle - self.iconVariants = iconVariants } - public static let `default` = Style(headlineStyle: .primary, subheadlineStyle: .secondary, iconStyle: .secondary, iconVariants: .fill) + public static let `default` = Style(headlineColor: .primary, subheadlineColor: .secondary, iconColor: .secondary) } } diff --git a/Modules/Indicators/Sources/Indicators/Indicators+IndicatorView.swift b/Modules/Indicators/Sources/Indicators/Indicators+IndicatorView.swift index 2fade08..2d5b23b 100644 --- a/Modules/Indicators/Sources/Indicators/Indicators+IndicatorView.swift +++ b/Modules/Indicators/Sources/Indicators/Indicators+IndicatorView.swift @@ -26,8 +26,6 @@ internal extension Indicators { if let icon = indicator.icon { Image(systemName: icon) .font(indicator.subheadline != nil ? .title3 : .footnote) - .symbolVariant(indicator.style.iconVariants) - .foregroundStyle(indicator.style.iconStyle) .foregroundColor(indicator.style.iconColor) .animation(.easeInOut, value: indicator.style.iconColor) .transition(.opacity) @@ -38,7 +36,6 @@ internal extension Indicators { .font(.footnote) .fontWeight(.medium) .lineLimit(1) - .foregroundStyle(indicator.style.headlineStyle) .foregroundColor(indicator.style.headlineColor) .animation(.easeInOut, value: indicator.style.headlineColor) @@ -47,7 +44,6 @@ internal extension Indicators { .font(.footnote) .fontWeight(.medium) .lineLimit(isExpanded ? nil : 1) - .foregroundStyle(indicator.style.subheadlineStyle) .foregroundColor(indicator.style.subheadlineColor) .animation(.easeInOut, value: indicator.style.subheadlineColor) } @@ -60,8 +56,7 @@ internal extension Indicators { } .padding(padding) .padding(.horizontal, padding) - // .background(Material.regular, in: backgroundShape) - .background(backgroundShape.fill(Color(uiColor: .secondarySystemGroupedBackground)).shadow(color: Color.black.opacity(0.1), radius: 14, x: 0, y: 0)) + .background(backgroundShape.fill(Color(UIColor.secondarySystemGroupedBackground)).shadow(color: Color.black.opacity(0.1), radius: 14, x: 0, y: 0)) .frame(maxWidth: isExpanded ? nil : maxWidth) .animation(.easeInOut, value: indicator.icon) .animation(.easeInOut, value: indicator.headline) @@ -100,7 +95,7 @@ struct IndicatorView_Previews: PreviewProvider { Indicators.IndicatorView(indicator: .init(id: "", icon: "bolt.fill", headline: "Headline", subheadline: "Subheadline", dismissType: .manual, style: .init(subheadlineColor: .red, iconColor: .red)), isExpanded: isExpanded) } .padding() - .background(Color(uiColor: .systemBackground)) + .background(Color(UIColor.systemBackground)) .previewLayout(.sizeThatFits) .environment(\.colorScheme, .light) } diff --git a/Modules/PortainerKit/Package.swift b/Modules/PortainerKit/Package.swift index a563047..ddae67d 100644 --- a/Modules/PortainerKit/Package.swift +++ b/Modules/PortainerKit/Package.swift @@ -6,8 +6,8 @@ import PackageDescription let package = Package( name: "PortainerKit", platforms: [ - .iOS(.v15), - .macOS(.v12) + .iOS(.v14), + .macOS(.v11) ], products: [ .library(name: "PortainerKit", targets: ["PortainerKit"]) diff --git a/Modules/PortainerKit/Sources/PortainerKit/PortainerKit+Errors.swift b/Modules/PortainerKit/Sources/PortainerKit/PortainerKit+Errors.swift index bfaa80e..00683fc 100644 --- a/Modules/PortainerKit/Sources/PortainerKit/PortainerKit+Errors.swift +++ b/Modules/PortainerKit/Sources/PortainerKit/PortainerKit+Errors.swift @@ -7,7 +7,7 @@ import Foundation -@available(iOS 15, macOS 12, *) +@available(iOS 14, macOS 11, *) public extension PortainerKit { enum APIError: Error, Comparable { case custom(_ reason: String) @@ -23,6 +23,8 @@ public extension PortainerKit { case invalidPayload case invalidURL + case noData + public var description: String { switch self { case .custom(let reason): return reason @@ -34,6 +36,7 @@ public extension PortainerKit { case .unauthorized: return "Unauthorized" case .invalidPayload: return "Invalid payload" case .invalidURL: return "Invalid URL" + case .noData: return "No data" } } diff --git a/Modules/PortainerKit/Sources/PortainerKit/PortainerKit+RequestPath.swift b/Modules/PortainerKit/Sources/PortainerKit/PortainerKit+RequestPath.swift index a37a23e..f437a8c 100644 --- a/Modules/PortainerKit/Sources/PortainerKit/PortainerKit+RequestPath.swift +++ b/Modules/PortainerKit/Sources/PortainerKit/PortainerKit+RequestPath.swift @@ -7,7 +7,7 @@ import Foundation -@available(iOS 15, macOS 12, *) +@available(iOS 14, macOS 11, *) internal extension PortainerKit { enum RequestPath { case login diff --git a/Modules/PortainerKit/Sources/PortainerKit/PortainerKit+WebSocketMessage.swift b/Modules/PortainerKit/Sources/PortainerKit/PortainerKit+WebSocketMessage.swift index 2cf9fe2..37a7c8f 100644 --- a/Modules/PortainerKit/Sources/PortainerKit/PortainerKit+WebSocketMessage.swift +++ b/Modules/PortainerKit/Sources/PortainerKit/PortainerKit+WebSocketMessage.swift @@ -7,7 +7,7 @@ import Foundation -@available(iOS 15, macOS 12, *) +@available(iOS 14, macOS 11, *) public extension PortainerKit { enum MessageSource { case server diff --git a/Modules/PortainerKit/Sources/PortainerKit/PortainerKit.swift b/Modules/PortainerKit/Sources/PortainerKit/PortainerKit.swift index f976e3f..4dfeffc 100644 --- a/Modules/PortainerKit/Sources/PortainerKit/PortainerKit.swift +++ b/Modules/PortainerKit/Sources/PortainerKit/PortainerKit.swift @@ -8,7 +8,7 @@ import Combine import Foundation -@available(iOS 15, macOS 12, *) +@available(iOS 14, macOS 11, *) public class PortainerKit { public typealias WebSocketPassthroughSubject = PassthroughSubject, Error> @@ -52,40 +52,69 @@ public class PortainerKit { /// - username: Username /// - password: Password /// - Returns: JWT token - public func login(username: String, password: String) async throws -> String { - var request = try request(for: .login) - request.httpMethod = "POST" - - let body = [ - "Username": username, - "Password": password - ] - request.httpBody = try JSONSerialization.data(withJSONObject: body) - - let (data, _) = try await session.data(for: request) - let decoded = try JSONDecoder().decode([String: String].self, from: data) - - if let jwt: String = decoded["jwt"] { - token = jwt - return jwt - } else { - throw APIError.fromMessage(decoded["message"]) + public func login(username: String, password: String, completionHandler: @escaping (Result) -> ()) { + do { + var request = try request(for: .login) + request.httpMethod = "POST" + + let body = [ + "Username": username, + "Password": password + ] + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + session.dataTask(with: request) { data, response, error in + print(data?.base64EncodedString() ?? "", response ?? "", error.debugDescription) + + if let error = error { + completionHandler(.failure(error)) + return + } + + do { + guard let data = data else { + throw APIError.noData + } + + let decoded = try JSONDecoder().decode([String: String].self, from: data) + + if let jwt: String = decoded["jwt"] { + self.token = jwt + completionHandler(.success(jwt)) + } else { + throw APIError.fromMessage(decoded["message"]) + } + } catch { + completionHandler(.failure(error)) + } + } + .resume() + } catch { + completionHandler(.failure(error)) } } /// Fetches available endpoints. /// - Returns: `[Endpoint]` - public func getEndpoints() async throws -> [Endpoint] { - let request = try request(for: .endpoints) - return try await fetch(request: request) + public func getEndpoints(completionHandler: @escaping (Result<[Endpoint], Error>) -> ()) { + do { + let request = try request(for: .endpoints) + fetch(request: request, completionHandler: completionHandler) + } catch { + completionHandler(.failure(error)) + } } /// Fetches available containers for supplied endpoint ID. /// - Parameter endpointID: Endpoint ID /// - Returns: `[Container]` - public func getContainers(for endpointID: Int) async throws -> [Container] { - let request = try request(for: .containers(endpointID: endpointID)) - return try await fetch(request: request) + public func getContainers(for endpointID: Int, completionHandler: @escaping (Result<[Container], Error>) -> ()) { + do { + let request = try request(for: .containers(endpointID: endpointID)) + fetch(request: request, completionHandler: completionHandler) + } catch { + completionHandler(.failure(error)) + } } /// Inspects the requested container. @@ -93,31 +122,35 @@ public class PortainerKit { /// - containerID: Container ID /// - endpointID: Endpoint ID /// - Returns: `ContainerDetails` - public func inspectContainer(_ containerID: String, endpointID: Int) async throws -> ContainerDetails { - let request = try request(for: .containerDetails(containerID: containerID, endpointID: endpointID)) - - let decoder = JSONDecoder() - let dateFormatter = ISO8601DateFormatter() - - /// Dear Docker/Portainer developers - - /// WHY THE HELL DO YOU RETURN FRACTIONAL SECONDS ONLY SOMETIMES - /// Sincerely, deeply upset me. - decoder.dateDecodingStrategy = .custom { decoder -> Date in - let container = try decoder.singleValueContainer() - let str = try container.decode(String.self) + public func inspectContainer(_ containerID: String, endpointID: Int, completionHandler: @escaping (Result) -> ()) { + do { + let request = try request(for: .containerDetails(containerID: containerID, endpointID: endpointID)) - // ISO8601 with fractional seconds - dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - if let date = dateFormatter.date(from: str) { return date } + let decoder = JSONDecoder() + let dateFormatter = ISO8601DateFormatter() - // ISO8601 without fractional seconds - dateFormatter.formatOptions = [.withInternetDateTime] - if let date = dateFormatter.date(from: str) { return date } + /// Dear Docker/Portainer developers - + /// WHY THE HELL DO YOU RETURN FRACTIONAL SECONDS ONLY SOMETIMES + /// Sincerely, deeply upset me. + decoder.dateDecodingStrategy = .custom { decoder -> Date in + let container = try decoder.singleValueContainer() + let str = try container.decode(String.self) + + // ISO8601 with fractional seconds + dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = dateFormatter.date(from: str) { return date } + + // ISO8601 without fractional seconds + dateFormatter.formatOptions = [.withInternetDateTime] + if let date = dateFormatter.date(from: str) { return date } + + throw DateError.invalidDate(dateString: str) + } - throw DateError.invalidDate(dateString: str) + fetch(request: request, decoder: decoder, completionHandler: completionHandler) + } catch { + completionHandler(.failure(error)) } - - return try await fetch(request: request, decoder: decoder) } /// Executes selected action for container with supplied ID. @@ -125,19 +158,34 @@ public class PortainerKit { /// - action: Executed action /// - containerID: Container ID /// - endpointID: Endpoint ID - public func execute(_ action: ExecuteAction, containerID: String, endpointID: Int) async throws { - var request = try request(for: .executeAction(action, containerID: containerID, endpointID: endpointID)) - request.httpMethod = "POST" - - let response = try await session.data(for: request) - if let statusCode = (response.1 as? HTTPURLResponse)?.statusCode { - if !(200...304 ~= statusCode) { - throw APIError.responseCodeUnacceptable(statusCode) + public func execute(_ action: ExecuteAction, containerID: String, endpointID: Int, completionHandler: @escaping (Result) -> ()) { + do { + var request = try request(for: .executeAction(action, containerID: containerID, endpointID: endpointID)) + request.httpMethod = "POST" + + session.dataTask(with: request) { data, response, error in + print(data?.base64EncodedString() ?? "", response ?? "", error.debugDescription) + + if let error = error { + completionHandler(.failure(error)) + return + } + + if let statusCode = (response as? HTTPURLResponse)?.statusCode { + if !(200...304 ~= statusCode) { + completionHandler(.failure(APIError.responseCodeUnacceptable(statusCode))) + } else { + completionHandler(.success(())) + } + } else { + // It shouldn't happen, but we should gracefully handle it anyways. + // For now, we're hoping it worked ¯\_(ツ)_/¯. + assertionFailure("Response isn't HTTPURLResponse 🤨 [\(#fileID):\(#line)]") + } } - } else { - // It shouldn't happen, but we should gracefully handle it anyways. - // For now, we're hoping it worked ¯\_(ツ)_/¯. - assertionFailure("Response isn't HTTPURLResponse 🤨 [\(#fileID):\(#line)]") + .resume() + } catch { + completionHandler(.failure(error)) } } @@ -149,12 +197,29 @@ public class PortainerKit { /// - tail: Number of lines, counting from the end /// - displayTimestamps: Display timestamps? /// - Returns: `String` logs - public func getLogs(containerID: String, endpointID: Int, since: TimeInterval = 0, tail: Int = 100, displayTimestamps: Bool = false) async throws -> String { - let request = try request(for: .logs(containerID: containerID, endpointID: endpointID, since: since, tail: tail, timestamps: displayTimestamps)) - - let (data, _) = try await session.data(for: request) - guard let string = String(data: data, encoding: .utf8) ?? String(data: data, encoding: .ascii) else { throw APIError.decodingFailed } - return string + public func getLogs(containerID: String, endpointID: Int, since: TimeInterval = 0, tail: Int = 100, displayTimestamps: Bool = false, completionHandler: @escaping (Result) -> ()) { + do { + let request = try request(for: .logs(containerID: containerID, endpointID: endpointID, since: since, tail: tail, timestamps: displayTimestamps)) + + session.dataTask(with: request) { data, response, error in + print(data?.base64EncodedString() ?? "", response ?? "", error.debugDescription) + + guard let data = data else { + completionHandler(.failure(APIError.noData)) + return + } + + guard let string = String(data: data, encoding: .utf8) ?? String(data: data, encoding: .ascii) else { + completionHandler(.failure(APIError.decodingFailed)) + return + } + + completionHandler(.success(string)) + } + .resume() + } catch { + completionHandler(.failure(error)) + } } /// Attaches to container with supplied ID. @@ -219,19 +284,32 @@ public class PortainerKit { /// - Parameter request: Request /// - Parameter decoder: JSONDecoder /// - Returns: Output - private func fetch(request: URLRequest, decoder: JSONDecoder = JSONDecoder()) async throws -> Output { - let response = try await session.data(for: request) - - do { - let decoded = try decoder.decode(Output.self, from: response.0) - return decoded - } catch { - if let errorJson = try? decoder.decode([String: String].self, from: response.0), - let message = errorJson["message"] { - throw APIError.fromMessage(message) - } else { - throw error + private func fetch(request: URLRequest, decoder: JSONDecoder = JSONDecoder(), completionHandler: @escaping (Result) -> ()) { + session.dataTask(with: request) { data, response, error in + print(data?.base64EncodedString() ?? "", response ?? "", error.debugDescription) + + if let error = error { + completionHandler(.failure(error)) + return + } + + guard let data = data else { + completionHandler(.failure(APIError.noData)) + return + } + + do { + let decoded = try decoder.decode(Output.self, from: data) + completionHandler(.success(decoded)) + } catch { + if let errorJson = try? decoder.decode([String: String].self, from: data), + let message = errorJson["message"] { + completionHandler(.failure(APIError.fromMessage(message))) + } else { + completionHandler(.failure(error)) + } } } + .resume() } } diff --git a/Modules/PortainerKit/Sources/PortainerKit/Types/Container.swift b/Modules/PortainerKit/Sources/PortainerKit/Types/Container.swift index 14f0060..2034bbe 100644 --- a/Modules/PortainerKit/Sources/PortainerKit/Types/Container.swift +++ b/Modules/PortainerKit/Sources/PortainerKit/Types/Container.swift @@ -8,7 +8,7 @@ import Foundation import Combine -@available(iOS 15, macOS 12, *) +@available(iOS 14, macOS 11, *) public extension PortainerKit { class Container: Identifiable, Codable, Equatable, ObservableObject { public struct NetworkSettings: Codable { diff --git a/Modules/PortainerKit/Sources/PortainerKit/Types/ContainerDetails.swift b/Modules/PortainerKit/Sources/PortainerKit/Types/ContainerDetails.swift index ce5e7fe..b0e5128 100644 --- a/Modules/PortainerKit/Sources/PortainerKit/Types/ContainerDetails.swift +++ b/Modules/PortainerKit/Sources/PortainerKit/Types/ContainerDetails.swift @@ -7,7 +7,7 @@ import Foundation -@available(iOS 15, macOS 12, *) +@available(iOS 14, macOS 11, *) public extension PortainerKit { struct ContainerDetails: Identifiable, Codable, Equatable { public struct NetworkSettings: Codable { @@ -59,7 +59,7 @@ public extension PortainerKit { public let id: String public let created: Date - public let platform: String + public let platform: String? public let path: String public let args: [String] public let state: ContainerState diff --git a/Modules/PortainerKit/Sources/PortainerKit/Types/Endpoint.swift b/Modules/PortainerKit/Sources/PortainerKit/Types/Endpoint.swift index f49bf77..64a900d 100644 --- a/Modules/PortainerKit/Sources/PortainerKit/Types/Endpoint.swift +++ b/Modules/PortainerKit/Sources/PortainerKit/Types/Endpoint.swift @@ -7,7 +7,7 @@ import Foundation -@available(iOS 15, macOS 12, *) +@available(iOS 14, macOS 11, *) public extension PortainerKit { class Endpoint: Identifiable, Codable { enum CodingKeys: String, CodingKey { diff --git a/Modules/PortainerKit/Sources/PortainerKit/Types/Generics.swift b/Modules/PortainerKit/Sources/PortainerKit/Types/Generics.swift index 601fb08..19cb5c9 100644 --- a/Modules/PortainerKit/Sources/PortainerKit/Types/Generics.swift +++ b/Modules/PortainerKit/Sources/PortainerKit/Types/Generics.swift @@ -9,7 +9,7 @@ import Foundation // MARK: - Generic enums -@available(iOS 15, macOS 12, *) +@available(iOS 14, macOS 11, *) public extension PortainerKit { enum ContainerStatus: String, Codable { case created @@ -68,7 +68,7 @@ public extension PortainerKit { // MARK: - Generic types -@available(iOS 15, macOS 12, *) +@available(iOS 14, macOS 11, *) public extension PortainerKit { struct AccessPolicy: Codable { enum CodingKeys: String, CodingKey { diff --git a/README.md b/README.md index 62b9fbe..ae314a1 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ - Build it yourself ## Coming up +- [ ] Container list view (instead of cells) - [ ] Widgets - [ ] Siri/Shortcuts - [ ] watchOS app diff --git a/Shared/Extensions+Modifiers/PortainerKit+.swift b/Shared/Extensions+Modifiers/PortainerKit+.swift index 643292d..c753099 100644 --- a/Shared/Extensions+Modifiers/PortainerKit+.swift +++ b/Shared/Extensions+Modifiers/PortainerKit+.swift @@ -23,7 +23,7 @@ extension PortainerKit.ExecuteAction { var color: Color { switch self { case .start: return .green - case .stop: return Color(uiColor: .darkGray) + case .stop: return Color(UIColor.darkGray) case .restart: return .blue case .kill: return .red case .pause: return .orange @@ -61,8 +61,8 @@ extension PortainerKit.ContainerStatus { case .running: return .green case .paused: return .orange case .restarting: return .blue - case .removing: return Color(uiColor: .lightGray) - case .exited: return Color(uiColor: .darkGray) + case .removing: return Color(UIColor.lightGray) + case .exited: return Color(UIColor.darkGray) case .dead: return .gray } }