Migrate to async/await

This commit is contained in:
William Brawner 2022-03-03 19:35:21 -07:00
parent ea47113615
commit a0bc5b98cf
13 changed files with 445 additions and 417 deletions

View file

@ -31,6 +31,7 @@
28EC1E3A23A441F30088BA26 /* OrDivider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28EC1E3923A441F30088BA26 /* OrDivider.swift */; }; 28EC1E3A23A441F30088BA26 /* OrDivider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28EC1E3923A441F30088BA26 /* OrDivider.swift */; };
28EC1E3C23A448940088BA26 /* Styles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28EC1E3B23A448940088BA26 /* Styles.swift */; }; 28EC1E3C23A448940088BA26 /* Styles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28EC1E3B23A448940088BA26 /* Styles.swift */; };
80654D7B24B0E92A007B1E1A /* DisableCustomTimeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80654D7A24B0E92A007B1E1A /* DisableCustomTimeView.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 */; }; 80E91FA723DE160100B7FB55 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80E91FA623DE160100B7FB55 /* AboutView.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
@ -88,6 +89,7 @@
28EC1E3B23A448940088BA26 /* Styles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Styles.swift; sourceTree = "<group>"; }; 28EC1E3B23A448940088BA26 /* Styles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Styles.swift; sourceTree = "<group>"; };
80436C6C27555BDE003D48E4 /* Pi-helper.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Pi-helper.entitlements"; sourceTree = "<group>"; }; 80436C6C27555BDE003D48E4 /* Pi-helper.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Pi-helper.entitlements"; sourceTree = "<group>"; };
80654D7A24B0E92A007B1E1A /* DisableCustomTimeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisableCustomTimeView.swift; sourceTree = "<group>"; }; 80654D7A24B0E92A007B1E1A /* DisableCustomTimeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisableCustomTimeView.swift; sourceTree = "<group>"; };
80A8CF842782190400CCCEC5 /* AsyncData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncData.swift; sourceTree = "<group>"; };
80E91FA623DE160100B7FB55 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; }; 80E91FA623DE160100B7FB55 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@ -141,32 +143,33 @@
2821266E235926E700072D52 /* Pi-helper */ = { 2821266E235926E700072D52 /* Pi-helper */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
28D7205B2380C2090038D439 /* resolver.c */,
80436C6C27555BDE003D48E4 /* Pi-helper.entitlements */, 80436C6C27555BDE003D48E4 /* Pi-helper.entitlements */,
28D720522380689E0038D439 /* PiHelper-Bridging-Header.h */, 28D720522380689E0038D439 /* PiHelper-Bridging-Header.h */,
2821266F235926E700072D52 /* AppDelegate.swift */, 28D7205A2380C2090038D439 /* resolver.h */,
28212671235926E700072D52 /* SceneDelegate.swift */,
28212673235926E700072D52 /* ContentView.swift */,
282126AD235BF21D00072D52 /* AddPiHoleView.swift */,
28212675235926EB00072D52 /* Assets.xcassets */,
2821267A235926EB00072D52 /* LaunchScreen.storyboard */,
2821267D235926EB00072D52 /* Info.plist */, 2821267D235926EB00072D52 /* Info.plist */,
28212677235926EB00072D52 /* Preview Content */, 80E91FA623DE160100B7FB55 /* AboutView.swift */,
282126A5235BE6EE00072D52 /* PiHoleDataStore.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 */, 282126A7235BE72800072D52 /* PiHole.swift */,
282126A9235BEE0F00072D52 /* PiHoleApiService.swift */, 282126A9235BEE0F00072D52 /* PiHoleApiService.swift */,
282126A5235BE6EE00072D52 /* PiHoleDataStore.swift */,
282126AB235BF09800072D52 /* PiHoleDetailsView.swift */, 282126AB235BF09800072D52 /* PiHoleDetailsView.swift */,
282126AF235BF52F00072D52 /* ActivityIndicatorView.swift */,
282126B6235C0F5400072D52 /* Localizable.strings */,
28D7205823808E8C0038D439 /* Extensions.swift */,
28D7205A2380C2090038D439 /* resolver.h */,
28D7205B2380C2090038D439 /* resolver.c */,
280AA3EA2390B302007841F1 /* RetrieveApiKeyView.swift */, 280AA3EA2390B302007841F1 /* RetrieveApiKeyView.swift */,
28EC1E3923A441F30088BA26 /* OrDivider.swift */,
28EC1E3B23A448940088BA26 /* Styles.swift */,
28D2125B23A59057003B33F2 /* ScanningView.swift */, 28D2125B23A59057003B33F2 /* ScanningView.swift */,
80654D7A24B0E92A007B1E1A /* DisableCustomTimeView.swift */, 28212671235926E700072D52 /* SceneDelegate.swift */,
280F0D6223DA9B1800D341B7 /* KeyboardHelper.swift */, 28EC1E3B23A448940088BA26 /* Styles.swift */,
80E91FA623DE160100B7FB55 /* AboutView.swift */, 28212675235926EB00072D52 /* Assets.xcassets */,
2821267A235926EB00072D52 /* LaunchScreen.storyboard */,
282126B6235C0F5400072D52 /* Localizable.strings */,
28212677235926EB00072D52 /* Preview Content */,
80A8CF842782190400CCCEC5 /* AsyncData.swift */,
); );
path = "Pi-helper"; path = "Pi-helper";
sourceTree = "<group>"; sourceTree = "<group>";
@ -358,6 +361,7 @@
28D7205923808E8C0038D439 /* Extensions.swift in Sources */, 28D7205923808E8C0038D439 /* Extensions.swift in Sources */,
282126A8235BE72800072D52 /* PiHole.swift in Sources */, 282126A8235BE72800072D52 /* PiHole.swift in Sources */,
80654D7B24B0E92A007B1E1A /* DisableCustomTimeView.swift in Sources */, 80654D7B24B0E92A007B1E1A /* DisableCustomTimeView.swift in Sources */,
80A8CF852782190400CCCEC5 /* AsyncData.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };

View file

@ -33,7 +33,7 @@
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "28212681235926EB00072D52" BlueprintIdentifier = "28212681235926EB00072D52"
BuildableName = "PihelperTests.xctest" BuildableName = "Pi-helperTests.xctest"
BlueprintName = "Pi-helperTests" BlueprintName = "Pi-helperTests"
ReferencedContainer = "container:Pi-helper.xcodeproj"> ReferencedContainer = "container:Pi-helper.xcodeproj">
</BuildableReference> </BuildableReference>
@ -43,7 +43,7 @@
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "2821268C235926EB00072D52" BlueprintIdentifier = "2821268C235926EB00072D52"
BuildableName = "PihelperUITests.xctest" BuildableName = "Pi-helperUITests.xctest"
BlueprintName = "Pi-helperUITests" BlueprintName = "Pi-helperUITests"
ReferencedContainer = "container:Pi-helper.xcodeproj"> ReferencedContainer = "container:Pi-helper.xcodeproj">
</BuildableReference> </BuildableReference>

View file

@ -9,26 +9,22 @@
import SwiftUI import SwiftUI
struct ActivityIndicatorView: View { struct ActivityIndicatorView: View {
var isAnimating: Binding<Bool>
@State private var rotation = 0.0 @State private var rotation = 0.0
var body: some View { var body: some View {
Image("logo") 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 { .onAppear {
return withAnimation(self.isAnimating.wrappedValue ? Animation.linear(duration: 1).repeatForever(autoreverses: false) : Animation.linear) { self.rotation = 360
self.rotation = 360.0 }
}
}
}
init(_ isAnimating: Binding<Bool>) {
self.isAnimating = isAnimating
} }
} }
struct ActivityIndicatorView_Previews: PreviewProvider { struct ActivityIndicatorView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
ActivityIndicatorView(.constant(true)) ActivityIndicatorView()
} }
} }

View file

@ -10,6 +10,17 @@ import SwiftUI
struct AddPiHoleView: View { struct AddPiHoleView: View {
@State var ipAddress: String = "" @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 { var body: some View {
NavigationView { NavigationView {
@ -17,9 +28,9 @@ struct AddPiHoleView: View {
VStack(spacing: 30) { VStack(spacing: 30) {
Image("logo") Image("logo")
ScanButton(dataStore: self.dataStore) 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: { 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() OrDivider()
@ -28,20 +39,27 @@ struct AddPiHoleView: View {
TextField("ip_address", text: $ipAddress) TextField("ip_address", text: $ipAddress)
.textFieldStyle(RoundedBorderTextFieldStyle()) .textFieldStyle(RoundedBorderTextFieldStyle())
.keyboardType(.default) .keyboardType(.default)
Button(action: { .disableAutocorrection(true)
self.dataStore.connect(self.ipAddress) .autocapitalization(.none)
}, label: { Text("connect") }) .onSubmit {
Task {
await self.dataStore.connect(self.ipAddress)
}
}
Button(action: { Task {
await self.dataStore.connect(self.ipAddress)
} }, label: { Text("connect") })
.buttonStyle(PiHelperButtonStyle()) .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: { 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() .padding()
.keyboardAwarePadding() .keyboardAwarePadding()
} }
.navigationBarHidden(self.dataStore.pihole == .failure(.missingIpAddress)) .navigationBarHidden(self.hideNavigationBar)
} }
.navigationViewStyle(StackNavigationViewStyle()) .navigationViewStyle(StackNavigationViewStyle())
} }
@ -68,17 +86,19 @@ struct ScanButton: View {
Text("scan_for_pihole") Text("scan_for_pihole")
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
Button(action: { Button(action: {
self.dataStore.beginScanning(ipAddress) self.dataStore.scanTask = Task {
await self.dataStore.beginScanning(ipAddress)
}
}, label: { Text("scan") }) }, label: { Text("scan") })
.buttonStyle(PiHelperButtonStyle()) .buttonStyle(PiHelperButtonStyle())
NavigationLink( NavigationLink(
destination: ScanningView(dataStore: self.dataStore), destination: ScanningView(dataStore: self.dataStore),
isActive: .constant(self.dataStore.pihole == .failure(.scanning(""))), isActive: $dataStore.isScanning,
label: { EmptyView() } label: { EmptyView() }
) )
NavigationLink( NavigationLink(
destination: RetrieveApiKeyView(dataStore: self.dataStore), 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() } label: { EmptyView() }
) )
}.toAnyView() }.toAnyView()

54
Pi-helper/AsyncData.swift Normal file
View file

@ -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<Data>: 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
}
}
}

View file

@ -25,9 +25,9 @@ struct DisableCustomTimeView: View {
} }
} }
.pickerStyle(SegmentedPickerStyle()) .pickerStyle(SegmentedPickerStyle())
Button(action: { Button(action: { Task {
self.dataStore.disable(Int(self.duration) ?? 0, unit: self.unit) await self.dataStore.disable(Int(self.duration) ?? 0, unit: self.unit)
}, label: { Text(LocalizedStringKey("disable")) }) } }, label: { Text(LocalizedStringKey("disable")) })
.buttonStyle(PiHelperButtonStyle()) .buttonStyle(PiHelperButtonStyle())
} }
.padding() .padding()

View file

@ -157,7 +157,7 @@ struct TopItemsResponse: Codable {
} }
} }
enum PiHoleStatus: Equatable { enum Status: Equatable {
case enabled case enabled
case disabled(_ duration: String? = nil) case disabled(_ duration: String? = nil)
case unknown case unknown

View file

@ -7,7 +7,6 @@
// //
import Foundation import Foundation
import Combine
class PiHoleApiService { class PiHoleApiService {
let decoder: JSONDecoder let decoder: JSONDecoder
@ -20,70 +19,61 @@ class PiHoleApiService {
self.decoder = decoder self.decoder = decoder
} }
func getVersion() -> AnyPublisher<VersionResponse, NetworkError> { func getVersion() async throws -> VersionResponse {
return get(queries: ["version": ""]) return try await get(queries: ["version": ""])
} }
func loadSummary() -> AnyPublisher<PiHole, NetworkError> { func loadSummary() async throws -> PiHole {
return get() return try await get()
} }
func enable() -> AnyPublisher<StatusUpdate, NetworkError> { func enable() async throws -> StatusUpdate {
return get(true, queries: ["enable": ""]) return try await get(true, queries: ["enable": ""])
} }
func getTopItems() -> AnyPublisher<TopItemsResponse, NetworkError> { func getTopItems() async throws -> TopItemsResponse {
return get(true, queries: ["topItems": "25"]) return try await get(true, queries: ["topItems": "25"])
} }
func disable(_ forSeconds: Int? = nil) -> AnyPublisher<StatusUpdate, NetworkError> { func disable(_ forSeconds: Int? = nil) async throws -> StatusUpdate {
var params = [String: String]() var params = [String: String]()
if let timeToDisable = forSeconds { if let timeToDisable = forSeconds {
params["disable"] = String(timeToDisable) params["disable"] = String(timeToDisable)
} else { } else {
params["disable"] = "" params["disable"] = ""
} }
return get(true, queries: params) return try await get(true, queries: params)
} }
func getCustomDisableTimer() -> AnyPublisher<UInt, NetworkError> { func getCustomDisableTimer() async throws -> UInt {
guard let baseUrl = self.baseUrl else { guard let baseUrl = self.baseUrl else {
return Result<UInt, NetworkError>.Publisher(.failure(.invalidUrl)) throw NetworkError.invalidUrl
.eraseToAnyPublisher()
} }
guard let url = URL(string: baseUrl + "/custom_disable_timer") else { guard let url = URL(string: baseUrl + "/custom_disable_timer") else {
return Result<UInt, NetworkError>.Publisher(.failure(.invalidUrl)) throw NetworkError.invalidUrl
.eraseToAnyPublisher()
} }
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.httpMethod = "GET" request.httpMethod = "GET"
request.timeoutInterval = 0.5 request.timeoutInterval = 0.5
let task = URLSession.shared.dataTaskPublisher(for: request) let (data, res) = try await URLSession.shared.data(for: request)
.tryMap { (data, res) -> UInt in guard let response = res as? HTTPURLResponse, 200...299 ~= response.statusCode else {
guard let response = res as? HTTPURLResponse, 200...299 ~= response.statusCode else { switch (res as? HTTPURLResponse)?.statusCode {
switch (res as? HTTPURLResponse)?.statusCode { case 400: throw NetworkError.badRequest
case 400: throw NetworkError.badRequest case 401, 403: throw NetworkError.unauthorized
case 401, 403: throw NetworkError.unauthorized case 404: throw NetworkError.notFound
case 404: throw NetworkError.notFound default: throw NetworkError.unknown(nil)
default: throw NetworkError.unknown }
}
}
let dataString = String(data: data, encoding: .utf8) ?? "0"
return UInt(dataString) ?? 0
} }
.mapError { let dataString = String(data: data, encoding: .utf8) ?? "0"
return $0 as! NetworkError return UInt(dataString) ?? 0
}
return task.eraseToAnyPublisher()
} }
private func get<ResultType: Codable>( private func get<ResultType: Codable>(
_ requiresAuth: Bool = false, _ requiresAuth: Bool = false,
queries: [String: String]? = nil queries: [String: String]? = nil
) -> AnyPublisher<ResultType, NetworkError> { ) async throws -> ResultType {
guard let baseUrl = self.baseUrl else { guard let baseUrl = self.baseUrl else {
return Result<ResultType, NetworkError>.Publisher(.failure(.invalidUrl)) throw NetworkError.invalidUrl
.eraseToAnyPublisher()
} }
var combinedEndPoint = baseUrl + "/admin/api.php" var combinedEndPoint = baseUrl + "/admin/api.php"
@ -101,7 +91,7 @@ class PiHoleApiService {
} }
guard let url = URL(string: combinedEndPoint) else { guard let url = URL(string: combinedEndPoint) else {
return Result.Publisher(.failure(.invalidUrl)).eraseToAnyPublisher() throw NetworkError.invalidUrl
} }
var request = URLRequest(url: url) var request = URLRequest(url: url)
@ -109,23 +99,20 @@ class PiHoleApiService {
request.httpMethod = "GET" request.httpMethod = "GET"
request.timeoutInterval = 0.5 request.timeoutInterval = 0.5
let task = URLSession.shared.dataTaskPublisher(for: request) do {
.tryMap { (data, res) -> Data in let (data, res) = try await URLSession.shared.data(for: request)
guard let response = res as? HTTPURLResponse, 200...299 ~= response.statusCode else { guard let response = res as? HTTPURLResponse, 200...299 ~= response.statusCode else {
switch (res as? HTTPURLResponse)?.statusCode { switch (res as? HTTPURLResponse)?.statusCode {
case 400: throw NetworkError.badRequest case 400: throw NetworkError.badRequest
case 401, 403: throw NetworkError.unauthorized case 401, 403: throw NetworkError.unauthorized
case 404: throw NetworkError.notFound case 404: throw NetworkError.notFound
default: throw NetworkError.unknown 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 badRequest
case notFound case notFound
case unauthorized case unauthorized
case unknown case unknown(Error?)
case invalidUrl case invalidUrl
case jsonParsingFailed(Error) case jsonParsingFailed(Error)
static func == (lhs: NetworkError, rhs: NetworkError) -> Bool { static func == (lhs: NetworkError, rhs: NetworkError) -> Bool {
switch (lhs, rhs) { switch (lhs, rhs) {
case (.loading, .loading):
return true
case (.cancelled, .cancelled): case (.cancelled, .cancelled):
return true return true
case (.badRequest, .badRequest): case (.badRequest, .badRequest):
@ -151,8 +136,8 @@ enum NetworkError: Error, Equatable {
return true return true
case (.unauthorized, .unauthorized): case (.unauthorized, .unauthorized):
return true return true
case (.unknown, .unknown): case (.unknown(let error1), .unknown(let error2)):
return true return error1?.localizedDescription == error2?.localizedDescription
case (.invalidUrl, .invalidUrl): case (.invalidUrl, .invalidUrl):
return true return true
case (.jsonParsingFailed(let error1), .jsonParsingFailed(let error2)): case (.jsonParsingFailed(let error1), .jsonParsingFailed(let error2)):
@ -162,5 +147,3 @@ enum NetworkError: Error, Equatable {
} }
} }
} }
struct Empty: Codable {}

View file

@ -10,12 +10,25 @@ import Foundation
import Combine import Combine
import UIKit import UIKit
@MainActor
class PiHoleDataStore: ObservableObject { class PiHoleDataStore: ObservableObject {
private let IP_MIN = 0 private let IP_MIN = 0
private let IP_MAX = 255 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 showCustomDisableView = false
@Published var pihole: Result<PiHoleStatus, PiHoleError> @Published var pihole: PiholeStatus = .empty
@Published var error: PiHoleError? = nil
@Published var apiKey: String? = nil { @Published var apiKey: String? = nil {
didSet { didSet {
UserDefaults.standard.set(apiKey, forKey: PiHoleDataStore.API_KEY) UserDefaults.standard.set(apiKey, forKey: PiHoleDataStore.API_KEY)
@ -30,9 +43,10 @@ class PiHoleDataStore: ObservableObject {
} }
} }
private var shouldMonitorStatus = false private var shouldMonitorStatus = false
var scanTask: Task<Any, Error>? = nil
private func prependScheme(_ ipAddress: String?) -> String? { private func prependScheme(_ ipAddress: String?) -> String? {
guard let host = ipAddress else { guard let host = ipAddress?.lowercased() else {
return nil return nil
} }
@ -47,162 +61,109 @@ class PiHoleDataStore: ObservableObject {
return host return host
} }
func monitorStatus() { func monitorStatus() async {
self.shouldMonitorStatus = true while !Task.isCancelled {
doMonitorStatus() do {
} try await Task.sleep(nanoseconds: 1_000_000_000)
await loadSummary()
private func doMonitorStatus() { } catch {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: { break
if !self.shouldMonitorStatus {
return
} }
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..<chunks {
let chunkStart = IP_MIN + (chunk * chunkSize)
let chunkEnd = IP_MIN + ((chunk + 1) * chunkSize)
addressParts[3] = Substring(String(((chunkEnd - chunkStart) / 2) + chunkStart))
ipAddresses.append(addressParts.joined(separator: "."))
}
chunks *= 2
} }
scan(ipAddresses) }
func beginScanning(_ ipAddress: String) async {
var addressParts = ipAddress.split(separator: ".")
let lastOctal = Int(addressParts[3])
for octal in 0..<IP_MAX {
if Task.isCancelled {
return
}
if octal == lastOctal {
// Don't scan the current device
continue
}
addressParts[3] = Substring(String(octal))
if await scan(addressParts.joined(separator: ".")) {
return
}
}
self.error = .scanFailed
} }
func connect(_ rawIpAddress: String) { func cancelScanning() {
scanTask?.cancel()
scanTask = nil
self.pihole = .missingIpAddress
}
func connect(_ rawIpAddress: String) async {
guard let formattedIpAddress = prependScheme(rawIpAddress) else { guard let formattedIpAddress = prependScheme(rawIpAddress) else {
self.pihole = .failure(.connectionFailed) self.error = .connectionFailed(rawIpAddress)
return return
} }
self.apiService.baseUrl = formattedIpAddress self.apiService.baseUrl = formattedIpAddress
currentRequest = self.apiService.getVersion() do {
.receive(on: DispatchQueue.main) _ = try await self.apiService.getVersion()
.sink(receiveCompletion: { (completion) in self.baseUrl = formattedIpAddress
switch completion { self.pihole = .missingApiKey
case .finished: } catch {
return self.error = .connectionFailed(formattedIpAddress)
case .failure(_): }
self.pihole = .failure(.connectionFailed)
}
}, receiveValue: { version in
self.baseUrl = formattedIpAddress
self.pihole = .failure(.missingApiKey)
})
} }
private func scan(_ ipAddresses: [String]) { func scan(_ ipAddress: String) async -> Bool {
if ipAddresses.isEmpty { self.pihole = PiholeStatus.scanning(ipAddress)
self.pihole = .failure(.scanFailed) do {
return 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() { func forgetPihole() {
self.baseUrl = nil self.baseUrl = nil
self.apiKey = 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() { if let hash = password.sha256Hash()?.sha256Hash() {
connectWithApiKey(hash) await connectWithApiKey(hash)
} else { } else {
self.pihole = .failure(.invalidCredentials) self.error = .invalidCredentials
} }
} }
func connectWithApiKey(_ apiToken: String) { func connectWithApiKey(_ apiToken: String) async {
self.apiService.apiKey = apiToken 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 { do {
previousStatus = try self.pihole.get() _ = try await self.apiService.getTopItems()
} catch _ { self.apiKey = apiToken
} catch {
self.error = .invalidCredentials
} }
self.pihole = .failure(.loading(previousStatus)) }
self.currentRequest = apiService.loadSummary() func loadSummary() async {
.receive(on: DispatchQueue.main) var loadingTask: Task<Void, Error>? = nil
.sink(receiveCompletion: { (completion) in if case let .success(previousState) = self.pihole {
switch completion { // Avoid showing the loading spinner immediately
case .finished: loadingTask = Task {
self.currentRequest = nil try await Task.sleep(nanoseconds: 1_000_000_000)
case .failure(let error): self.pihole = .loading(previousState)
self.pihole = .failure(.networkError(error)) }
} } else {
if let completionBlock = completionBlock { self.pihole = .loading()
completionBlock() }
} do {
}, receiveValue: { pihole in let pihole = try await apiService.loadSummary()
await MainActor.run {
UIApplication.shared.shortcutItems = [ UIApplication.shared.shortcutItems = [
UIApplicationShortcutItem( UIApplicationShortcutItem(
type: ShortcutAction.enable.rawValue, type: ShortcutAction.enable.rawValue,
@ -233,64 +194,57 @@ class PiHoleDataStore: ObservableObject {
userInfo: ["forSeconds": 300 as NSSecureCoding] userInfo: ["forSeconds": 300 as NSSecureCoding]
) )
] ]
self.updateStatus(status: pihole.status) }
}) await self.updateStatus(pihole.status)
} loadingTask?.cancel()
} catch {
func enable() { if let error = error as? NetworkError {
var previousStatus: PiHoleStatus? = nil self.error = .networkError(error)
do { } else {
previousStatus = try self.pihole.get() print("Unhandled error! \(error)")
} catch _ { }
} }
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 let multiplier = NSDecimalNumber(decimal: pow(60, unit)).intValue
disable(forDuration * multiplier) await disable(forDuration * multiplier)
} }
func disable(_ forSeconds: Int? = nil) { func disable(_ forSeconds: Int? = nil) async {
self.showCustomDisableView = false var previousStatus: Status? = nil
var previousStatus: PiHoleStatus? = nil if case let .success(status) = self.pihole {
do { previousStatus = status
previousStatus = try self.pihole.get() }
} catch _ { 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 { switch status {
case "disabled": case "disabled":
self.getDisabledDuration() await self.getDisabledDuration()
default: default:
self.pihole = .success(.enabled) self.pihole = .success(.enabled)
} }
@ -298,27 +252,20 @@ class PiHoleDataStore: ObservableObject {
private var customDisableTimeRequest: AnyCancellable? = nil private var customDisableTimeRequest: AnyCancellable? = nil
private func getDisabledDuration() { private func getDisabledDuration() async {
self.customDisableTimeRequest = self.apiService.getCustomDisableTimer() do {
.receive(on: DispatchQueue.main) let timestamp = try await self.apiService.getCustomDisableTimer()
.sink(receiveCompletion: { (completion) in let disabledUntil = TimeInterval(round(Double(timestamp) / 1000.0))
switch completion { let now = Date().timeIntervalSince1970
case .finished: if now > disabledUntil {
return self.pihole = .success(.disabled())
case .failure(_): } else {
self.pihole = .success(.disabled()) self.pihole = .success(.disabled(UInt(disabledUntil - now).toDurationString()))
self.customDisableTimeRequest = nil }
} self.customDisableTimeRequest = nil
}, receiveValue: { timestamp in } catch {
let disabledUntil = TimeInterval(round(Double(timestamp) / 1000.0)) self.pihole = .success(.disabled())
let now = Date().timeIntervalSince1970 }
if now > disabledUntil {
self.pihole = .success(.disabled())
} else {
self.pihole = .success(.disabled(UInt(disabledUntil - now).toDurationString()))
}
self.customDisableTimeRequest = nil
})
} }
let apiService = PiHoleApiService() let apiService = PiHoleApiService()
@ -329,12 +276,11 @@ class PiHoleDataStore: ObservableObject {
self.baseUrl = baseUrl self.baseUrl = baseUrl
self.apiKey = apiKey self.apiKey = apiKey
if baseUrl == nil { if baseUrl == nil {
self.pihole = .failure(.missingIpAddress) self.pihole = .missingIpAddress
} else if apiKey == nil { } else if apiKey == nil {
self.pihole = .failure(.missingApiKey) self.pihole = .missingApiKey
} else { } else {
self.pihole = .failure(.networkError(.loading)) self.pihole = .loading(nil)
self.loadSummary()
} }
} }
} }
@ -344,32 +290,49 @@ enum ShortcutAction: String {
case disable = "DisableAction" case disable = "DisableAction"
} }
enum PiHoleError : Error, Equatable { enum PiholeStatus: Equatable {
case networkError(_ error: NetworkError) case empty
case loading(_ previousStatus: PiHoleStatus? = nil) case loading(_ previousStatus: Status? = nil)
case error(_ error: Error)
case scanning(_ ipAddress: String) case scanning(_ ipAddress: String)
case missingIpAddress case missingIpAddress
case missingApiKey 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 invalidCredentials
case scanFailed case scanFailed
case connectionFailed case connectionFailed(_ host: String)
static func == (lhs: PiHoleError, rhs: PiHoleError) -> Bool { static func == (lhs: PiHoleError, rhs: PiHoleError) -> Bool {
switch (lhs, rhs) { switch (lhs, rhs) {
case (.networkError(let error1), .networkError(let error2)): case (.networkError(let error1), .networkError(let error2)):
return error1 == error2 return error1 == error2
case (.scanning(_), .scanning(_)):
return true
case (.missingIpAddress, .missingIpAddress):
return true
case (.missingApiKey, .missingApiKey):
return true
case (.invalidCredentials, .invalidCredentials): case (.invalidCredentials, .invalidCredentials):
return true return true
case (.scanFailed, .scanFailed): case (.scanFailed, .scanFailed):
return true return true
case (.connectionFailed, .connectionFailed): case (.connectionFailed(let lIp), .connectionFailed(let rIp)):
return true return lIp == rIp
default: default:
return false return false
} }

View file

@ -9,30 +9,32 @@
import SwiftUI import SwiftUI
struct PiHoleDetailsView: View { struct PiHoleDetailsView: View {
var stateContent: AnyView { @ViewBuilder
func stateContent() -> some View {
switch self.dataStore.pihole { switch self.dataStore.pihole {
case .success(let pihole): case .success(let pihole):
return PiHoleActionsView(dataStore: self.dataStore, status: pihole).toAnyView() PiHoleActionsView(dataStore: self.dataStore, status: pihole)
case .failure(.loading(let previousStatus)): case .loading(let previousStatus):
if let status = previousStatus { if let status = previousStatus {
return PiHoleActionsView(dataStore: self.dataStore, status: status).toAnyView() PiHoleActionsView(dataStore: self.dataStore, status: status)
} else { } else {
return ActivityIndicatorView(.constant(true)).toAnyView() ActivityIndicatorView()
} }
case .failure(.networkError(let error)): case .error(let error):
switch (error) { switch (error) {
default: default:
return PiHoleActionsView(dataStore: self.dataStore, status: .unknown).toAnyView() PiHoleActionsView(dataStore: self.dataStore, status: .unknown)
} }
default: default:
return ActivityIndicatorView(.constant(true)).toAnyView() ActivityIndicatorView()
} }
} }
var body: some View { var body: some View {
NavigationView { NavigationView {
stateContent stateContent()
.navigationBarTitle("PiHelper") .animation(.default, value: self.dataStore.pihole)
.navigationBarTitle("Pi-helper")
.navigationBarItems(trailing: NavigationLink(destination: AboutView(self.dataStore), label: { .navigationBarItems(trailing: NavigationLink(destination: AboutView(self.dataStore), label: {
Image(systemName: "info.circle") Image(systemName: "info.circle")
.padding() .padding()
@ -40,10 +42,9 @@ struct PiHoleDetailsView: View {
} }
.navigationViewStyle(StackNavigationViewStyle()) .navigationViewStyle(StackNavigationViewStyle())
.onAppear { .onAppear {
self.dataStore.monitorStatus() Task {
} await self.dataStore.monitorStatus()
.onDisappear { }
self.dataStore.stopMonitoring()
} }
} }
@ -58,53 +59,32 @@ struct PiHoleActionsView: View {
VStack { VStack {
HStack { HStack {
Text("status") Text("status")
PiHoleStatusView(status) PiholeStatusView(status)
} }
DurationView(status)
PiHoleActions(self.dataStore, status: status) PiHoleActions(self.dataStore, status: status)
} }
} }
let dataStore: PiHoleDataStore let dataStore: PiHoleDataStore
let status: PiHoleStatus let status: Status
} }
struct PiHoleStatusView: View { struct PiholeStatusView: View {
@ViewBuilder
var body: some View { var body: some View {
Text(status.localizedStringKey).foregroundColor(status.foregroundColor) HStack {
} Text(status.localizedStringKey)
.foregroundColor(status.foregroundColor)
let status: PiHoleStatus if case let .disabled(duration) = status, let durationString = duration {
init(_ status: PiHoleStatus) { Text("(\(durationString))")
self.status = status .monospacedDigit()
} .foregroundColor(status.foregroundColor)
}
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()
} }
default:
return EmptyView().toAnyView()
} }
} }
let status: PiHoleStatus let status: Status
init(_ status: PiHoleStatus) { init(_ status: Status) {
self.status = status self.status = status
} }
} }
@ -117,34 +97,44 @@ struct PiHoleActions: View {
var stateContent: AnyView { var stateContent: AnyView {
switch status { switch status {
case .disabled: 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)) .buttonStyle(PiHelperButtonStyle(.green))
.padding(Edge.Set(arrayLiteral: [.bottom, .top]), 5.0) .padding(Edge.Set(arrayLiteral: [.bottom, .top]), 5.0)
.toAnyView() .toAnyView()
case .enabled: case .enabled:
return VStack { 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()) .buttonStyle(PiHelperButtonStyle())
.padding(Edge.Set(arrayLiteral: [.bottom, .top]), 5.0) .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()) .buttonStyle(PiHelperButtonStyle())
.padding(Edge.Set(arrayLiteral: [.bottom, .top]), 5.0) .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()) .buttonStyle(PiHelperButtonStyle())
.padding(Edge.Set(arrayLiteral: [.bottom, .top]), 5.0) .padding(Edge.Set(arrayLiteral: [.bottom, .top]), 5.0)
NavigationLink(
destination: DisableCustomTimeView(self.dataStore),
isActive: .constant(self.dataStore.showCustomDisableView),
label: { EmptyView() }
)
Button(action: { Button(action: {
self.dataStore.showCustomDisableView = true self.dataStore.showCustomDisableView = true
}, label: { Text("disable_custom") }) }, label: { Text("disable_custom") })
.buttonStyle(PiHelperButtonStyle()) .buttonStyle(PiHelperButtonStyle())
.padding(Edge.Set(arrayLiteral: [.bottom, .top]), 5.0) .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()) .buttonStyle(PiHelperButtonStyle())
.padding(Edge.Set(arrayLiteral: [.bottom, .top]), 5.0) .padding(Edge.Set(arrayLiteral: [.bottom, .top]), 5.0)
NavigationLink(
destination: DisableCustomTimeView(self.dataStore),
isActive: self.$dataStore.showCustomDisableView,
label: { EmptyView() }
)
}.toAnyView() }.toAnyView()
default: default:
return Text("Unable to load Pi-hole status. Please verify your credentials and ensure the Pi-hole is accessible from your current network.") 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 @ObservedObject var dataStore: PiHoleDataStore
let status: PiHoleStatus let status: Status
init(_ dataStore: PiHoleDataStore, status: PiHoleStatus) { init(_ dataStore: PiHoleDataStore, status: Status) {
self.dataStore = dataStore self.dataStore = dataStore
self.status = status self.status = status
} }
@ -164,7 +154,7 @@ struct PiHoleDetailsView_Previews: PreviewProvider {
static var dataStore: PiHoleDataStore { static var dataStore: PiHoleDataStore {
get { get {
let _dataStore = PiHoleDataStore() let _dataStore = PiHoleDataStore()
_dataStore.pihole = .success(.disabled("20")) _dataStore.pihole = PiholeStatus.success(Status.disabled("20"))
return _dataStore return _dataStore
} }
} }

View file

@ -11,6 +11,15 @@ import SwiftUI
struct RetrieveApiKeyView: View { struct RetrieveApiKeyView: View {
@State var apiKey: String = "" @State var apiKey: String = ""
@State var password: String = "" @State var password: String = ""
var showAlert: Bool {
get {
if case .invalidCredentials = self.dataStore.error {
return true
} else {
return false
}
}
}
var body: some View { var body: some View {
ScrollView { ScrollView {
@ -22,32 +31,42 @@ struct RetrieveApiKeyView: View {
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
SecureField("prompt_password", text: self.$password) SecureField("prompt_password", text: self.$password)
.textFieldStyle(RoundedBorderTextFieldStyle()) .textFieldStyle(RoundedBorderTextFieldStyle())
Button(action: { .onSubmit {
self.dataStore.connectWithPassword(self.password) Task {
}, label: { await self.dataStore.connectWithPassword(self.password)
}
}
Button(action: { Task {
await self.dataStore.connectWithPassword(self.password)
} }, label: {
Text("connect_with_password") Text("connect_with_password")
}) })
.buttonStyle(PiHelperButtonStyle()) .buttonStyle(PiHelperButtonStyle())
OrDivider() OrDivider()
SecureField("prompt_api_key", text: self.$apiKey) SecureField("prompt_api_key", text: self.$apiKey)
.textFieldStyle(RoundedBorderTextFieldStyle()) .textFieldStyle(RoundedBorderTextFieldStyle())
Button(action: { .onSubmit {
self.dataStore.connectWithApiKey(self.apiKey) Task {
}, label: { await self.dataStore.connectWithApiKey(self.apiKey)
}
}
Button(action: { Task {
await self.dataStore.connectWithApiKey(self.apiKey)
} }, label: {
Text("connect_with_api_key") Text("connect_with_api_key")
}) })
.buttonStyle(PiHelperButtonStyle()) .buttonStyle(PiHelperButtonStyle())
} }
.padding() .padding()
.keyboardAwarePadding() .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: { Alert(title: Text("connection_failed"), message: Text("verify_credentials"), dismissButton: .default(Text("OK"), action: {
self.dataStore.pihole = .failure(.missingApiKey) self.dataStore.pihole = .missingApiKey
})) }))
}) })
} }
.onDisappear { .onDisappear {
self.dataStore.pihole = .failure(.missingIpAddress) self.dataStore.pihole = .missingIpAddress
} }
} }

View file

@ -12,39 +12,34 @@ struct ScanningView: View {
@Environment(\.presentationMode) var presentationMode @Environment(\.presentationMode) var presentationMode
@ObservedObject var dataStore: PiHoleDataStore @ObservedObject var dataStore: PiHoleDataStore
var stateContent: AnyView { @ViewBuilder
var body: some View {
switch dataStore.pihole { switch dataStore.pihole {
case .failure(.scanning(let ipAddress)): case .scanning(let ipAddress):
return ScrollView { ScrollView {
VStack(spacing: 10) { VStack(spacing: 10) {
ActivityIndicatorView(.constant(true)) ActivityIndicatorView()
Text("scanning_ip_address") Text("scanning_ip_address")
Text(verbatim: ipAddress) Text(verbatim: ipAddress)
Button(action: { Button(action: {
self.dataStore.cancelRequest() self.dataStore.cancelScanning()
}, label: { Text("cancel") }) }, label: { Text("cancel") })
.buttonStyle(PiHelperButtonStyle()) .buttonStyle(PiHelperButtonStyle())
}.padding() }.padding()
}.toAnyView() }
default: default:
self.presentationMode.wrappedValue.dismiss() EmptyView().onAppear {
return EmptyView().toAnyView() self.presentationMode.wrappedValue.dismiss()
}
} }
} }
var body: some View {
stateContent
.onDisappear {
self.dataStore.cancelRequest()
}
}
} }
struct ScanningView_Previews: PreviewProvider { struct ScanningView_Previews: PreviewProvider {
static var dataStore: PiHoleDataStore { static var dataStore: PiHoleDataStore {
get { get {
let dataStore = PiHoleDataStore() let dataStore = PiHoleDataStore()
dataStore.pihole = .failure(.scanning("127.0.0.1")) dataStore.pihole = .scanning("127.0.0.1")
return dataStore return dataStore
} }
} }

View file

@ -22,11 +22,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
// If we already have the address of a Pi we've previously connected to, try that // 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) let apiKey = UserDefaults.standard.string(forKey: PiHoleDataStore.API_KEY)
self.dataStore = PiHoleDataStore(baseUrl: host, apiKey: apiKey) 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 { } else {
self.dataStore = PiHoleDataStore() self.dataStore = PiHoleDataStore()
Task {
await self.dataStore?.scan("pi.hole")
}
} }
// Create the SwiftUI view that provides the window contents. // 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? // Is there a shortcut item that has not yet been processed?
if let shortcutItem = (UIApplication.shared.delegate as! AppDelegate).shortcutItemToProcess { if let shortcutItem = (UIApplication.shared.delegate as! AppDelegate).shortcutItemToProcess {
if shortcutItem.type == ShortcutAction.enable.rawValue { if shortcutItem.type == ShortcutAction.enable.rawValue {
self.dataStore?.enable() Task {
await self.dataStore?.enable()
}
} else { } else {
let amount = shortcutItem.userInfo?["forSeconds"] as? Int 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. // Reset the shorcut item so it's never processed twice.