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 */; };
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 = "<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>"; };
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>"; };
/* 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 = "<group>";
@ -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;
};

View file

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

View file

@ -9,26 +9,22 @@
import SwiftUI
struct ActivityIndicatorView: View {
var isAnimating: Binding<Bool>
@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<Bool>) {
self.isAnimating = isAnimating
self.rotation = 360
}
}
}
struct ActivityIndicatorView_Previews: PreviewProvider {
static var previews: some View {
ActivityIndicatorView(.constant(true))
ActivityIndicatorView()
}
}

View file

@ -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()

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())
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()

View file

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

View file

@ -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<VersionResponse, NetworkError> {
return get(queries: ["version": ""])
func getVersion() async throws -> VersionResponse {
return try await get(queries: ["version": ""])
}
func loadSummary() -> AnyPublisher<PiHole, NetworkError> {
return get()
func loadSummary() async throws -> PiHole {
return try await get()
}
func enable() -> AnyPublisher<StatusUpdate, NetworkError> {
return get(true, queries: ["enable": ""])
func enable() async throws -> StatusUpdate {
return try await get(true, queries: ["enable": ""])
}
func getTopItems() -> AnyPublisher<TopItemsResponse, NetworkError> {
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<StatusUpdate, NetworkError> {
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<UInt, NetworkError> {
func getCustomDisableTimer() async throws -> UInt {
guard let baseUrl = self.baseUrl else {
return Result<UInt, NetworkError>.Publisher(.failure(.invalidUrl))
.eraseToAnyPublisher()
throw NetworkError.invalidUrl
}
guard let url = URL(string: baseUrl + "/custom_disable_timer") else {
return Result<UInt, NetworkError>.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<ResultType: Codable>(
_ requiresAuth: Bool = false,
queries: [String: String]? = nil
) -> AnyPublisher<ResultType, NetworkError> {
) async throws -> ResultType {
guard let baseUrl = self.baseUrl else {
return Result<ResultType, NetworkError>.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 {}

View file

@ -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<PiHoleStatus, PiHoleError>
@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<Any, Error>? = 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..<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 {
self.pihole = .failure(.connectionFailed)
self.error = .connectionFailed(rawIpAddress)
return
}
self.apiService.baseUrl = formattedIpAddress
currentRequest = self.apiService.getVersion()
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { (completion) in
switch completion {
case .finished:
return
case .failure(_):
self.pihole = .failure(.connectionFailed)
}
}, receiveValue: { version in
self.baseUrl = formattedIpAddress
self.pihole = .failure(.missingApiKey)
})
do {
_ = try await self.apiService.getVersion()
self.baseUrl = formattedIpAddress
self.pihole = .missingApiKey
} catch {
self.error = .connectionFailed(formattedIpAddress)
}
}
private func scan(_ ipAddresses: [String]) {
if ipAddresses.isEmpty {
self.pihole = .failure(.scanFailed)
return
func scan(_ ipAddress: String) async -> 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<Void, Error>? = 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
}

View file

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

View file

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

View file

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

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
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.