Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
219 changes: 219 additions & 0 deletions Pihole.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
//
// PiHole.swift
// PiHoleStats
//
// Created by Fernando Bunn on 24/05/2020.
// Copyright © 2020 Fernando Bunn. All rights reserved.
//

import Foundation
import SwiftHole
import os.log

class Pihole: Identifiable, Codable, ObservableObject {
private let log = Logger().osLog(describing: Pihole.self)
var address: String
var secure: Bool
var reverserproxy: Bool
var actionError: String?
var pollingError: String?
let id: UUID
private(set) var summary: Summary? {
didSet {
if summary?.status.lowercased() == "enabled" {
active = true
os_log("%@ summary has enabled status", log: self.log, type: .debug, address)
} else {
active = false
os_log("%@ summary has disabled status", log: self.log, type: .debug, address)
}
}
}
private(set) var active = false

private lazy var keychainToken = APIToken(accountName: self.id.uuidString)
var apiToken: String {
get {
keychainToken.token
}
set {
keychainToken.token = newValue
}
}

var port: Int? {
getPort(address)
}

var host: String {
address.components(separatedBy: ":").first ?? ""
}

private var service: SwiftHole {
SwiftHole(host: host, port: port, apiToken: apiToken, secure: secure, reverserproxy: reverserproxy)
}

enum CodingKeys: CodingKey {
case id
case address
case secure
case reverserproxy
}

required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(UUID.self, forKey: .id)
address = try container.decode(String.self, forKey: .address)

//New properties that might break decoded from older versions
do {
secure = try container.decode(Bool.self, forKey: .secure)
} catch {
secure = false
}

//New properties that might break decoded from older versions
do {
reverserproxy = try container.decode(Bool.self, forKey: .reverserproxy)
} catch {
reverserproxy = false
}
}

func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(address, forKey: .address)
try container.encode(secure, forKey: .secure)
try container.encode(reverserproxy, forKey: .reverserproxy)
}

public init(address: String, apiToken: String? = nil, piHoleID: UUID? = nil, secure: Bool = false, reverserproxy: Bool = false) {
self.address = address
self.secure = secure
self.reverserproxy = reverserproxy

if let piHoleID = piHoleID {
self.id = piHoleID
} else {
self.id = UUID()
}

if let apiToken = apiToken {
keychainToken.token = apiToken
}
}

private func getPort(_ address: String) -> Int? {
let split = address.components(separatedBy: ":")
guard let port = split.last else { return nil }
return Int(port)
}
}

// MARK: Network Methods

extension Pihole {
public func updateSummary(completion: @escaping (SwiftHoleError?) -> Void) {
service.fetchSummary { result in
switch result {
case .success(let summary):
self.summary = summary
completion(nil)
case .failure(let error):
self.summary = nil
completion(error)
}
}
}

public func enablePiHole(completion: @escaping (Result<Void, SwiftHoleError>) -> Void) {
service.enablePiHole { result in
switch result {
case .success:
self.active = true
os_log("%@ enable request success", log: self.log, type: .debug, self.address)
completion(result)
case .failure:
os_log("%@ enable request failure", log: self.log, type: .debug, self.address)
completion(result)
}
}
}

public func disablePiHole(seconds: Int = 0, completion: @escaping (Result<Void, SwiftHoleError>) -> Void) {
service.disablePiHole(seconds: seconds) { result in
switch result {
case .success:
self.active = false
os_log("%@ disable request success", log: self.log, type: .debug, self.address)
completion(result)
case .failure:
os_log("%@ disable request failure", log: self.log, type: .debug, self.address)
completion(result)
}
}
}
}

// MARK: I/O Methods

extension Pihole {
private static let piHoleListKey = "PiHoleStatsPiHoleList"

public func delete() {
var piholeList = Pihole.restoreAll()

if let index = piholeList.firstIndex(of: self) {
piholeList.remove(at: index)
}
save(piholeList)
self.keychainToken.delete()
}

public func save() {
var piholeList = Pihole.restoreAll()
if let index = piholeList.firstIndex(where: { $0.id == self.id }) {
piholeList[index] = self
} else {
piholeList.append(self)
}
save(piholeList)
}

private func save(_ list: [Pihole]) {
let encoder = JSONEncoder()
if let encoded = try? encoder.encode(list) {
let defaults = UserDefaults.standard
defaults.set(encoded, forKey: Pihole.piHoleListKey)
}
}

static func restoreAll() -> [Pihole] {
if let piHoleList = UserDefaults.standard.object(forKey: Pihole.piHoleListKey) as? Data {
let decoder = JSONDecoder()

if let list = try? decoder.decode([Pihole].self, from: piHoleList) {
return list
} else {
return [Pihole]()
}
} else {
return [Pihole]()
}
}

static func restore(_ uuid: UUID) -> Pihole? {
return Pihole.restoreAll().filter { $0.id == uuid }.first
}
}

