Add methods for automatically detecting the Pi-Hole

As soon as the user starts up the app, we'll first try to ping their DNS
server, as that should in theory be the Pi-Hole. If that doesn't work,
then we offer the user the chance to either scan their network or
manually enter in the IP address for their Pi.
This commit is contained in:
Billy Brawner 2019-11-16 19:12:56 -07:00
parent 1ce7b74329
commit 703ca0717a
15 changed files with 446 additions and 209 deletions

90
.gitignore vendored Normal file
View file

@ -0,0 +1,90 @@
# Xcode
#
# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
## User settings
xcuserdata/
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
*.xcscmblueprint
*.xccheckout
## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
build/
DerivedData/
*.moved-aside
*.pbxuser
!default.pbxuser
*.mode1v3
!default.mode1v3
*.mode2v3
!default.mode2v3
*.perspectivev3
!default.perspectivev3
## Obj-C/Swift specific
*.hmap
## App packaging
*.ipa
*.dSYM.zip
*.dSYM
## Playgrounds
timeline.xctimeline
playground.xcworkspace
# Swift Package Manager
#
# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
# Packages/
# Package.pins
# Package.resolved
# *.xcodeproj
#
# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
# hence it is not needed unless you have added a package configuration file to your project
# .swiftpm
.build/
# CocoaPods
#
# We recommend against adding the Pods directory to your .gitignore. However
# you should judge for yourself, the pros and cons are mentioned at:
# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
#
# Pods/
#
# Add this line if you want to avoid checking in source code from the Xcode workspace
# *.xcworkspace
# Carthage
#
# Add this line if you want to avoid checking in source code from Carthage dependencies.
# Carthage/Checkouts
Carthage/Build/
# Accio dependency management
Dependencies/
.accio/
# fastlane
#
# It is recommended to not store the screenshots in the git repo.
# Instead, use fastlane to re-generate the screenshots whenever they are needed.
# For more information about the recommended setup visit:
# https://docs.fastlane.tools/best-practices/source-control/#source-control
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots/**/*.png
fastlane/test_output
# Code Injection
#
# After new code Injection tools there's a generated folder /iOSInjectionProject
# https://github.com/johnno1962/injectionforxcode
iOSInjectionProject/

View file

