diff --git a/Pihole.swift b/Pihole.swift new file mode 100644 index 0000000..8969295 --- /dev/null +++ b/Pihole.swift @@ -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) { + 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) { + 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) + } +} diff --git a/PiholeItemConfigView.swift b/PiholeItemConfigView.swift new file mode 100644 index 0000000..b2f6ca2 --- /dev/null +++ b/PiholeItemConfigView.swift @@ -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.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) + } +} diff --git a/PiholeListViewModel.swift b/PiholeListViewModel.swift new file mode 100644 index 0000000..0271b22 --- /dev/null +++ b/PiholeListViewModel.swift @@ -0,0 +1,50 @@ +// +// PiholeListViewModel.swift +// PiHoleStats +// +// Created by Fernando Bunn on 09/06/2020. +// Copyright © 2020 Fernando Bunn. All rights reserved. +// + +import Foundation + +class PiholeListViewModel: ObservableObject { + private let piholeDataProvider: PiholeDataProvider + var piholes: [Pihole] { + piholeDataProvider.piholes + } + + init(piholeDataProvider: PiholeDataProvider) { + self.piholeDataProvider = piholeDataProvider + } + + func addStubPihole() -> Pihole { + let pihole = Pihole(address: "127.0.0.1") + piholeDataProvider.add(pihole) + return pihole + } + + func remove(_ pihole: Pihole) { + piholeDataProvider.remove(pihole) + pihole.delete() + } + + func itemViewModel(_ pihole: Pihole) -> PiholeViewModel { + let model = PiholeViewModel(piHole: pihole) + model.delegate = self + return model + } +} + +extension PiholeListViewModel: PiholeViewModelDelegate { + func piholeViewModelDidSave(_ piholeViewModel: PiholeViewModel, address: String, token: String, secure: Bool, reverserproxy: Bool) { + objectWillChange.send() + if let index = piholeDataProvider.piholes.firstIndex(where: {$0.id == piholeViewModel.piHole.id}) { + piholeDataProvider.piholes[index].address = address + piholeDataProvider.piholes[index].apiToken = token + piholeDataProvider.piholes[index].secure = secure + piholeDataProvider.piholes[index].reverserproxy = reverserproxy + piholeDataProvider.piholes[index].save() + } + } +} diff --git a/PiholeViewModel.swift b/PiholeViewModel.swift new file mode 100644 index 0000000..55b8fb3 --- /dev/null +++ b/PiholeViewModel.swift @@ -0,0 +1,61 @@ +// +// PiHoleViewModel.swift +// PiHoleStats +// +// Created by Fernando Bunn on 31/05/2020. +// Copyright © 2020 Fernando Bunn. All rights reserved. +// + +import Foundation +import SwiftUI + +enum SecureTag: Int { + case unsecure + case secure +} + +protocol PiholeViewModelDelegate: AnyObject { + func piholeViewModelDidSave(_ piholeViewModel: PiholeViewModel, address: String, token: String, secure: Bool, reverserproxy: Bool) +} + +class PiholeViewModel: ObservableObject { + @Published var address: String + @Published var token: String + @Published var secure: Bool + @Published var reverserproxy: Bool + @Published var secureTag: SecureTag { + didSet { + secure = secureTag == SecureTag.secure + } + } + + weak var delegate: PiholeViewModelDelegate? + let piHole: Pihole + + var json: String { + return """ + { + "pihole": { + "host": "\(piHole.address)", + "port": \(piHole.port ?? 80), + "token": "\(piHole.apiToken)", + "secure": \(piHole.secure) + "reverserproxy": \(piHole.reverserproxy) + } + } + """ + } + + internal init(piHole: Pihole) { + self.piHole = piHole + self.address = piHole.address + self.token = piHole.apiToken + self.secure = piHole.secure + self.reverserproxy = piHole.reverserproxy + self.secureTag = piHole.secure ? SecureTag.secure : SecureTag.unsecure + } + + func save() { + delegate?.piholeViewModelDidSave(self, address: address, token: token, secure: secure, reverserproxy: reverserproxy) + } +} diff --git a/UIConstants.swift b/UIConstants.swift new file mode 100644 index 0000000..e275a6e --- /dev/null +++ b/UIConstants.swift @@ -0,0 +1,103 @@ +// +// UIConstants.swift +// PiHoleStats +// +// Created by Fernando Bunn on 11/05/2020. +// Copyright © 2020 Fernando Bunn. All rights reserved. +// + +import Foundation +import SwiftUI + +struct UIConstants { + struct Geometry { + static let circleSize: CGFloat = 10.0 + } + struct Colors { + static let disabled = Color("disabled") + static let enabled = Color("enabled") + static let enabledAndDisabled = Color("enabledAndDisabled") + static let totalQuery = Color("totalQuery") + static let domainBlocked = Color("domainBlocked") + static let queryBlocked = Color("queryBlocked") + static let percentBlocked = Color("percentBlocked") + } + + struct NSColors { + static let disabled = NSColor(named: "disabled") + static let enabledAndDisabled = NSColor(named: "enabledAndDisabled") + } + + struct Images { + static let globe = "globe" + static let QRCode = "qrcode" + } + + struct Strings { + + struct Error { + static let invalidAPIToken = "Invalid API Token" + static let invalidResponse = "Invalid Response" + static let invalidURL = "Invalid URL" + static let decodeResponseError = "Can't decode response" + static let noAPITokenProvided = "No API Token Provided" + static let sessionError = "Session Error" + } + + static let totalQueries = "Total Queries" + static let queriesBlocked = "Queries Blocked" + static let percentBlocked = "Percent Blocked" + static let domainsOnBlocklist = "Domains on Blocklist" + static let buttonPreferences = "Preferences" + static let buttonOK = "OK" + static let buttonQuit = "Quit" + static let statusEnabled = "Active" + static let statusDisabled = "Offline" + static let statusNeedsAttention = "Needs Attention" + static let statusEnabledAndDisabled = "Partially Active" + static let buttonEnable = "Enable" + static let buttonDisable = "Disable" + static let host = "Host" + static let hostPlaceholder = "0.0.0.0" + static let apiToken = "API Token" + static let preferencesProtocol = "Protocol" + static let preferencesProtocolHTTP = "HTTP" + static let preferencesProtocolHTTPS = "HTTPS" + static let preferencesReverseProxy = "Reverse Proxy" + static let apiTokenPlaceholder = "token" + static let buttonClose = "Close" + static let findAPITokenInfo = "You can find the API Token on /etc/pihole/setupVars.conf under WEBPASSWORD or WebUI - Settings - API - Show API Token" + static let openPreferencesToConfigureFirstPihole = "Open Preferences to configure your Pi-hole" + static let tokenStoredOnKeychainInfo = "Your Pi-hole token is securely stored in your Mac's Keychain" + static let copyright = "Copyright © Fernando Bunn" + static let version = "Version" + static let piStatsName = "Pi Stats" + static let keepPopoverOpenPreference = "Keep popover open when clicking outside" + static let launchAtLogonPreference = "Launch at login" + static let preferencesWindowTitle = "Pi Stats Preferences" + static let disableTimeOptionsTitle = "Display disable time options" + static let displayStatusColorWhenPiholeIsOffline = "Display status color on menu bar icon when pi-hole is offline" + static let disableButtonOptionPermanently = "Permanently" + static let disableButtonOption30Seconds = "For 30 seconds" + static let disableButtonOption1Minute = "For 1 minute" + static let disableButtonOption5Minutes = "For 5 minutes" + static let buttonClearErrorMessages = "Clear" + static let preferencesPiholesTabTitle = "Pi-holes" + static let preferencesPreferencesTabTitle = "Preferences" + static let preferencesAboutTabTitle = "About" + static let addPiholeButton = "Add" + static let removePiholeButton = "Remove" + static let savePiholeButton = "Save" + static let noSelectedPiholeMessage = "Select a pi-hole on the left or click Add to setup a new pi-hole" + static let noAvailablePiholeToSelectMessage = "No pi-holes available, click Add to setup a new pi-hole" + static let warningButton = "⚠️" + static let openProjectWebsiteButton = "Project Website" + static let piStatsForMobileButton = "Pi Stats Mobile" + static let preferencesQRCodeFormat = "QR Code Format:" + static let preferencesQRCodeFormatWebInterface = "Web Interface" + static let preferencesQRCodeFormatPiStats = "Pi Stats" + static let preferencesQRCodeToolTip = "Display Pi-hole Settings as QR Code" + static let preferencesWebToolTip = "Open Pi-hole Web Interface" + + } +}