extension Pihole: Hashable {
static func == (lhs: Pihole, rhs: Pihole) -> Bool {
return lhs.id == rhs.id
}

func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
138 changes: 138 additions & 0 deletions PiholeItemConfigView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
//
// PiHoleItemConfig.swift
// PiHoleStats
//
// Created by Fernando Bunn on 30/05/2020.
// Copyright © 2020 Fernando Bunn. All rights reserved.
//

import SwiftUI

struct PiholeItemConfigView: View {
@ObservedObject var piholeViewModel: PiholeViewModel
@EnvironmentObject var preferences: UserPreferences
@State private var width: CGFloat?
@State private var presentingQRCodePopOver = false
private let qrcodeSize: CGFloat = 300

private var qrcodeValue: String {
switch preferences.qrcodeFormat {
case .piStats:
return piholeViewModel.json
case .webInterface:
return piholeViewModel.token
}
}

private var selectedQRCodeFormatLabel: String {
switch preferences.qrcodeFormat {
case .webInterface:
return UIConstants.Strings.preferencesQRCodeFormatWebInterface
case .piStats:
return UIConstants.Strings.preferencesQRCodeFormatPiStats
}
}

enum LabelWidth: Preference {}
let labelWidth = GeometryPreferenceReader(
key: AppendValue<LabelWidth>.self,
value: { [$0.size.width] }
)

var body: some View {
VStack(alignment: .trailing) {
HStack {
Text(UIConstants.Strings.host)
.read(labelWidth)
.frame(width: width, alignment: .leading)
TextField(UIConstants.Strings.hostPlaceholder, text: $piholeViewModel.address)
}

HStack {
Text(UIConstants.Strings.apiToken)
.read(labelWidth)
.frame(width: width, alignment: .leading)
SecureField(UIConstants.Strings.apiTokenPlaceholder, text: $piholeViewModel.token)
}

HStack {
Text(UIConstants.Strings.preferencesProtocol)
.read(labelWidth)

Picker(selection: $piholeViewModel.secureTag, label: Text("")) {
Text(UIConstants.Strings.preferencesProtocolHTTP).tag(SecureTag.unsecure)
Text(UIConstants.Strings.preferencesProtocolHTTPS).tag(SecureTag.secure)
}
Toggle(isOn: self.$piholeViewModel.reverserproxy) {
Text(UIConstants.Strings.preferencesReverseProxy)
}
}

HStack {

Button(action: {
if let url = URL(string: "http://\(self.piholeViewModel.address)/admin/") {
NSWorkspace.shared.open(url) }
}, label: {
HStack {
Image(UIConstants.Images.globe)
.resizable().aspectRatio(contentMode: .fit)
.frame(width: 15)
}
})
.overlay(Tooltip(tooltip: UIConstants.Strings.preferencesWebToolTip))

Button(action: {
self.presentingQRCodePopOver.toggle()
}, label: {
HStack {
Image(UIConstants.Images.QRCode)
.resizable().aspectRatio(contentMode: .fit)
.frame(width: 13)
}
})
.overlay(Tooltip(tooltip: UIConstants.Strings.preferencesQRCodeToolTip))
.popover(isPresented: $presentingQRCodePopOver) {
VStack {
Image(nsImage: QRCodeGenerator().generateQRCode(from: self.qrcodeValue, with: NSSize(width: self.qrcodeSize, height: self.qrcodeSize)))
.interpolation(.none)
.padding()

HStack {
Text(UIConstants.Strings.preferencesQRCodeFormat)
MenuButton(label: Text(self.selectedQRCodeFormatLabel)) {
Button(action: {
self.preferences.qrcodeFormat = .webInterface
}, label: { Text(UIConstants.Strings.preferencesQRCodeFormatWebInterface) })
Button(action: {
self.preferences.qrcodeFormat = .piStats
}, label: { Text(UIConstants.Strings.preferencesQRCodeFormatPiStats) })
}
}
.padding(.bottom)
.padding(.horizontal)
}
}

Button(action: {
self.piholeViewModel.save()
}, label: {
Text(UIConstants.Strings.savePiholeButton)
})
}

Divider()

Text(UIConstants.Strings.findAPITokenInfo)
.font(.caption)
.multilineTextAlignment(.center)
.layoutPriority(1)
Divider()
Text(UIConstants.Strings.tokenStoredOnKeychainInfo)
.font(.footnote)
.multilineTextAlignment(.center)
.minimumScaleFactor(0.8)
.foregroundColor(.secondary)
} .assignMaxPreference(for: labelWidth.key, to: $width)
}
}
Loading