@ -22,6 +22,9 @@
282126AE235BF21D00072D52 /* AddPiHoleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 282126AD235BF21D00072D52 /* AddPiHoleView.swift */; }; 282126AE235BF21D00072D52 /* AddPiHoleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 282126AD235BF21D00072D52 /* AddPiHoleView.swift */; };
282126B0235BF52F00072D52 /* ActivityIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 282126AF235BF52F00072D52 /* ActivityIndicatorView.swift */; }; 282126B0235BF52F00072D52 /* ActivityIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 282126AF235BF52F00072D52 /* ActivityIndicatorView.swift */; };
282126B4235C0F5400072D52 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 282126B6235C0F5400072D52 /* Localizable.strings */; }; 282126B4235C0F5400072D52 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 282126B6235C0F5400072D52 /* Localizable.strings */; };
28D72051238067F30038D439 /* libresolv.9.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 28D72050238067EC0038D439 /* libresolv.9.tbd */; };
28D7205923808E8C0038D439 /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28D7205823808E8C0038D439 /* Extensions.swift */; };
28D7205C2380C2090038D439 /* resolver.c in Sources */ = {isa = PBXBuildFile; fileRef = 28D7205B2380C2090038D439 /* resolver.c */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@ -65,6 +68,12 @@
282126B3235C0EBF00072D52 /* es-419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-419"; path = "es-419.lproj/LaunchScreen.strings"; sourceTree = "<group>"; }; 282126B3235C0EBF00072D52 /* es-419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-419"; path = "es-419.lproj/LaunchScreen.strings"; sourceTree = "<group>"; };
282126B5235C0F5400072D52 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; }; 282126B5235C0F5400072D52 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
282126B8235C0F8400072D52 /* es-419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-419"; path = "es-419.lproj/Localizable.strings"; sourceTree = "<group>"; }; 282126B8235C0F8400072D52 /* es-419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-419"; path = "es-419.lproj/Localizable.strings"; sourceTree = "<group>"; };
28D7204E238067B20038D439 /* libresolv.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libresolv.tbd; path = usr/lib/libresolv.tbd; sourceTree = SDKROOT; };
28D72050238067EC0038D439 /* libresolv.9.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libresolv.9.tbd; path = usr/lib/libresolv.9.tbd; sourceTree = SDKROOT; };
28D720522380689E0038D439 /* Pi-Helper-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Pi-Helper-Bridging-Header.h"; sourceTree = "<group>"; };
28D7205823808E8C0038D439 /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = "<group>"; };
28D7205A2380C2090038D439 /* resolver.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = resolver.h; sourceTree = "<group>"; };
28D7205B2380C2090038D439 /* resolver.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = resolver.c; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@ -72,6 +81,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
28D72051238067F30038D439 /* libresolv.9.tbd in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -99,6 +109,7 @@
28212685235926EB00072D52 /* Pi-HelperTests */, 28212685235926EB00072D52 /* Pi-HelperTests */,
28212690235926EB00072D52 /* Pi-HelperUITests */, 28212690235926EB00072D52 /* Pi-HelperUITests */,
2821266D235926E700072D52 /* Products */, 2821266D235926E700072D52 /* Products */,
28D7204D238067B20038D439 /* Frameworks */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@ -115,6 +126,7 @@
2821266E235926E700072D52 /* Pi-Helper */ = { 2821266E235926E700072D52 /* Pi-Helper */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
28D720522380689E0038D439 /* Pi-Helper-Bridging-Header.h */,
2821266F235926E700072D52 /* AppDelegate.swift */, 2821266F235926E700072D52 /* AppDelegate.swift */,
28212671235926E700072D52 /* SceneDelegate.swift */, 28212671235926E700072D52 /* SceneDelegate.swift */,
28212673235926E700072D52 /* ContentView.swift */, 28212673235926E700072D52 /* ContentView.swift */,
@ -129,6 +141,9 @@
282126AB235BF09800072D52 /* PiHoleDetailsView.swift */, 282126AB235BF09800072D52 /* PiHoleDetailsView.swift */,
282126AF235BF52F00072D52 /* ActivityIndicatorView.swift */, 282126AF235BF52F00072D52 /* ActivityIndicatorView.swift */,
282126B6235C0F5400072D52 /* Localizable.strings */, 282126B6235C0F5400072D52 /* Localizable.strings */,
28D7205823808E8C0038D439 /* Extensions.swift */,
28D7205A2380C2090038D439 /* resolver.h */,
28D7205B2380C2090038D439 /* resolver.c */,
); );
path = "Pi-Helper"; path = "Pi-Helper";
sourceTree = "<group>"; sourceTree = "<group>";
@ -159,6 +174,15 @@
path = "Pi-HelperUITests"; path = "Pi-HelperUITests";
sourceTree = "<group>"; sourceTree = "<group>";
}; };
28D7204D238067B20038D439 /* Frameworks */ = {
isa = PBXGroup;
children = (
28D72050238067EC0038D439 /* libresolv.9.tbd */,
28D7204E238067B20038D439 /* libresolv.tbd */,
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@ -227,6 +251,7 @@
TargetAttributes = { TargetAttributes = {
2821266B235926E700072D52 = { 2821266B235926E700072D52 = {
CreatedOnToolsVersion = 11.0; CreatedOnToolsVersion = 11.0;
LastSwiftMigration = 1120;
}; };
28212681235926EB00072D52 = { 28212681235926EB00072D52 = {
CreatedOnToolsVersion = 11.0; CreatedOnToolsVersion = 11.0;
@ -297,9 +322,11 @@
282126AA235BEE0F00072D52 /* PiHoleApiService.swift in Sources */, 282126AA235BEE0F00072D52 /* PiHoleApiService.swift in Sources */,
282126AE235BF21D00072D52 /* AddPiHoleView.swift in Sources */, 282126AE235BF21D00072D52 /* AddPiHoleView.swift in Sources */,
28212670235926E700072D52 /* AppDelegate.swift in Sources */, 28212670235926E700072D52 /* AppDelegate.swift in Sources */,
28D7205C2380C2090038D439 /* resolver.c in Sources */,
28212672235926E700072D52 /* SceneDelegate.swift in Sources */, 28212672235926E700072D52 /* SceneDelegate.swift in Sources */,
282126B0235BF52F00072D52 /* ActivityIndicatorView.swift in Sources */, 282126B0235BF52F00072D52 /* ActivityIndicatorView.swift in Sources */,
28212674235926E700072D52 /* ContentView.swift in Sources */, 28212674235926E700072D52 /* ContentView.swift in Sources */,
28D7205923808E8C0038D439 /* Extensions.swift in Sources */,
282126A8235BE72800072D52 /* PiHole.swift in Sources */, 282126A8235BE72800072D52 /* PiHole.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@ -477,6 +504,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_ASSET_PATHS = "\"Pi-Helper/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"Pi-Helper/Preview Content\"";
DEVELOPMENT_TEAM = 9Z6DE6KNJ9; DEVELOPMENT_TEAM = 9Z6DE6KNJ9;
@ -488,6 +516,8 @@
); );
PRODUCT_BUNDLE_IDENTIFIER = "com.wbrawner.Pi-Helper"; PRODUCT_BUNDLE_IDENTIFIER = "com.wbrawner.Pi-Helper";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Pi-Helper/Pi-Helper-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
}; };
@ -497,6 +527,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_ASSET_PATHS = "\"Pi-Helper/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"Pi-Helper/Preview Content\"";
DEVELOPMENT_TEAM = 9Z6DE6KNJ9; DEVELOPMENT_TEAM = 9Z6DE6KNJ9;
@ -508,6 +539,7 @@
); );
PRODUCT_BUNDLE_IDENTIFIER = "com.wbrawner.Pi-Helper"; PRODUCT_BUNDLE_IDENTIFIER = "com.wbrawner.Pi-Helper";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Pi-Helper/Pi-Helper-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
}; };

View file

@ -1,168 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Bucket
uuid = "59EB2C60-D493-44EF-8FB1-43FCFB54B3E8"
type = "1"
version = "2.0">
<Breakpoints>
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
<BreakpointContent
uuid = "794E3259-7833-47A1-B889-ACE3E7B7B788"
shouldBeEnabled = "No"
ignoreCount = "0"
continueAfterRunningActions = "No"
filePath = "Pi-Helper/PiHoleDataStore.swift"
startingColumnNumber = "9223372036854775807"
endingColumnNumber = "9223372036854775807"
startingLineNumber = "21"
endingLineNumber = "21"
landmarkName = "loadSummary(_:apiKey:)"
landmarkType = "7">
</BreakpointContent>
</BreakpointProxy>
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
<BreakpointContent
uuid = "70FF2209-0733-48C4-B198-1F58A9D129D8"
shouldBeEnabled = "No"
ignoreCount = "0"
continueAfterRunningActions = "No"
filePath = "Pi-Helper/PiHoleDataStore.swift"
startingColumnNumber = "9223372036854775807"
endingColumnNumber = "9223372036854775807"
startingLineNumber = "33"
endingLineNumber = "33"
landmarkName = "loadSummary(_:apiKey:)"
landmarkType = "7">
</BreakpointContent>
</BreakpointProxy>
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
<BreakpointContent
uuid = "61C30D69-66FB-4844-8C00-1A8C239EB9E9"
shouldBeEnabled = "No"
ignoreCount = "0"
continueAfterRunningActions = "No"
filePath = "Pi-Helper/PiHoleDataStore.swift"
startingColumnNumber = "9223372036854775807"
endingColumnNumber = "9223372036854775807"
startingLineNumber = "36"
endingLineNumber = "36"
landmarkName = "loadSummary(_:apiKey:)"
landmarkType = "7">
</BreakpointContent>
</BreakpointProxy>
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
<BreakpointContent
uuid = "1206CF28-BA55-4380-8D4D-6806755092E4"
shouldBeEnabled = "No"
ignoreCount = "0"
continueAfterRunningActions = "No"
filePath = "Pi-Helper/ContentView.swift"
startingColumnNumber = "9223372036854775807"
endingColumnNumber = "9223372036854775807"
startingLineNumber = "19"
endingLineNumber = "19"
landmarkName = "stateContent"
landmarkType = "24">
</BreakpointContent>
</BreakpointProxy>
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
<BreakpointContent
uuid = "19509035-C3D0-4116-8A99-AAFB00F61136"
shouldBeEnabled = "No"
ignoreCount = "0"
continueAfterRunningActions = "No"
filePath = "Pi-Helper/ContentView.swift"
startingColumnNumber = "9223372036854775807"
endingColumnNumber = "9223372036854775807"
startingLineNumber = "21"
endingLineNumber = "21"
landmarkName = "stateContent"
landmarkType = "24">
</BreakpointContent>
</BreakpointProxy>
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
<BreakpointContent
uuid = "B75FD518-9E34-4579-89C6-27498913E3DC"
shouldBeEnabled = "No"
ignoreCount = "0"
continueAfterRunningActions = "No"
filePath = "Pi-Helper/ContentView.swift"
startingColumnNumber = "9223372036854775807"
endingColumnNumber = "9223372036854775807"
startingLineNumber = "23"
endingLineNumber = "23"
landmarkName = "stateContent"
landmarkType = "24">
</BreakpointContent>
</BreakpointProxy>
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
<BreakpointContent
uuid = "F5CC5BA4-EE2D-45D1-8487-3D9C78C6D735"
shouldBeEnabled = "No"
ignoreCount = "0"
continueAfterRunningActions = "No"
filePath = "Pi-Helper/PiHoleDataStore.swift"
startingColumnNumber = "9223372036854775807"
endingColumnNumber = "9223372036854775807"
startingLineNumber = "104"
endingLineNumber = "104"
landmarkName = "disable(_:)"
landmarkType = "7">
</BreakpointContent>
</BreakpointProxy>
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
<BreakpointContent
uuid = "6C7C0D86-0A5F-403F-A1B6-36AB78805BD1"
shouldBeEnabled = "No"
ignoreCount = "0"
continueAfterRunningActions = "No"
filePath = "Pi-Helper/PiHoleDataStore.swift"
startingColumnNumber = "9223372036854775807"
endingColumnNumber = "9223372036854775807"
startingLineNumber = "101"
endingLineNumber = "101"
landmarkName = "disable(_:)"
landmarkType = "7">
</BreakpointContent>
</BreakpointProxy>
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
<BreakpointContent
uuid = "A60B4D50-A0F1-4565-AB8B-B40648E7A622"
shouldBeEnabled = "No"
ignoreCount = "0"
continueAfterRunningActions = "No"
filePath = "Pi-Helper/PiHoleApiService.swift"
startingColumnNumber = "9223372036854775807"
endingColumnNumber = "9223372036854775807"
startingLineNumber = "64"
endingLineNumber = "64"
landmarkName = "get(_:queries:)"
landmarkType = "7">
</BreakpointContent>
</BreakpointProxy>
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
<BreakpointContent
uuid = "918A86D9-9B8C-4C31-8536-B20357C2E017"
shouldBeEnabled = "Yes"
ignoreCount = "0"
continueAfterRunningActions = "No"
filePath = "Pi-Helper/SceneDelegate.swift"
startingColumnNumber = "9223372036854775807"
endingColumnNumber = "9223372036854775807"
startingLineNumber = "79"
endingLineNumber = "79"
landmarkName = "windowScene(_:performActionFor:completionHandler:)"
landmarkType = "7">
</BreakpointContent>
</BreakpointProxy>
</Breakpoints>
</Bucket>

View file

@ -9,17 +9,22 @@
import SwiftUI import SwiftUI
struct AddPiHoleView: View { struct AddPiHoleView: View {
@State var ipAddress: String = "" var statefulContent: AnyView {
@State var apiKey: String = "" switch self.dataStore.pihole {
case .failure(let piholeError):
switch piholeError {
case .scanning(let ipAddress):
return ScanningView(dataStore: self.dataStore, ipAddress: ipAddress).toAnyView()
default:
return ManuallyAddPiHoleView(dataStore: self.dataStore).toAnyView()
}
default:
return ManuallyAddPiHoleView(dataStore: self.dataStore).toAnyView()
}
}
var body: some View { var body: some View {
VStack { statefulContent
Text("add_pihole")
TextField("ip_address", text: $ipAddress)
SecureField("api_key_optional", text: $apiKey)
Button(action: { self.dataStore.loadSummary(self.ipAddress, apiKey: self.apiKey) }, label: { Text("connect") })
}
.padding()
} }
@ObservedObject var dataStore: PiHoleDataStore @ObservedObject var dataStore: PiHoleDataStore
@ -28,6 +33,62 @@ struct AddPiHoleView: View {
} }
} }
struct ManuallyAddPiHoleView: View {
@State var ipAddress: String = ""
@State var apiKey: String = ""
@ObservedObject var dataStore: PiHoleDataStore
var body: some View {
VStack {
Text("add_pihole")
TextField("ip_address", text: $ipAddress)
SecureField("api_key_optional", text: $apiKey)
Button(action: {
self.dataStore.baseUrl = self.ipAddress
self.dataStore.apiKey = self.apiKey
self.dataStore.loadSummary()
}, label: { Text("connect") })
.padding()
ScanButton(dataStore: self.dataStore)
}
.padding()
}
}
struct ScanButton: View {
@ObservedObject var dataStore: PiHoleDataStore
var statefulContent: AnyView {
if let deviceIpAddress = resolver_get_device_ip() {
return Button(action: {
self.dataStore.beginScanning(String(cString: deviceIpAddress))
}, label: { Text("scan") }).padding().toAnyView()
} else {
return EmptyView().toAnyView()
}
}
var body: some View { statefulContent }
}
struct ScanningView: View {
let dataStore: PiHoleDataStore
let ipAddress: String
var body: some View {
VStack {
ActivityIndicatorView(.constant(true))
Text("scanning_ip_address")
Text(verbatim: ipAddress)
Button(action: {
self.dataStore.cancelRequest()
}, label: { Text("cancel") }
)
.padding()
}
}
}
struct AddPiHoleView_Previews: PreviewProvider { struct AddPiHoleView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
AddPiHoleView(PiHoleDataStore()) AddPiHoleView(PiHoleDataStore())

View file

@ -16,11 +16,11 @@ struct ContentView: View {
var stateContent: AnyView { var stateContent: AnyView {
switch self.dataStore.pihole { switch self.dataStore.pihole {
case .success(_): case .success(_):
return AnyView(PiHoleDetailsView(self.dataStore)) return PiHoleDetailsView(self.dataStore).toAnyView()
case .failure(.loading): case .failure(.networkError(.loading)):
return AnyView(ActivityIndicatorView(.constant(true))) return ActivityIndicatorView(.constant(true)).toAnyView()
default: default:
return AnyView(AddPiHoleView(self.dataStore)) return AddPiHoleView(self.dataStore).toAnyView()
} }
} }

View file

@ -0,0 +1,16 @@
//
// Extensions.swift
// Pi-Helper
//
// Created by Billy Brawner on 11/16/19.
// Copyright © 2019 William Brawner. All rights reserved.
//
import Foundation
import SwiftUI
extension View {
func toAnyView() -> AnyView {
return AnyView(self)
}
}

View file

@ -0,0 +1,5 @@
//
// Use this file to import your target's public headers that you would like to expose to Swift.
//
#include "resolver.h"

View file

@ -110,6 +110,10 @@ struct StatusUpdate: Codable {
let status: String let status: String
} }
struct VersionResponse: Codable {
let version: Int
}
enum PiHoleStatus { enum PiHoleStatus {
case enabled case enabled
case disabled case disabled

View file

@ -20,6 +20,10 @@ class PiHoleApiService {
self.decoder = decoder self.decoder = decoder
} }
func getVersion() -> AnyPublisher<VersionResponse, NetworkError> {
return get(queries: ["version": nil])
}
func loadSummary() -> AnyPublisher<PiHole, NetworkError> { func loadSummary() -> AnyPublisher<PiHole, NetworkError> {
return get() return get()
} }
@ -68,6 +72,7 @@ class PiHoleApiService {
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.addValue("application/json", forHTTPHeaderField: "Content-Type") request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpMethod = "GET" request.httpMethod = "GET"
request.timeoutInterval = 0.5
let task = URLSession.shared.dataTaskPublisher(for: request) let task = URLSession.shared.dataTaskPublisher(for: request)
.tryMap { (data, res) -> Data in .tryMap { (data, res) -> Data in
@ -91,6 +96,7 @@ class PiHoleApiService {
enum NetworkError: Error { enum NetworkError: Error {
case loading case loading
case cancelled
case badRequest case badRequest
case notFound case notFound
case unauthorized case unauthorized

View file

@ -11,22 +11,95 @@ import Combine
import UIKit import UIKit
class PiHoleDataStore: ObservableObject { class PiHoleDataStore: ObservableObject {
var pihole: Result<PiHole, NetworkError> = .failure(.notFound) { private let IP_MIN = 0
private let IP_MAX = 255
private var currentRequest: AnyCancellable? = nil
@Published var pihole: Result<PiHole, PiHoleError> = .failure(.notConfigured)
var apiKey: String? = nil {
didSet { didSet {
self.objectWillChange.send() UserDefaults.standard.set(apiKey, forKey: PiHoleDataStore.API_KEY)
}
}
func loadSummary(_ host: String, apiKey: String? = nil) {
self.pihole = .failure(.loading)
var safeHost = host
if !host.starts(with: "http://") || !host.starts(with: "https://") {
safeHost = "http://" + safeHost
}
apiService.baseUrl = safeHost
apiService.apiKey = apiKey apiService.apiKey = apiKey
_ = apiService.loadSummary() }
}
var baseUrl: String? = nil {
didSet {
let safeHost = prependScheme(baseUrl)
UserDefaults.standard.set(safeHost, forKey: PiHoleDataStore.HOST_KEY)
apiService.baseUrl = safeHost
}
}
private func prependScheme(_ ipAddress: String?) -> String? {
guard let host = ipAddress else {
return nil
}
if !host.starts(with: "http://") && !host.starts(with: "https://") {
return "http://" + host
}
return host
}
func beginScanning(_ ipAddress: String) {
var addressParts = ipAddress.split(separator: ".")
var chunks = 1
var ipAddresses = [String]()
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)
}
private func scan(_ ipAddresses: [String]) {
if ipAddresses.isEmpty {
self.pihole = .failure(.notConfigured)
return
}
guard let ipAddress = prependScheme(ipAddresses[0]) else {
return
}
self.apiService.baseUrl = ipAddress
self.pihole = .failure(.scanning(ipAddress))
print("Scanning \(ipAddress)")
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.loadSummary()
})
}
func cancelRequest() {
self.currentRequest?.cancel()
self.pihole = .failure(.networkError(.cancelled))
}
func loadSummary() {
self.pihole = .failure(.networkError(.loading))
currentRequest = apiService.loadSummary()
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink(receiveCompletion: { (completion) in .sink(receiveCompletion: { (completion) in
switch completion { switch completion {
@ -34,11 +107,9 @@ class PiHoleDataStore: ObservableObject {
// no-op // no-op
return return
case .failure(let error): case .failure(let error):
self.pihole = .failure(error) self.pihole = .failure(.networkError(error))
} }
}, receiveValue: { pihole in }, receiveValue: { pihole in
UserDefaults.standard.set(host, forKey: PiHoleDataStore.HOST_KEY)
UserDefaults.standard.set(apiKey, forKey: PiHoleDataStore.API_KEY)
UIApplication.shared.shortcutItems = [ UIApplication.shared.shortcutItems = [
UIApplicationShortcutItem( UIApplicationShortcutItem(
type: ShortcutAction.enable.rawValue, type: ShortcutAction.enable.rawValue,
@ -75,8 +146,8 @@ class PiHoleDataStore: ObservableObject {
func enable() { func enable() {
let oldPihole = try! pihole.get() let oldPihole = try! pihole.get()
self.pihole = .failure(.loading) self.pihole = .failure(.networkError(.loading))
_ = self.apiService.enable() currentRequest = self.apiService.enable()
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink(receiveCompletion: { (completion) in .sink(receiveCompletion: { (completion) in
switch completion { switch completion {
@ -84,7 +155,7 @@ class PiHoleDataStore: ObservableObject {
// no-op // no-op
return return
case .failure(let error): case .failure(let error):
self.pihole = .failure(error) self.pihole = .failure(.networkError(error))
} }
}, receiveValue: { newStatus in }, receiveValue: { newStatus in
self.pihole = .success(oldPihole.copy(status: newStatus.status)) self.pihole = .success(oldPihole.copy(status: newStatus.status))
@ -93,8 +164,8 @@ class PiHoleDataStore: ObservableObject {
func disable(_ forSeconds: Int? = nil) { func disable(_ forSeconds: Int? = nil) {
let oldPihole = try! pihole.get() let oldPihole = try! pihole.get()
self.pihole = .failure(.loading) self.pihole = .failure(.networkError(.loading))
_ = self.apiService.disable(forSeconds) currentRequest = self.apiService.disable(forSeconds)
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink(receiveCompletion: { (completion) in .sink(receiveCompletion: { (completion) in
switch completion { switch completion {
@ -102,26 +173,25 @@ class PiHoleDataStore: ObservableObject {
// no-op // no-op
return return
case .failure(let error): case .failure(let error):
self.pihole = .failure(error) self.pihole = .failure(.networkError(error))
} }
}, receiveValue: { newStatus in }, receiveValue: { newStatus in
self.pihole = .success(oldPihole.copy(status: newStatus.status)) self.pihole = .success(oldPihole.copy(status: newStatus.status))
}) })
} }
let objectWillChange = ObservableObjectPublisher()
let apiService = PiHoleApiService() let apiService = PiHoleApiService()
static let HOST_KEY = "host" static let HOST_KEY = "host"
static let API_KEY = "apiKey" static let API_KEY = "apiKey"
init() {
if let host = UserDefaults.standard.string(forKey: PiHoleDataStore.HOST_KEY) {
let apiKey = UserDefaults.standard.string(forKey: PiHoleDataStore.API_KEY)
loadSummary(host, apiKey: apiKey)
}
}
} }
enum ShortcutAction: String { enum ShortcutAction: String {
case enable = "EnableAction" case enable = "EnableAction"
case disable = "DisableAction" case disable = "DisableAction"
} }
enum PiHoleError : Error {
case networkError(_ error: NetworkError)
case scanning(_ ipAddress: String)
case notConfigured
}

View file

@ -18,8 +18,20 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene. // If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
self.dataStore = PiHoleDataStore() self.dataStore = PiHoleDataStore()
if let host = UserDefaults.standard.string(forKey: PiHoleDataStore.HOST_KEY) {
// 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!.baseUrl = host
self.dataStore!.apiKey = apiKey
self.dataStore!.loadSummary()
} 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 there
let dnsServerIp = String(cString: cString)
self.dataStore!.baseUrl = dnsServerIp
self.dataStore!.loadSummary()
}
// Create the SwiftUI view that provides the window contents. // Create the SwiftUI view that provides the window contents.
let contentView = ContentView(self.dataStore!) let contentView = ContentView(self.dataStore!)

View file

@ -9,6 +9,9 @@
"add_pihole" = "Add the IP address or hostname of your Pi-Hole to be able to view statistics. To enable or disable the Pi-Hole, you'll also need to enter the API key."; "add_pihole" = "Add the IP address or hostname of your Pi-Hole to be able to view statistics. To enable or disable the Pi-Hole, you'll also need to enter the API key.";
"ip_address" = "IP Address"; "ip_address" = "IP Address";
"api_key_optional" = "API Key (Optional)"; "api_key_optional" = "API Key (Optional)";
"scanning_ip_address" = "Scanning the network for your Pi-Hole…";
"scan" = "Scan";
"cancel" = "Cancel";
"connect" = "Connect"; "connect" = "Connect";
"status" = "Status"; "status" = "Status";
"enabled" = "Enabled"; "enabled" = "Enabled";

View file

@ -9,6 +9,9 @@
"add_pihole" = "Agrega la dirección IP o nombre de host de tu Pi-Hole para poder ver las estadísticas. Para habilitar o deshabilitar el Pi-Hole, también es necesario entregar la clave API."; "add_pihole" = "Agrega la dirección IP o nombre de host de tu Pi-Hole para poder ver las estadísticas. Para habilitar o deshabilitar el Pi-Hole, también es necesario entregar la clave API.";
"ip_address" = "Dirección IP"; "ip_address" = "Dirección IP";
"api_key_optional" = "Clave API (Opcional)"; "api_key_optional" = "Clave API (Opcional)";
"scanning_ip_address" = "Escaneando la red para el Pi-Hole…";
"scan" = "Escanear";
"cancel" = "Cancelar";
"connect" = "Conectar"; "connect" = "Conectar";
"status" = "Estado"; "status" = "Estado";
"enabled" = "Habilitado"; "enabled" = "Habilitado";

77
Pi-Helper/resolver.c Normal file
View file

@ -0,0 +1,77 @@
//
// resolver.c
// Pi-Helper
//
// Created by Billy Brawner on 11/16/19.
// Copyright © 2019 William Brawner. All rights reserved.
//
#include "resolver.h"
char * resolver_get_dns_server_ip(void) {
res_state state = malloc(sizeof(res_state));
res_ninit(state);
union res_sockaddr_union sockaddr_unions[MAX_SERVERS];
int servers_found = res_getservers(state, sockaddr_unions, MAX_SERVERS);
res_ndestroy(state);
for (int i = 0; i < servers_found; i++) {
union res_sockaddr_union sockaddr_union = sockaddr_unions[i];
if (sockaddr_union.sin.sin_len < 1) {
continue;
}
char * ipAddress = malloc(NI_MAXHOST + 1);
ipAddress[NI_MAXHOST] = '\0';
getnameinfo(
&sockaddr_union.sin,
sockaddr_union.sin.sin_len,
ipAddress,
MAX_IP_ADDR_LEN,
NULL,
0,
NI_NUMERICHOST
);
return ipAddress;
}
return NULL;
}
char * resolver_get_device_ip(void) {
struct ifaddrs *ifaddr, *ifa;
if (getifaddrs(&ifaddr) == -1) {
return NULL;
}
int n;
for (ifa = ifaddr, n = 0; ifa != NULL; ifa = ifa->ifa_next, n++) {
if (ifa->ifa_addr == NULL) {
continue;
}
if (ifa->ifa_addr->sa_family != AF_INET) {
continue;
}
if (strcmp("en0", ifa->ifa_name) == 0
|| strcmp("en1", ifa->ifa_name) == 0
|| strcmp("en2", ifa->ifa_name) == 0
|| strcmp("en3", ifa->ifa_name) == 0
|| strcmp("en4", ifa->ifa_name) == 0) {
char *ipAddress = malloc(NI_MAXHOST + 1);
ipAddress[NI_NUMERICHOST] = '\0';
getnameinfo(
ifa->ifa_addr,
ifa->ifa_addr->sa_len,
ipAddress,
NI_MAXHOST,
NULL,
0,
NI_NUMERICHOST
);
freeifaddrs(ifaddr);
return ipAddress;
}
}
freeifaddrs(ifaddr);
return NULL;
}

26
Pi-Helper/resolver.h Normal file
View file

@ -0,0 +1,26 @@
//
// resolver.h
// Pi-Helper
//
// Created by Billy Brawner on 11/16/19.
// Copyright © 2019 William Brawner. All rights reserved.
//
#ifndef resolver_h
#define resolver_h
#include <stdlib.h>
#include <resolv.h>
#include <limits.h>
#include <netdb.h>
#include <ifaddrs.h>
#include <string.h>
const int MAX_SERVERS = 10;
// String length for max IP address length (255.255.255.255)
const int MAX_IP_ADDR_LEN = 15;
char * resolver_get_dns_server_ip(void);
char * resolver_get_device_ip(void);
#endif /* resolver_h */