Files
openhaystack/OpenHaystack/OpenHaystack/HaystackApp/Views/OpenHaystackMainView.swift
2021-04-13 09:44:17 +02:00

440 lines
16 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// OpenHaystack Tracking personal Bluetooth devices via Apple's Find My network
//
// Copyright © 2021 Secure Mobile Networking Lab (SEEMOO)
// Copyright © 2021 The Open Wireless Link Project
//
// SPDX-License-Identifier: AGPL-3.0-only
//
import MapKit
import OSLog
import SwiftUI
struct OpenHaystackMainView: View {
@State var loading = false
@EnvironmentObject var accessoryController: AccessoryController
var accessories: [Accessory] {
return self.accessoryController.accessories
}
@State var alertType: AlertType?
@State var popUpAlertType: PopUpAlertType?
@State var errorDescription: String?
@State var searchPartyToken: String = ""
@State var searchPartyTokenLoaded = false
@State var mapType: MKMapType = .standard
@State var isLoading = false
@State var focusedAccessory: Accessory?
@State var historyMapView = false
@State var historySeconds: TimeInterval = TimeInterval.Units.day.rawValue
@State var accessoryToDeploy: Accessory?
@State var showMailPlugInPopover = false
@State var mailPluginIsActive = false
@State var showESP32DeploySheet = false
var body: some View {
NavigationView {
ManageAccessoriesView(
alertType: self.$alertType,
focusedAccessory: self.$focusedAccessory,
accessoryToDeploy: self.$accessoryToDeploy,
showESP32DeploySheet: self.$showESP32DeploySheet
)
.frame(minWidth: 250, idealWidth: 280, maxWidth: .infinity, minHeight: 300, idealHeight: 400, maxHeight: .infinity, alignment: .center)
ZStack {
AccessoryMapView(
accessoryController: self.accessoryController, mapType: self.$mapType, focusedAccessory: self.$focusedAccessory, showHistory: self.$historyMapView,
showPastHistory: self.$historySeconds
)
.overlay(self.mapOverlay)
if self.popUpAlertType != nil {
VStack {
Spacer()
PopUpAlertView(alertType: self.popUpAlertType!)
.transition(AnyTransition.move(edge: .bottom))
.padding(.bottom, 30)
}
}
}
.frame(minWidth: 500, idealWidth: 500, maxWidth: .infinity, minHeight: 300, idealHeight: 400, maxHeight: .infinity, alignment: .center)
.toolbar(content: {
self.toolbarView
})
.alert(
item: self.$alertType,
content: { alertType in
return self.alert(for: alertType)
}
)
.onChange(of: self.searchPartyToken) { (searchPartyToken) in
guard !searchPartyToken.isEmpty, self.accessories.isEmpty == false else { return }
self.downloadLocationReports()
}
.onChange(
of: self.popUpAlertType,
perform: { popUpAlert in
guard popUpAlert != nil else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
self.popUpAlertType = nil
}
}
)
.onAppear {
self.onAppear()
}
}
.navigationTitle(self.focusedAccessory?.name ?? "Your accessories")
}
// MARK: Subviews
/// Overlay for the map that is gray and shows an activity indicator when loading.
var mapOverlay: some View {
ZStack {
if self.isLoading {
Rectangle()
.fill(Color.gray)
.opacity(0.5)
ActivityIndicator(size: .large)
}
}
}
/// All toolbar items shown.
var toolbarView: some View {
Group {
if self.historyMapView {
Text("\(TimeInterval(self.historySeconds).description)")
Slider<Text, EmptyView>.withLogScale(value: $historySeconds, in: 30 * TimeInterval.Units.minute.rawValue...TimeInterval.Units.week.rawValue) {
Text("Past time to show")
}
.frame(width: 80)
}
Toggle(isOn: $historyMapView) {
Label("Show location history", systemImage: "clock")
}
.disabled(self.focusedAccessory == nil)
Picker("", selection: self.$mapType) {
Text("Satellite").tag(MKMapType.hybrid)
Text("Standard").tag(MKMapType.standard)
}
.pickerStyle(SegmentedPickerStyle())
Button(
action: {
if !self.mailPluginIsActive {
self.showMailPlugInPopover.toggle()
} else {
self.downloadLocationReports()
}
},
label: {
HStack {
Circle()
.fill(self.mailPluginIsActive ? Color.green : Color.orange)
.frame(width: 8, height: 8)
Label("Reload", systemImage: "arrow.clockwise")
.disabled(!self.mailPluginIsActive)
}
}
)
.disabled(self.accessories.isEmpty)
.popover(
isPresented: $showMailPlugInPopover,
content: {
self.mailStatePopover
})
}
}
func onAppear() {
/// Checks if the search party token can be fetched without the Mail Plugin. If true the plugin is not needed for this environment. (e.g. when SIP is disabled)
let reportsFetcher = ReportsFetcher()
if let token = reportsFetcher.fetchSearchpartyToken(),
let tokenString = String(data: token, encoding: .ascii)
{
self.searchPartyToken = tokenString
return
}
let pluginManager = MailPluginManager()
// Check if the plugin is installed
if pluginManager.isMailPluginInstalled == false {
// Install the mail plugin
self.alertType = .activatePlugin
} else {
self.checkPluginIsRunning(nil)
}
}
/// Download the location reports for all current accessories. Shows an error if something fails, like plug-in is missing
func downloadLocationReports() {
self.isLoading = true
self.accessoryController.downloadLocationReports { result in
self.isLoading = false
switch result {
case .failure(let alert):
if alert == .noReportsFound {
self.popUpAlertType = .noReportsFound
} else {
if alert == .activatePlugin {
self.mailPluginIsActive = false
}
self.alertType = alert
}
case .success(_):
break
}
}
}
var mailStatePopover: some View {
VStack {
HStack {
Image(systemName: "envelope")
.font(.title)
.foregroundColor(self.mailPluginIsActive ? .green : .red)
if self.mailPluginIsActive {
Text("The mail plug-in is up and running")
} else {
Text("Cannot connect to the mail plug-in. Open Apple Mail and make sure the plug-in is enabled")
}
}
.padding()
}
.frame(width: 250, height: 120)
}
/// Ask to install and activate the mail plugin.
func installMailPlugin() {
let pluginManager = MailPluginManager()
guard pluginManager.isMailPluginInstalled == false else {
return
}
do {
try pluginManager.installMailPlugin()
} catch {
DispatchQueue.main.async {
self.alertType = .pluginInstallFailed
os_log(.error, "Could not install mail plugin\n %@", String(describing: error))
}
}
}
func checkPluginIsRunning(silent: Bool = false, _ completion: ((Bool) -> Void)?) {
// Check if Mail plugin is active
AnisetteDataManager.shared.requestAnisetteData { (result) in
DispatchQueue.main.async {
switch result {
case .success(let accountData):
withAnimation {
if let token = accountData.searchPartyToken {
self.searchPartyToken = String(data: token, encoding: .ascii) ?? ""
if self.searchPartyToken.isEmpty == false {
self.searchPartyTokenLoaded = true
}
}
}
self.mailPluginIsActive = true
self.showMailPlugInPopover = false
completion?(true)
case .failure(let error):
if let error = error as? AnisetteDataError, silent == false {
switch error {
case .pluginNotFound:
self.alertType = .activatePlugin
default:
self.alertType = .activatePlugin
}
}
self.mailPluginIsActive = false
completion?(false)
//Check again in 5s
DispatchQueue.main.asyncAfter(
deadline: .now() + 5,
execute: {
self.checkPluginIsRunning(silent: true, nil)
})
}
}
}
}
func downloadPlugin() {
do {
try MailPluginManager().pluginDownload()
} catch {
self.alertType = .pluginInstallFailed
}
}
// MARK: - Alerts
// swiftlint:disable function_body_length
/// Create an alert for the given alert type.
///
/// - Parameter alertType: current alert type
/// - Returns: A SwiftUI Alert
func alert(for alertType: AlertType) -> Alert {
switch alertType {
case .keyError:
return Alert(title: Text("Could not create accessory"), message: Text(String(describing: self.errorDescription)), dismissButton: Alert.Button.cancel())
case .searchPartyToken:
return Alert(
title: Text("Add the search party token"),
message: Text(
"""
Please paste the search party token below after copying itfrom the macOS Keychain.
The item that contains the key can be found by searching for:
com.apple.account.DeviceLocator.search-party-token
"""
),
dismissButton: Alert.Button.okay())
case .deployFailed:
return Alert(
title: Text("Could not deploy"),
message: Text("Deploying to microbit failed. Please reconnect the device over USB"),
dismissButton: Alert.Button.okay())
case .deployedSuccessfully:
return Alert(
title: Text("Deploy successfull"),
message: Text("This device will now be tracked by all iPhones and you can use this app to find its last reported location"),
dismissButton: Alert.Button.okay())
case .deletionFailed:
return Alert(title: Text("Could not delete accessory"), dismissButton: Alert.Button.okay())
case .noReportsFound:
return Alert(
title: Text("No reports found"),
message: Text("Your accessory might have not been found yet or it is not powered. Make sure it has enough power to be found by nearby iPhones"),
dismissButton: Alert.Button.okay())
case .activatePlugin:
let message =
"""
To access your Apple ID for downloading location reports we need to use a plugin in Apple Mail.
Please make sure Apple Mail is running.
Open Mail -> Preferences -> General -> Manage Plug-Ins... -> Select Haystack
We do not access any of your e-mail data. This is just necessary, because Apple blocks access to certain iCloud tokens otherwise.
"""
return Alert(
title: Text("Install & Activate Mail Plugin"), message: Text(message),
primaryButton: .default(Text("Okay"), action: { self.installMailPlugin() }),
secondaryButton: .cancel())
case .pluginInstallFailed:
return Alert(
title: Text("Mail Plugin installation failed"),
message: Text(
"To access the location reports of your devices an Apple Mail plugin is necessary"
+ "\nThe installtion of this plugin has failed.\n\n Please download it manually unzip it and move it to /Library/Mail/Bundles"),
primaryButton: .default(
Text("Download plug-in"),
action: {
self.downloadPlugin()
}), secondaryButton: .cancel())
case .downloadingReportsFailed:
return Alert(
title: Text("Downloading locations failed"),
message: Text("We could not download any locations from Apple. Please try again later"),
dismissButton: Alert.Button.okay())
case .exportFailed:
return Alert(
title: Text("Export failed"),
message: Text("Please check that no the folder is writable and that you have the most current version of the app"),
dismissButton: .okay())
case .importFailed:
return Alert(
title: Text("Import failed"),
message: Text("Could not import the selected file. Please make sure it has not been modified and that you have the current version of the app."),
dismissButton: .okay())
}
}
enum AlertType: Int, Identifiable, Error {
var id: Int {
return self.rawValue
}
case keyError
case searchPartyToken
case deployFailed
case deployedSuccessfully
case deletionFailed
case noReportsFound
case downloadingReportsFailed
case activatePlugin
case pluginInstallFailed
case exportFailed
case importFailed
}
}
struct OpenHaystackMainView_Previews: PreviewProvider {
static var accessoryController = AccessoryControllerPreview(accessories: PreviewData.accessories, findMyController: FindMyController()) as AccessoryController
static var previews: some View {
OpenHaystackMainView()
.environmentObject(self.accessoryController)
}
}
extension Alert.Button {
static func okay() -> Alert.Button {
Alert.Button.default(Text("Okay"))
}
}
extension TimeInterval {
var description: String {
var value = 0
var unit = Units.second
Units.allCases.forEach { u in
if self.rounded() >= u.rawValue {
value = Int((self / u.rawValue).rounded())
unit = u
}
}
return "\(value) \(unit.description)\(value > 1 ? "s" : "")"
}
enum Units: Double, CaseIterable {
case second = 1
case minute = 60
case hour = 3600
case day = 86400
case week = 604800
var description: String {
switch self {
case .second: return "Second"
case .minute: return "Minute"
case .hour: return "Hour"
case .day: return "Day"
case .week: return "Week"
}
}
}
}