diff --git a/Pi-helper.xcodeproj/project.pbxproj b/Pi-helper.xcodeproj/project.pbxproj index 3dfb29c..a5990cd 100644 --- a/Pi-helper.xcodeproj/project.pbxproj +++ b/Pi-helper.xcodeproj/project.pbxproj @@ -31,6 +31,7 @@ 28EC1E3A23A441F30088BA26 /* OrDivider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28EC1E3923A441F30088BA26 /* OrDivider.swift */; }; 28EC1E3C23A448940088BA26 /* Styles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28EC1E3B23A448940088BA26 /* Styles.swift */; }; 80654D7B24B0E92A007B1E1A /* DisableCustomTimeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80654D7A24B0E92A007B1E1A /* DisableCustomTimeView.swift */; }; + 80A8CF852782190400CCCEC5 /* AsyncData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80A8CF842782190400CCCEC5 /* AsyncData.swift */; }; 80E91FA723DE160100B7FB55 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80E91FA623DE160100B7FB55 /* AboutView.swift */; }; /* End PBXBuildFile section */ @@ -88,6 +89,7 @@ 28EC1E3B23A448940088BA26 /* Styles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Styles.swift; sourceTree = ""; }; 80436C6C27555BDE003D48E4 /* Pi-helper.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Pi-helper.entitlements"; sourceTree = ""; }; 80654D7A24B0E92A007B1E1A /* DisableCustomTimeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisableCustomTimeView.swift; sourceTree = ""; }; + 80A8CF842782190400CCCEC5 /* AsyncData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncData.swift; sourceTree = ""; }; 80E91FA623DE160100B7FB55 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -141,32 +143,33 @@ 2821266E235926E700072D52 /* Pi-helper */ = { isa = PBXGroup; children = ( + 28D7205B2380C2090038D439 /* resolver.c */, 80436C6C27555BDE003D48E4 /* Pi-helper.entitlements */, 28D720522380689E0038D439 /* PiHelper-Bridging-Header.h */, - 2821266F235926E700072D52 /* AppDelegate.swift */, - 28212671235926E700072D52 /* SceneDelegate.swift */, - 28212673235926E700072D52 /* ContentView.swift */, - 282126AD235BF21D00072D52 /* AddPiHoleView.swift */, - 28212675235926EB00072D52 /* Assets.xcassets */, - 2821267A235926EB00072D52 /* LaunchScreen.storyboard */, + 28D7205A2380C2090038D439 /* resolver.h */, 2821267D235926EB00072D52 /* Info.plist */, - 28212677235926EB00072D52 /* Preview Content */, - 282126A5235BE6EE00072D52 /* PiHoleDataStore.swift */, + 80E91FA623DE160100B7FB55 /* AboutView.swift */, + 282126AF235BF52F00072D52 /* ActivityIndicatorView.swift */, + 282126AD235BF21D00072D52 /* AddPiHoleView.swift */, + 2821266F235926E700072D52 /* AppDelegate.swift */, + 28212673235926E700072D52 /* ContentView.swift */, + 80654D7A24B0E92A007B1E1A /* DisableCustomTimeView.swift */, + 28D7205823808E8C0038D439 /* Extensions.swift */, + 280F0D6223DA9B1800D341B7 /* KeyboardHelper.swift */, + 28EC1E3923A441F30088BA26 /* OrDivider.swift */, 282126A7235BE72800072D52 /* PiHole.swift */, 282126A9235BEE0F00072D52 /* PiHoleApiService.swift */, + 282126A5235BE6EE00072D52 /* PiHoleDataStore.swift */, 282126AB235BF09800072D52 /* PiHoleDetailsView.swift */, - 282126AF235BF52F00072D52 /* ActivityIndicatorView.swift */, - 282126B6235C0F5400072D52 /* Localizable.strings */, - 28D7205823808E8C0038D439 /* Extensions.swift */, - 28D7205A2380C2090038D439 /* resolver.h */, - 28D7205B2380C2090038D439 /* resolver.c */, 280AA3EA2390B302007841F1 /* RetrieveApiKeyView.swift */, - 28EC1E3923A441F30088BA26 /* OrDivider.swift */, - 28EC1E3B23A448940088BA26 /* Styles.swift */, 28D2125B23A59057003B33F2 /* ScanningView.swift */, - 80654D7A24B0E92A007B1E1A /* DisableCustomTimeView.swift */, - 280F0D6223DA9B1800D341B7 /* KeyboardHelper.swift */, - 80E91FA623DE160100B7FB55 /* AboutView.swift */, + 28212671235926E700072D52 /* SceneDelegate.swift */, + 28EC1E3B23A448940088BA26 /* Styles.swift */, + 28212675235926EB00072D52 /* Assets.xcassets */, + 2821267A235926EB00072D52 /* LaunchScreen.storyboard */, + 282126B6235C0F5400072D52 /* Localizable.strings */, + 28212677235926EB00072D52 /* Preview Content */, + 80A8CF842782190400CCCEC5 /* AsyncData.swift */, ); path = "Pi-helper"; sourceTree = ""; @@ -358,6 +361,7 @@ 28D7205923808E8C0038D439 /* Extensions.swift in Sources */, 282126A8235BE72800072D52 /* PiHole.swift in Sources */, 80654D7B24B0E92A007B1E1A /* DisableCustomTimeView.swift in Sources */, + 80A8CF852782190400CCCEC5 /* AsyncData.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Pi-helper.xcodeproj/xcshareddata/xcschemes/PiHelper.xcscheme b/Pi-helper.xcodeproj/xcshareddata/xcschemes/PiHelper.xcscheme index 01c86c1..087261f 100644 --- a/Pi-helper.xcodeproj/xcshareddata/xcschemes/PiHelper.xcscheme +++ b/Pi-helper.xcodeproj/xcshareddata/xcschemes/PiHelper.xcscheme @@ -33,7 +33,7 @@ @@ -43,7 +43,7 @@ diff --git a/Pi-helper/ActivityIndicatorView.swift b/Pi-helper/ActivityIndicatorView.swift index 9f207eb..3e7e8ec 100644 --- a/Pi-helper/ActivityIndicatorView.swift +++ b/Pi-helper/ActivityIndicatorView.swift @@ -9,26 +9,22 @@ import SwiftUI struct ActivityIndicatorView: View { - var isAnimating: Binding @State private var rotation = 0.0 var body: some View { Image("logo") - .rotationEffect(.degrees(rotation)) + .resizable() + .frame(width: 100, height: 100) + .rotationEffect(.degrees(self.rotation)) + .animation(Animation.linear(duration: 1).repeatForever(autoreverses: false), value: self.rotation) .onAppear { - return withAnimation(self.isAnimating.wrappedValue ? Animation.linear(duration: 1).repeatForever(autoreverses: false) : Animation.linear) { - self.rotation = 360.0 - } - } - } - - init(_ isAnimating: Binding) { - self.isAnimating = isAnimating + self.rotation = 360 + } } } struct ActivityIndicatorView_Previews: PreviewProvider { static var previews: some View { - ActivityIndicatorView(.constant(true)) + ActivityIndicatorView() } } diff --git a/Pi-helper/AddPiHoleView.swift b/Pi-helper/AddPiHoleView.swift index d436d24..3d79596 100644 --- a/Pi-helper/AddPiHoleView.swift +++ b/Pi-helper/AddPiHoleView.swift @@ -10,6 +10,17 @@ import SwiftUI struct AddPiHoleView: View { @State var ipAddress: String = "" + @State var showScanFailed = false + @State var showConnectFailed = false + var hideNavigationBar: Bool { + get { + if case .scanning(_) = self.dataStore.pihole { + return true + } else { + return false + } + } + } var body: some View { NavigationView { @@ -17,9 +28,9 @@ struct AddPiHoleView: View { VStack(spacing: 30) { Image("logo") ScanButton(dataStore: self.dataStore) - .alert(isPresented: .constant(self.dataStore.pihole == .failure(.scanFailed)), content: { + .alert(isPresented: self.$showScanFailed, content: { Alert(title: Text("scan_failed"), message: Text("try_direct_connection"), dismissButton: .default(Text("OK"), action: { - self.dataStore.pihole = .failure(.missingIpAddress) + self.dataStore.pihole = .missingIpAddress })) }) OrDivider() @@ -28,20 +39,27 @@ struct AddPiHoleView: View { TextField("ip_address", text: $ipAddress) .textFieldStyle(RoundedBorderTextFieldStyle()) .keyboardType(.default) - Button(action: { - self.dataStore.connect(self.ipAddress) - }, label: { Text("connect") }) + .disableAutocorrection(true) + .autocapitalization(.none) + .onSubmit { + Task { + await self.dataStore.connect(self.ipAddress) + } + } + Button(action: { Task { + await self.dataStore.connect(self.ipAddress) + } }, label: { Text("connect") }) .buttonStyle(PiHelperButtonStyle()) - .alert(isPresented: .constant(self.dataStore.pihole == .failure(.connectionFailed)), content: { + .alert(isPresented: self.$showConnectFailed, content: { Alert(title: Text("connection_failed"), message: Text("verify_ip_address"), dismissButton: .default(Text("OK"), action: { - self.dataStore.pihole = .failure(.missingIpAddress) + self.dataStore.pihole = .missingIpAddress })) }) } .padding() .keyboardAwarePadding() } - .navigationBarHidden(self.dataStore.pihole == .failure(.missingIpAddress)) + .navigationBarHidden(self.hideNavigationBar) } .navigationViewStyle(StackNavigationViewStyle()) } @@ -68,17 +86,19 @@ struct ScanButton: View { Text("scan_for_pihole") .multilineTextAlignment(.center) Button(action: { - self.dataStore.beginScanning(ipAddress) + self.dataStore.scanTask = Task { + await self.dataStore.beginScanning(ipAddress) + } }, label: { Text("scan") }) .buttonStyle(PiHelperButtonStyle()) NavigationLink( destination: ScanningView(dataStore: self.dataStore), - isActive: .constant(self.dataStore.pihole == .failure(.scanning(""))), + isActive: $dataStore.isScanning, label: { EmptyView() } ) NavigationLink( destination: RetrieveApiKeyView(dataStore: self.dataStore), - isActive: .constant(self.dataStore.pihole == .failure(.missingApiKey) || self.dataStore.pihole == .failure(.invalidCredentials)), + isActive: .constant(self.dataStore.pihole == .missingApiKey), label: { EmptyView() } ) }.toAnyView() diff --git a/Pi-helper/AsyncData.swift b/Pi-helper/AsyncData.swift new file mode 100644 index 0000000..4906021 --- /dev/null +++ b/Pi-helper/AsyncData.swift @@ -0,0 +1,54 @@ +// +// AsyncData.swift +// Pi-helper +// +// Created by William Brawner on 1/2/22. +// Copyright © 2022 William Brawner. All rights reserved. +// + +import Foundation +import SwiftUI + +enum AsyncData: Equatable where Data: Equatable { + case empty + case loading + case error(Error) + case success(Data) + + var value: Data? { + get { + if case let .success(data) = self { + return data + } else { + return nil + } + } + set {} + } + + var error: Error? { + get { + if case let .error(error) = self { + return error + } else { + return nil + } + } + set {} + } + + static func == (lhs: AsyncData, rhs: AsyncData) -> Bool { + switch (lhs, rhs) { + case (.empty, .empty): + return true + case (.loading, .loading): + return true + case (.error(let lError), .error(let rError)): + return rError.localizedDescription == lError.localizedDescription + case (.success(let lData), .success(let rData)): + return lData == rData + default: + return false + } + } +} diff --git a/Pi-helper/DisableCustomTimeView.swift b/Pi-helper/DisableCustomTimeView.swift index db9b98f..77c530d 100644 --- a/Pi-helper/DisableCustomTimeView.swift +++ b/Pi-helper/DisableCustomTimeView.swift @@ -25,9 +25,9 @@ struct DisableCustomTimeView: View { } } .pickerStyle(SegmentedPickerStyle()) - Button(action: { - self.dataStore.disable(Int(self.duration) ?? 0, unit: self.unit) - }, label: { Text(LocalizedStringKey("disable")) }) + Button(action: { Task { + await self.dataStore.disable(Int(self.duration) ?? 0, unit: self.unit) + } }, label: { Text(LocalizedStringKey("disable")) }) .buttonStyle(PiHelperButtonStyle()) } .padding() diff --git a/Pi-helper/PiHole.swift b/Pi-helper/PiHole.swift index 461cf54..7ec18e8 100644 --- a/Pi-helper/PiHole.swift +++ b/Pi-helper/PiHole.swift @@ -157,7 +157,7 @@ struct TopItemsResponse: Codable { } } -enum PiHoleStatus: Equatable { +enum Status: Equatable { case enabled case disabled(_ duration: String? = nil) case unknown diff --git a/Pi-helper/PiHoleApiService.swift b/Pi-helper/PiHoleApiService.swift index 3cdc930..65bfe8b 100644 --- a/Pi-helper/PiHoleApiService.swift +++ b/Pi-helper/PiHoleApiService.swift @@ -7,7 +7,6 @@ // import Foundation -import Combine class PiHoleApiService { let decoder: JSONDecoder @@ -20,70 +19,61 @@ class PiHoleApiService { self.decoder = decoder } - func getVersion() -> AnyPublisher { - return get(queries: ["version": ""]) + func getVersion() async throws -> VersionResponse { + return try await get(queries: ["version": ""]) } - func loadSummary() -> AnyPublisher { - return get() + func loadSummary() async throws -> PiHole { + return try await get() } - func enable() -> AnyPublisher { - return get(true, queries: ["enable": ""]) + func enable() async throws -> StatusUpdate { + return try await get(true, queries: ["enable": ""]) } - func getTopItems() -> AnyPublisher { - return get(true, queries: ["topItems": "25"]) + func getTopItems() async throws -> TopItemsResponse { + return try await get(true, queries: ["topItems": "25"]) } - func disable(_ forSeconds: Int? = nil) -> AnyPublisher { + func disable(_ forSeconds: Int? = nil) async throws -> StatusUpdate { var params = [String: String]() if let timeToDisable = forSeconds { params["disable"] = String(timeToDisable) } else { params["disable"] = "" } - return get(true, queries: params) + return try await get(true, queries: params) } - func getCustomDisableTimer() -> AnyPublisher { + func getCustomDisableTimer() async throws -> UInt { guard let baseUrl = self.baseUrl else { - return Result.Publisher(.failure(.invalidUrl)) - .eraseToAnyPublisher() + throw NetworkError.invalidUrl } guard let url = URL(string: baseUrl + "/custom_disable_timer") else { - return Result.Publisher(.failure(.invalidUrl)) - .eraseToAnyPublisher() + throw NetworkError.invalidUrl } var request = URLRequest(url: url) request.httpMethod = "GET" request.timeoutInterval = 0.5 - let task = URLSession.shared.dataTaskPublisher(for: request) - .tryMap { (data, res) -> UInt in - guard let response = res as? HTTPURLResponse, 200...299 ~= response.statusCode else { - switch (res as? HTTPURLResponse)?.statusCode { - case 400: throw NetworkError.badRequest - case 401, 403: throw NetworkError.unauthorized - case 404: throw NetworkError.notFound - default: throw NetworkError.unknown - } - } - let dataString = String(data: data, encoding: .utf8) ?? "0" - return UInt(dataString) ?? 0 + let (data, res) = try await URLSession.shared.data(for: request) + guard let response = res as? HTTPURLResponse, 200...299 ~= response.statusCode else { + switch (res as? HTTPURLResponse)?.statusCode { + case 400: throw NetworkError.badRequest + case 401, 403: throw NetworkError.unauthorized + case 404: throw NetworkError.notFound + default: throw NetworkError.unknown(nil) + } } - .mapError { - return $0 as! NetworkError - } - return task.eraseToAnyPublisher() + let dataString = String(data: data, encoding: .utf8) ?? "0" + return UInt(dataString) ?? 0 } private func get( _ requiresAuth: Bool = false, queries: [String: String]? = nil - ) -> AnyPublisher { + ) async throws -> ResultType { guard let baseUrl = self.baseUrl else { - return Result.Publisher(.failure(.invalidUrl)) - .eraseToAnyPublisher() + throw NetworkError.invalidUrl } var combinedEndPoint = baseUrl + "/admin/api.php" @@ -101,7 +91,7 @@ class PiHoleApiService { } guard let url = URL(string: combinedEndPoint) else { - return Result.Publisher(.failure(.invalidUrl)).eraseToAnyPublisher() + throw NetworkError.invalidUrl } var request = URLRequest(url: url) @@ -109,23 +99,20 @@ class PiHoleApiService { request.httpMethod = "GET" request.timeoutInterval = 0.5 - let task = URLSession.shared.dataTaskPublisher(for: request) - .tryMap { (data, res) -> Data in - guard let response = res as? HTTPURLResponse, 200...299 ~= response.statusCode else { - switch (res as? HTTPURLResponse)?.statusCode { - case 400: throw NetworkError.badRequest - case 401, 403: throw NetworkError.unauthorized - case 404: throw NetworkError.notFound - default: throw NetworkError.unknown - } + do { + let (data, res) = try await URLSession.shared.data(for: request) + guard let response = res as? HTTPURLResponse, 200...299 ~= response.statusCode else { + switch (res as? HTTPURLResponse)?.statusCode { + case 400: throw NetworkError.badRequest + case 401, 403: throw NetworkError.unauthorized + case 404: throw NetworkError.notFound + default: throw NetworkError.unknown(nil) } - return data + } + return try self.decoder.decode(ResultType.self, from: data) + } catch { + throw NetworkError.unknown(error) } - .decode(type: ResultType.self, decoder: self.decoder) - .mapError { - return NetworkError.jsonParsingFailed($0) - } - return task.eraseToAnyPublisher() } } @@ -135,14 +122,12 @@ enum NetworkError: Error, Equatable { case badRequest case notFound case unauthorized - case unknown + case unknown(Error?) case invalidUrl case jsonParsingFailed(Error) static func == (lhs: NetworkError, rhs: NetworkError) -> Bool { switch (lhs, rhs) { - case (.loading, .loading): - return true case (.cancelled, .cancelled): return true case (.badRequest, .badRequest): @@ -151,8 +136,8 @@ enum NetworkError: Error, Equatable { return true case (.unauthorized, .unauthorized): return true - case (.unknown, .unknown): - return true + case (.unknown(let error1), .unknown(let error2)): + return error1?.localizedDescription == error2?.localizedDescription case (.invalidUrl, .invalidUrl): return true case (.jsonParsingFailed(let error1), .jsonParsingFailed(let error2)): @@ -162,5 +147,3 @@ enum NetworkError: Error, Equatable { } } } - -struct Empty: Codable {} diff --git a/Pi-helper/PiHoleDataStore.swift b/Pi-helper/PiHoleDataStore.swift index c72c2f6..e86e28e 100644 --- a/Pi-helper/PiHoleDataStore.swift +++ b/Pi-helper/PiHoleDataStore.swift @@ -10,12 +10,25 @@ import Foundation import Combine import UIKit +@MainActor class PiHoleDataStore: ObservableObject { private let IP_MIN = 0 private let IP_MAX = 255 - private var currentRequest: AnyCancellable? = nil + var isScanning: Bool { + get { + if case .scanning(_) = self.pihole { + return true + } else { + return false + } + } + set { + // No op + } + } @Published var showCustomDisableView = false - @Published var pihole: Result + @Published var pihole: PiholeStatus = .empty + @Published var error: PiHoleError? = nil @Published var apiKey: String? = nil { didSet { UserDefaults.standard.set(apiKey, forKey: PiHoleDataStore.API_KEY) @@ -30,9 +43,10 @@ class PiHoleDataStore: ObservableObject { } } private var shouldMonitorStatus = false + var scanTask: Task? = nil private func prependScheme(_ ipAddress: String?) -> String? { - guard let host = ipAddress else { + guard let host = ipAddress?.lowercased() else { return nil } @@ -47,162 +61,109 @@ class PiHoleDataStore: ObservableObject { return host } - func monitorStatus() { - self.shouldMonitorStatus = true - doMonitorStatus() - } - - private func doMonitorStatus() { - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: { - if !self.shouldMonitorStatus { - return + func monitorStatus() async { + while !Task.isCancelled { + do { + try await Task.sleep(nanoseconds: 1_000_000_000) + await loadSummary() + } catch { + break } - self.loadSummary { - self.doMonitorStatus() - } - }) - } - - func stopMonitoring() { - self.shouldMonitorStatus = false - } - - func beginScanning(_ ipAddress: String) { - var addressParts = ipAddress.split(separator: ".") - var chunks = 1 - var ipAddresses = [String]() - ipAddresses.append("pi.hole") - while chunks <= IP_MAX { - let chunkSize = (IP_MAX - IP_MIN + 1) / chunks - if chunkSize == 1 { - return - } - for chunk in 0.. Bool { + self.pihole = PiholeStatus.scanning(ipAddress) + do { + self.apiService.baseUrl = prependScheme(ipAddress) + _ = try await self.apiService.getVersion() + self.baseUrl = self.apiService.baseUrl + self.pihole = PiholeStatus.missingApiKey + return true + } catch { + return false } - - guard let ipAddress = prependScheme(ipAddresses[0]) else { - return - } - self.apiService.baseUrl = ipAddress - self.pihole = .failure(.scanning(ipAddresses[0])) - currentRequest = self.apiService.getVersion() - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { (completion) in - switch completion { - case .finished: - return - case .failure(let error): - // ignore if timeout, otherwise handle - print(error) - self.scan(Array(ipAddresses.dropFirst())) - } - }, receiveValue: { version in - // Stop scans, load summary - self.baseUrl = ipAddress - self.pihole = .failure(.missingApiKey) - }) } func forgetPihole() { self.baseUrl = nil self.apiKey = nil - self.pihole = .failure(.missingIpAddress) + self.pihole = PiholeStatus.missingIpAddress } - func connectWithPassword(_ password: String) { + func connectWithPassword(_ password: String) async { if let hash = password.sha256Hash()?.sha256Hash() { - connectWithApiKey(hash) + await connectWithApiKey(hash) } else { - self.pihole = .failure(.invalidCredentials) + self.error = .invalidCredentials } } - func connectWithApiKey(_ apiToken: String) { + func connectWithApiKey(_ apiToken: String) async { self.apiService.apiKey = apiToken - currentRequest = self.apiService.getTopItems() - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { (completion) in - switch completion { - case .finished: - return - case .failure(let error): - self.pihole = .failure(.invalidCredentials) - print("\(error)") - } - }, receiveValue: { topItems in - self.apiKey = apiToken - }) - - } - - func cancelRequest() { - self.currentRequest?.cancel() - if (self.pihole != .failure(.missingApiKey)) { - // TODO: Find a better way to handle this - // The problem is that without this check, the scanning functionality is essentially broken because - // it finds the correct IP, navigates to the authentication screen, but then immediately navigates - // back to the IP input screen. - self.pihole = .failure(.networkError(.cancelled)) - } - } - - func loadSummary(completionBlock: (() -> Void)? = nil) { - var previousStatus: PiHoleStatus? = nil do { - previousStatus = try self.pihole.get() - } catch _ { + _ = try await self.apiService.getTopItems() + self.apiKey = apiToken + } catch { + self.error = .invalidCredentials } - self.pihole = .failure(.loading(previousStatus)) - - self.currentRequest = apiService.loadSummary() - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { (completion) in - switch completion { - case .finished: - self.currentRequest = nil - case .failure(let error): - self.pihole = .failure(.networkError(error)) - } - if let completionBlock = completionBlock { - completionBlock() - } - }, receiveValue: { pihole in + } + + func loadSummary() async { + var loadingTask: Task? = nil + if case let .success(previousState) = self.pihole { + // Avoid showing the loading spinner immediately + loadingTask = Task { + try await Task.sleep(nanoseconds: 1_000_000_000) + self.pihole = .loading(previousState) + } + } else { + self.pihole = .loading() + } + do { + let pihole = try await apiService.loadSummary() + await MainActor.run { UIApplication.shared.shortcutItems = [ UIApplicationShortcutItem( type: ShortcutAction.enable.rawValue, @@ -233,64 +194,57 @@ class PiHoleDataStore: ObservableObject { userInfo: ["forSeconds": 300 as NSSecureCoding] ) ] - self.updateStatus(status: pihole.status) - }) - } - - func enable() { - var previousStatus: PiHoleStatus? = nil - do { - previousStatus = try self.pihole.get() - } catch _ { + } + await self.updateStatus(pihole.status) + loadingTask?.cancel() + } catch { + if let error = error as? NetworkError { + self.error = .networkError(error) + } else { + print("Unhandled error! \(error)") + } } - self.pihole = .failure(.loading(previousStatus)) - self.currentRequest = self.apiService.enable() - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { (completion) in - switch completion { - case .finished: - self.currentRequest = nil - return - case .failure(let error): - self.pihole = .failure(.networkError(error)) - } - }, receiveValue: { newStatus in - self.updateStatus(status: newStatus.status) - }) } - func disable(_ forDuration: Int, unit: Int) { + func enable() async { + var previousStatus: Status? = nil + if case let .success(status) = self.pihole { + previousStatus = status + } + self.pihole = .loading(previousStatus) + do { + let status = try await self.apiService.enable() + await self.updateStatus(status.status) + } catch { + self.error = .networkError(error as! NetworkError) + } + } + + func disable(_ forDuration: Int, unit: Int) async { let multiplier = NSDecimalNumber(decimal: pow(60, unit)).intValue - disable(forDuration * multiplier) + await disable(forDuration * multiplier) } - func disable(_ forSeconds: Int? = nil) { - self.showCustomDisableView = false - var previousStatus: PiHoleStatus? = nil - do { - previousStatus = try self.pihole.get() - } catch _ { + func disable(_ forSeconds: Int? = nil) async { + var previousStatus: Status? = nil + if case let .success(status) = self.pihole { + previousStatus = status + } + self.pihole = .loading(previousStatus) + do { + let status = try await self.apiService.disable(forSeconds) + await self.updateStatus(status.status) + } catch { + if let error = error as? NetworkError { + self.error = .networkError(error) + } } - self.pihole = .failure(.loading(previousStatus)) - self.currentRequest = self.apiService.disable(forSeconds) - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { (completion) in - switch completion { - case .finished: - self.currentRequest = nil - return - case .failure(let error): - self.pihole = .failure(.networkError(error)) - } - }, receiveValue: { newStatus in - self.updateStatus(status: newStatus.status) - }) } - private func updateStatus(status: String) { + private func updateStatus(_ status: String) async { switch status { case "disabled": - self.getDisabledDuration() + await self.getDisabledDuration() default: self.pihole = .success(.enabled) } @@ -298,27 +252,20 @@ class PiHoleDataStore: ObservableObject { private var customDisableTimeRequest: AnyCancellable? = nil - private func getDisabledDuration() { - self.customDisableTimeRequest = self.apiService.getCustomDisableTimer() - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { (completion) in - switch completion { - case .finished: - return - case .failure(_): - self.pihole = .success(.disabled()) - self.customDisableTimeRequest = nil - } - }, receiveValue: { timestamp in - let disabledUntil = TimeInterval(round(Double(timestamp) / 1000.0)) - let now = Date().timeIntervalSince1970 - if now > disabledUntil { - self.pihole = .success(.disabled()) - } else { - self.pihole = .success(.disabled(UInt(disabledUntil - now).toDurationString())) - } - self.customDisableTimeRequest = nil - }) + private func getDisabledDuration() async { + do { + let timestamp = try await self.apiService.getCustomDisableTimer() + let disabledUntil = TimeInterval(round(Double(timestamp) / 1000.0)) + let now = Date().timeIntervalSince1970 + if now > disabledUntil { + self.pihole = .success(.disabled()) + } else { + self.pihole = .success(.disabled(UInt(disabledUntil - now).toDurationString())) + } + self.customDisableTimeRequest = nil + } catch { + self.pihole = .success(.disabled()) + } } let apiService = PiHoleApiService() @@ -329,12 +276,11 @@ class PiHoleDataStore: ObservableObject { self.baseUrl = baseUrl self.apiKey = apiKey if baseUrl == nil { - self.pihole = .failure(.missingIpAddress) + self.pihole = .missingIpAddress } else if apiKey == nil { - self.pihole = .failure(.missingApiKey) + self.pihole = .missingApiKey } else { - self.pihole = .failure(.networkError(.loading)) - self.loadSummary() + self.pihole = .loading(nil) } } } @@ -344,32 +290,49 @@ enum ShortcutAction: String { case disable = "DisableAction" } -enum PiHoleError : Error, Equatable { - case networkError(_ error: NetworkError) - case loading(_ previousStatus: PiHoleStatus? = nil) +enum PiholeStatus: Equatable { + case empty + case loading(_ previousStatus: Status? = nil) + case error(_ error: Error) case scanning(_ ipAddress: String) case missingIpAddress case missingApiKey + case success(_ status: Status) + + static func == (lhs: PiholeStatus, rhs: PiholeStatus) -> Bool { + switch (lhs, rhs) { + case (.loading(let left), .loading(let right)): + return left == right + case (.scanning(let left), .scanning(let right)): + return left == right + case (.missingIpAddress, .missingIpAddress): + return true + case (.missingApiKey, .missingApiKey): + return true + case (.success(let left), .success(let right)): + return left == right + default: + return false + } + } +} + +enum PiHoleError : Error, Equatable { + case networkError(_ error: NetworkError) case invalidCredentials case scanFailed - case connectionFailed + case connectionFailed(_ host: String) static func == (lhs: PiHoleError, rhs: PiHoleError) -> Bool { switch (lhs, rhs) { case (.networkError(let error1), .networkError(let error2)): return error1 == error2 - case (.scanning(_), .scanning(_)): - return true - case (.missingIpAddress, .missingIpAddress): - return true - case (.missingApiKey, .missingApiKey): - return true case (.invalidCredentials, .invalidCredentials): return true case (.scanFailed, .scanFailed): return true - case (.connectionFailed, .connectionFailed): - return true + case (.connectionFailed(let lIp), .connectionFailed(let rIp)): + return lIp == rIp default: return false } diff --git a/Pi-helper/PiHoleDetailsView.swift b/Pi-helper/PiHoleDetailsView.swift index 54c2622..93a1c8b 100644 --- a/Pi-helper/PiHoleDetailsView.swift +++ b/Pi-helper/PiHoleDetailsView.swift @@ -9,30 +9,32 @@ import SwiftUI struct PiHoleDetailsView: View { - var stateContent: AnyView { + @ViewBuilder + func stateContent() -> some View { switch self.dataStore.pihole { case .success(let pihole): - return PiHoleActionsView(dataStore: self.dataStore, status: pihole).toAnyView() - case .failure(.loading(let previousStatus)): + PiHoleActionsView(dataStore: self.dataStore, status: pihole) + case .loading(let previousStatus): if let status = previousStatus { - return PiHoleActionsView(dataStore: self.dataStore, status: status).toAnyView() + PiHoleActionsView(dataStore: self.dataStore, status: status) } else { - return ActivityIndicatorView(.constant(true)).toAnyView() + ActivityIndicatorView() } - case .failure(.networkError(let error)): + case .error(let error): switch (error) { default: - return PiHoleActionsView(dataStore: self.dataStore, status: .unknown).toAnyView() + PiHoleActionsView(dataStore: self.dataStore, status: .unknown) } default: - return ActivityIndicatorView(.constant(true)).toAnyView() + ActivityIndicatorView() } } var body: some View { NavigationView { - stateContent - .navigationBarTitle("PiHelper") + stateContent() + .animation(.default, value: self.dataStore.pihole) + .navigationBarTitle("Pi-helper") .navigationBarItems(trailing: NavigationLink(destination: AboutView(self.dataStore), label: { Image(systemName: "info.circle") .padding() @@ -40,10 +42,9 @@ struct PiHoleDetailsView: View { } .navigationViewStyle(StackNavigationViewStyle()) .onAppear { - self.dataStore.monitorStatus() - } - .onDisappear { - self.dataStore.stopMonitoring() + Task { + await self.dataStore.monitorStatus() + } } } @@ -58,53 +59,32 @@ struct PiHoleActionsView: View { VStack { HStack { Text("status") - PiHoleStatusView(status) + PiholeStatusView(status) } - DurationView(status) PiHoleActions(self.dataStore, status: status) } } let dataStore: PiHoleDataStore - let status: PiHoleStatus + let status: Status } -struct PiHoleStatusView: View { +struct PiholeStatusView: View { + @ViewBuilder var body: some View { - Text(status.localizedStringKey).foregroundColor(status.foregroundColor) - } - - let status: PiHoleStatus - init(_ status: PiHoleStatus) { - self.status = status - } -} - -struct DurationView: View { - var body: some View { - stateContent - } - - var stateContent: AnyView { - switch status { - case .disabled(let duration): - if let durationString = duration { - return HStack { - Text("time_remaining") - Text(durationString) - .frame(minWidth: 30) - } - .toAnyView() - } else { - return EmptyView().toAnyView() + HStack { + Text(status.localizedStringKey) + .foregroundColor(status.foregroundColor) + if case let .disabled(duration) = status, let durationString = duration { + Text("(\(durationString))") + .monospacedDigit() + .foregroundColor(status.foregroundColor) } - default: - return EmptyView().toAnyView() } } - let status: PiHoleStatus - init(_ status: PiHoleStatus) { + let status: Status + init(_ status: Status) { self.status = status } } @@ -117,34 +97,44 @@ struct PiHoleActions: View { var stateContent: AnyView { switch status { case .disabled: - return Button(action: { self.dataStore.enable() }, label: { Text("enable") }) + return Button(action: { Task { + await self.dataStore.enable() + } }, label: { Text("enable") }) .buttonStyle(PiHelperButtonStyle(.green)) .padding(Edge.Set(arrayLiteral: [.bottom, .top]), 5.0) .toAnyView() case .enabled: return VStack { - Button(action: { self.dataStore.disable(10) }, label: { Text("disable_10_sec") }) + Button(action: { Task { + await self.dataStore.disable(10) + } }, label: { Text("disable_10_sec") }) .buttonStyle(PiHelperButtonStyle()) .padding(Edge.Set(arrayLiteral: [.bottom, .top]), 5.0) - Button(action: { self.dataStore.disable(30) }, label: { Text("disable_30_sec") }) + Button(action: { Task{ + await self.dataStore.disable(30) + } }, label: { Text("disable_30_sec") }) .buttonStyle(PiHelperButtonStyle()) .padding(Edge.Set(arrayLiteral: [.bottom, .top]), 5.0) - Button(action: { self.dataStore.disable(300) }, label: { Text("disable_5_min") }) + Button(action: { Task { + await self.dataStore.disable(300) + } }, label: { Text("disable_5_min") }) .buttonStyle(PiHelperButtonStyle()) .padding(Edge.Set(arrayLiteral: [.bottom, .top]), 5.0) - NavigationLink( - destination: DisableCustomTimeView(self.dataStore), - isActive: .constant(self.dataStore.showCustomDisableView), - label: { EmptyView() } - ) Button(action: { self.dataStore.showCustomDisableView = true }, label: { Text("disable_custom") }) .buttonStyle(PiHelperButtonStyle()) .padding(Edge.Set(arrayLiteral: [.bottom, .top]), 5.0) - Button(action: { self.dataStore.disable() }, label: { Text("disable_permanent") }) + Button(action: { Task { + await self.dataStore.disable() + } }, label: { Text("disable_permanent") }) .buttonStyle(PiHelperButtonStyle()) .padding(Edge.Set(arrayLiteral: [.bottom, .top]), 5.0) + NavigationLink( + destination: DisableCustomTimeView(self.dataStore), + isActive: self.$dataStore.showCustomDisableView, + label: { EmptyView() } + ) }.toAnyView() default: return Text("Unable to load Pi-hole status. Please verify your credentials and ensure the Pi-hole is accessible from your current network.") @@ -153,8 +143,8 @@ struct PiHoleActions: View { } @ObservedObject var dataStore: PiHoleDataStore - let status: PiHoleStatus - init(_ dataStore: PiHoleDataStore, status: PiHoleStatus) { + let status: Status + init(_ dataStore: PiHoleDataStore, status: Status) { self.dataStore = dataStore self.status = status } @@ -164,7 +154,7 @@ struct PiHoleDetailsView_Previews: PreviewProvider { static var dataStore: PiHoleDataStore { get { let _dataStore = PiHoleDataStore() - _dataStore.pihole = .success(.disabled("20")) + _dataStore.pihole = PiholeStatus.success(Status.disabled("20")) return _dataStore } } diff --git a/Pi-helper/RetrieveApiKeyView.swift b/Pi-helper/RetrieveApiKeyView.swift index f4c6d7a..2463522 100644 --- a/Pi-helper/RetrieveApiKeyView.swift +++ b/Pi-helper/RetrieveApiKeyView.swift @@ -11,6 +11,15 @@ import SwiftUI struct RetrieveApiKeyView: View { @State var apiKey: String = "" @State var password: String = "" + var showAlert: Bool { + get { + if case .invalidCredentials = self.dataStore.error { + return true + } else { + return false + } + } + } var body: some View { ScrollView { @@ -22,32 +31,42 @@ struct RetrieveApiKeyView: View { .multilineTextAlignment(.center) SecureField("prompt_password", text: self.$password) .textFieldStyle(RoundedBorderTextFieldStyle()) - Button(action: { - self.dataStore.connectWithPassword(self.password) - }, label: { + .onSubmit { + Task { + await self.dataStore.connectWithPassword(self.password) + } + } + Button(action: { Task { + await self.dataStore.connectWithPassword(self.password) + } }, label: { Text("connect_with_password") }) .buttonStyle(PiHelperButtonStyle()) OrDivider() SecureField("prompt_api_key", text: self.$apiKey) .textFieldStyle(RoundedBorderTextFieldStyle()) - Button(action: { - self.dataStore.connectWithApiKey(self.apiKey) - }, label: { + .onSubmit { + Task { + await self.dataStore.connectWithApiKey(self.apiKey) + } + } + Button(action: { Task { + await self.dataStore.connectWithApiKey(self.apiKey) + } }, label: { Text("connect_with_api_key") }) .buttonStyle(PiHelperButtonStyle()) } .padding() .keyboardAwarePadding() - .alert(isPresented: .constant(self.dataStore.pihole == .failure(.invalidCredentials)), content: { + .alert(isPresented: .constant(showAlert), content: { Alert(title: Text("connection_failed"), message: Text("verify_credentials"), dismissButton: .default(Text("OK"), action: { - self.dataStore.pihole = .failure(.missingApiKey) + self.dataStore.pihole = .missingApiKey })) }) } .onDisappear { - self.dataStore.pihole = .failure(.missingIpAddress) + self.dataStore.pihole = .missingIpAddress } } diff --git a/Pi-helper/ScanningView.swift b/Pi-helper/ScanningView.swift index 928346a..50d9fc3 100644 --- a/Pi-helper/ScanningView.swift +++ b/Pi-helper/ScanningView.swift @@ -12,39 +12,34 @@ struct ScanningView: View { @Environment(\.presentationMode) var presentationMode @ObservedObject var dataStore: PiHoleDataStore - var stateContent: AnyView { + @ViewBuilder + var body: some View { switch dataStore.pihole { - case .failure(.scanning(let ipAddress)): - return ScrollView { + case .scanning(let ipAddress): + ScrollView { VStack(spacing: 10) { - ActivityIndicatorView(.constant(true)) - Text("scanning_ip_address") - Text(verbatim: ipAddress) - Button(action: { - self.dataStore.cancelRequest() - }, label: { Text("cancel") }) - .buttonStyle(PiHelperButtonStyle()) + ActivityIndicatorView() + Text("scanning_ip_address") + Text(verbatim: ipAddress) + Button(action: { + self.dataStore.cancelScanning() + }, label: { Text("cancel") }) + .buttonStyle(PiHelperButtonStyle()) }.padding() - }.toAnyView() + } default: - self.presentationMode.wrappedValue.dismiss() - return EmptyView().toAnyView() + EmptyView().onAppear { + self.presentationMode.wrappedValue.dismiss() + } } } - - var body: some View { - stateContent - .onDisappear { - self.dataStore.cancelRequest() - } - } } - + struct ScanningView_Previews: PreviewProvider { static var dataStore: PiHoleDataStore { get { let dataStore = PiHoleDataStore() - dataStore.pihole = .failure(.scanning("127.0.0.1")) + dataStore.pihole = .scanning("127.0.0.1") return dataStore } } diff --git a/Pi-helper/SceneDelegate.swift b/Pi-helper/SceneDelegate.swift index 2b35d12..354d3df 100644 --- a/Pi-helper/SceneDelegate.swift +++ b/Pi-helper/SceneDelegate.swift @@ -22,11 +22,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { // If we already have the address of a Pi we've previously connected to, try that let apiKey = UserDefaults.standard.string(forKey: PiHoleDataStore.API_KEY) self.dataStore = PiHoleDataStore(baseUrl: host, apiKey: apiKey) - //} else if let cString = resolver_get_dns_server_ip() { - // Otherwise the Pi is likely the DNS server (though not always true), so we can try connecting to it here - // self.dataStore!.scan(String(cString: cString)) } else { self.dataStore = PiHoleDataStore() + Task { + await self.dataStore?.scan("pi.hole") + } } // Create the SwiftUI view that provides the window contents. @@ -55,10 +55,14 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { // Is there a shortcut item that has not yet been processed? if let shortcutItem = (UIApplication.shared.delegate as! AppDelegate).shortcutItemToProcess { if shortcutItem.type == ShortcutAction.enable.rawValue { - self.dataStore?.enable() + Task { + await self.dataStore?.enable() + } } else { let amount = shortcutItem.userInfo?["forSeconds"] as? Int - self.dataStore?.disable(amount) + Task { + await self.dataStore?.disable(amount) + } } // Reset the shorcut item so it's never processed twice.