4 Commits

Author SHA1 Message Date
Alexander Heinrich
e7a6135d95 Showing error messages when the import fails 2021-03-15 10:36:28 +01:00
Alexander Heinrich
9406f817f3 Instead of showing a mail button a small circle is shown next to the reload button.
The circle is orange if the mail plug-in is disabled
2021-03-15 10:36:28 +01:00
Alexander Heinrich
ab1c3eb83a Adding a button that shows if the mail plug-in is active. The button turns red if the plug-in is not active.
Architectural changes discussed with @schmittner: Moving the FindMyController out of the environment and using the AccessoryController as the main entry point, also for downloading reports
The AccessoryController is now passed as an Environment Object again
2021-03-15 10:36:28 +01:00
Alexander Heinrich
b56aa1faa7 Added import and export options
Added the AccessoryController and the FindMyController to the SwiftUI Environment
2021-03-15 10:36:28 +01:00
6 changed files with 280 additions and 79 deletions

View File

@@ -15,11 +15,6 @@ import SwiftUI
class FindMyController: ObservableObject {
@Published var error: Error?
@Published var devices = [FindMyDevice]()
var accessories: AccessoryController
init(accessories: AccessoryController) {
self.accessories = accessories
}
func loadPrivateKeys(from data: Data, with searchPartyToken: Data, completion: @escaping (Error?) -> Void) {
do {
@@ -102,11 +97,6 @@ class FindMyController: ObservableObject {
self.fetchReports(with: token) { error in
let reports = self.devices.compactMap({ $0.reports }).flatMap({ $0 })
if reports.isEmpty == false {
self.accessories.updateWithDecryptedReports(devices: self.devices)
}
if let error = error {
completion(.failure(error))
os_log("Error: %@", String(describing: error))

View File

@@ -10,20 +10,23 @@
import Combine
import Foundation
import SwiftUI
import OSLog
class AccessoryController: ObservableObject {
@Published var accessories: [Accessory]
var selfObserver: AnyCancellable?
var listElementsObserver = [AnyCancellable]()
init(accessories: [Accessory]) {
let findMyController: FindMyController
init(accessories: [Accessory], findMyController: FindMyController) {
self.accessories = accessories
self.findMyController = findMyController
initAccessoryObserver()
initObserver()
}
convenience init() {
self.init(accessories: KeychainController.loadAccessoriesFromKeychain())
self.init(accessories: KeychainController.loadAccessoriesFromKeychain(), findMyController: FindMyController())
}
func initAccessoryObserver() {
@@ -88,6 +91,106 @@ class AccessoryController: ObservableObject {
}
return accessory
}
/// Export the accessories property list so it can be imported at another location
func export(accessories: [Accessory]) throws -> URL {
let propertyList = try PropertyListEncoder().encode(accessories)
let savePanel = NSSavePanel()
savePanel.allowedFileTypes = ["plist"]
savePanel.canCreateDirectories = true
savePanel.directoryURL = try FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false)
savePanel.message = "This export contains all private keys! Keep the file save to protect your location data"
savePanel.nameFieldLabel = "Filename"
savePanel.nameFieldStringValue = "openhaystack_accessories.plist"
savePanel.prompt = "Export"
savePanel.title = "Export accessories & keys"
let result = savePanel.runModal()
if result == .OK,
let url = savePanel.url {
// Store the accessory file
try propertyList.write(to: url)
return url
}
throw ImportError.cancelled
}
/// Let the user select a file to import the accessories exported by another OpenHaystack instance
func importAccessories() throws {
let openPanel = NSOpenPanel()
openPanel.allowedFileTypes = ["plist"]
openPanel.canCreateDirectories = true
openPanel.directoryURL = try FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false)
openPanel.message = "Import an accessories file that includes the private keys"
openPanel.prompt = "Import"
openPanel.title = "Import accessories & keys"
let result = openPanel.runModal()
if result == .OK,
let url = openPanel.url {
let propertyList = try Data(contentsOf: url)
var importedAccessories = try PropertyListDecoder().decode([Accessory].self, from: propertyList)
var updatedAccessories = self.accessories
// Filter out accessories with the same id (no duplicates)
importedAccessories = importedAccessories.filter({acc in !self.accessories.contains(where: {acc.id == $0.id})})
updatedAccessories.append(contentsOf: importedAccessories)
updatedAccessories.sort(by: {$0.name < $1.name})
self.accessories = updatedAccessories
//Update reports automatically. Do not report errors from here
self.downloadLocationReports { result in}
}
}
enum ImportError: Error {
case cancelled
}
//MARK: Location reports
/// Download the location reports from
/// - Parameter completion: called when the reports have been succesfully downloaded or the request has failed
func downloadLocationReports(completion: @escaping (Result<Void,OpenHaystackMainView.AlertType>) -> Void) {
AnisetteDataManager.shared.requestAnisetteData { result in
switch result {
case .failure(_):
completion(.failure(.activatePlugin))
case .success(let accountData):
guard let token = accountData.searchPartyToken,
token.isEmpty == false else {
completion(.failure(.searchPartyToken))
return
}
self.findMyController.fetchReports(for: self.accessories, with: token) { result in
switch result {
case .failure(let error):
os_log(.error, "Downloading reports failed %@", error.localizedDescription)
completion(.failure(.downloadingReportsFailed))
case .success(let devices):
let reports = devices.compactMap({ $0.reports }).flatMap({ $0 })
if reports.isEmpty {
completion(.failure(.noReportsFound))
}else {
self.updateWithDecryptedReports(devices: devices)
completion(.success(()))
}
}
}
}
}
}
}
class AccessoryControllerPreview: AccessoryController {

View File

@@ -1,4 +1,4 @@
//
//
// OpenHaystack Tracking personal Bluetooth devices via Apple's Find My network
//
// Copyright © 2021 Secure Mobile Networking Lab (SEEMOO)
@@ -21,6 +21,8 @@ struct ManageAccessoriesView: View {
@Binding var focusedAccessory: Accessory?
@Binding var accessoryToDeploy: Accessory?
@Binding var showESP32DeploySheet: Bool
@State var showMailPopup = false
var body: some View {
VStack {
@@ -38,10 +40,7 @@ struct ManageAccessoriesView: View {
}
}
.toolbar(content: {
Spacer()
Button(action: self.addAccessory) {
Label("Add accessory", systemImage: "plus")
}
self.toolbarView
})
.sheet(
isPresented: self.$showESP32DeploySheet,
@@ -75,6 +74,29 @@ struct ManageAccessoriesView: View {
.listStyle(SidebarListStyle())
}
/// All toolbar buttons shown
var toolbarView: some View {
Group {
Spacer()
Button(action: self.importAccessories, label: {
Label("Import accessories", systemImage: "square.and.arrow.down")
})
.help("Import accessories from a file")
Button(action: self.exportAccessories, label: {
Label("Export accessories", systemImage: "square.and.arrow.up")
})
.help("Export all accessories to a file")
Button(action: self.addAccessory) {
Label("Add accessory", systemImage: "plus")
}
.help("Add a new accessory")
}
}
/// Delete an accessory from the list of accessories.
func delete(accessory: Accessory) {
@@ -98,6 +120,28 @@ struct ManageAccessoriesView: View {
self.alertType = .keyError
}
}
func exportAccessories() {
do {
_ = try self.accessoryController.export(accessories: self.accessories)
}catch {
self.alertType = .exportFailed
}
}
func importAccessories() {
do {
try self.accessoryController.importAccessories()
}catch {
if let importError = error as? AccessoryController.ImportError,
importError == .cancelled {
//User cancelled the import. No error
return
}
self.alertType = .importFailed
}
}
}

View File

@@ -15,12 +15,11 @@ struct OpenHaystackMainView: View {
@State var loading = false
@EnvironmentObject var accessoryController: AccessoryController
@EnvironmentObject var findMyController: FindMyController
var accessories: [Accessory] {
return self.accessoryController.accessories
}
@State var showKeyError = false
@State var alertType: AlertType?
@State var popUpAlertType: PopUpAlertType?
@State var errorDescription: String?
@@ -30,7 +29,10 @@ struct OpenHaystackMainView: View {
@State var isLoading = false
@State var focusedAccessory: Accessory?
@State var accessoryToDeploy: Accessory?
@State var showMailPlugInPopover = false
@State var mailPluginIsActive = false
@State var showESP32DeploySheet = false
var body: some View {
@@ -43,7 +45,7 @@ struct OpenHaystackMainView: View {
accessoryToDeploy: self.$accessoryToDeploy,
showESP32DeploySheet: self.$showESP32DeploySheet
)
.frame(minWidth: 200, idealWidth: 200, maxWidth: .infinity, minHeight: 300, idealHeight: 400, maxHeight: .infinity, alignment: .center)
.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)
@@ -59,15 +61,7 @@ struct OpenHaystackMainView: View {
}
.frame(minWidth: 500, idealWidth: 500, maxWidth: .infinity, minHeight: 300, idealHeight: 400, maxHeight: .infinity, alignment: .center)
.toolbar(content: {
Picker("", selection: self.$mapType) {
Text("Satellite").tag(MKMapType.hybrid)
Text("Standard").tag(MKMapType.standard)
}
.pickerStyle(SegmentedPickerStyle())
Button(action: self.downloadLocationReports) {
Label("Reload", systemImage: "arrow.clockwise")
}
.disabled(self.accessories.isEmpty)
self.toolbarView
})
.alert(
item: self.$alertType,
@@ -110,6 +104,41 @@ struct OpenHaystackMainView: View {
}
}
}
/// All toolbar items shown
var toolbarView: some View {
Group {
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() {
@@ -135,42 +164,37 @@ struct OpenHaystackMainView: View {
/// Download the location reports for all current accessories. Shows an error if something fails, like plug-in is missing
func downloadLocationReports() {
self.checkPluginIsRunning { (running) in
guard running else {
self.alertType = .activatePlugin
return
}
guard !self.searchPartyToken.isEmpty,
let tokenData = self.searchPartyToken.data(using: .utf8)
else {
self.alertType = .searchPartyToken
return
}
withAnimation {
self.isLoading = true
}
findMyController.fetchReports(for: accessories, with: tokenData) { result in
switch result {
case .failure(let error):
os_log(.error, "Downloading reports failed %@", error.localizedDescription)
case .success(let devices):
let reports = devices.compactMap({ $0.reports }).flatMap({ $0 })
if reports.isEmpty {
withAnimation {
self.popUpAlertType = .noReportsFound
}
}
}
withAnimation {
self.isLoading = false
self.isLoading = true
self.accessoryController.downloadLocationReports { result in
self.isLoading = false
switch result {
case .failure(let alert):
if alert == .noReportsFound {
self.popUpAlertType = .noReportsFound
}else {
self.alertType = alert
}
case .success(_):
break
}
}
}
var mailStatePopover: some View {
HStack {
Image(systemName: "envelope")
.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")
.lineLimit(10)
.multilineTextAlignment(.leading)
}
}
.frame(maxWidth: 250)
.padding()
}
func deploy(accessory: Accessory) {
@@ -210,7 +234,7 @@ struct OpenHaystackMainView: View {
}
}
func checkPluginIsRunning(_ completion: ((Bool) -> Void)?) {
func checkPluginIsRunning(silent: Bool=false, _ completion: ((Bool) -> Void)?) {
// Check if Mail plugin is active
AnisetteDataManager.shared.requestAnisetteData { (result) in
DispatchQueue.main.async {
@@ -218,14 +242,18 @@ struct OpenHaystackMainView: View {
case .success(let accountData):
withAnimation {
self.searchPartyToken = String(data: accountData.searchPartyToken, encoding: .ascii) ?? ""
if self.searchPartyToken.isEmpty == false {
self.searchPartyTokenLoaded = true
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 {
if let error = error as? AnisetteDataError, silent == false {
switch error {
case .pluginNotFound:
self.alertType = .activatePlugin
@@ -233,7 +261,13 @@ struct OpenHaystackMainView: View {
self.alertType = .activatePlugin
}
}
self.mailPluginIsActive = false
completion?(false)
//Check again in 5s
DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: {
self.checkPluginIsRunning(silent: true, nil)
})
}
}
}
@@ -327,10 +361,24 @@ struct OpenHaystackMainView: View {
message: Text("Please select to which device you want to deploy"),
primaryButton: microbitButton,
secondaryButton: esp32Button)
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 {
enum AlertType: Int, Identifiable, Error {
var id: Int {
return self.rawValue
}
@@ -341,19 +389,22 @@ struct OpenHaystackMainView: View {
case deployedSuccessfully
case deletionFailed
case noReportsFound
case downloadingReportsFailed
case activatePlugin
case pluginInstallFailed
case selectDepoyTarget
case exportFailed
case importFailed
}
}
struct OpenHaystackMainView_Previews: PreviewProvider {
static var accessoryController = AccessoryControllerPreview(accessories: PreviewData.accessories) as AccessoryController
static var accessoryController = AccessoryControllerPreview(accessories: PreviewData.accessories, findMyController: FindMyController()) as AccessoryController
static var previews: some View {
OpenHaystackMainView()
.environmentObject(accessoryController)
.environmentObject(self.accessoryController)
}
}

View File

@@ -12,28 +12,41 @@ import SwiftUI
@main
struct OpenHaystackApp: App {
@StateObject var accessoryController: AccessoryController
@StateObject var findMyController: FindMyController
init() {
var accessoryController: AccessoryController
let accessoryController: AccessoryController
if ProcessInfo().arguments.contains("-preview") {
accessoryController = AccessoryControllerPreview(accessories: PreviewData.accessories)
accessoryController = AccessoryControllerPreview(accessories: PreviewData.accessories, findMyController: FindMyController())
} else {
accessoryController = AccessoryController()
}
self._accessoryController = StateObject(wrappedValue: accessoryController)
self._findMyController = StateObject(wrappedValue: FindMyController(accessories: accessoryController))
}
var body: some Scene {
WindowGroup {
OpenHaystackMainView()
.environmentObject(accessoryController)
.environmentObject(findMyController)
.environmentObject(self.accessoryController)
}
.commands {
SidebarCommands()
}
}
}
//MARK: Environment objects
private struct FindMyControllerEnvironmentKey: EnvironmentKey {
static let defaultValue: FindMyController = FindMyController()
}
private struct AccessoryControllerEnvironmentKey: EnvironmentKey {
static let defaultValue: AccessoryController = {
if ProcessInfo().arguments.contains("-preview") {
return AccessoryControllerPreview(accessories: PreviewData.accessories, findMyController: FindMyController())
} else {
return AccessoryController()
}
}()
}

View File

@@ -33,7 +33,7 @@ NS_ASSUME_NONNULL_BEGIN
@property(nonatomic, copy) NSLocale *locale;
@property(nonatomic, copy) NSTimeZone *timeZone;
@property(nonatomic, copy) NSData *searchPartyToken;
@property(nonatomic, copy) NSData * _Nullable searchPartyToken;
- (instancetype)initWithMachineID:(NSString *)machineID
oneTimePassword:(NSString *)oneTimePassword