mirror of
https://github.com/seemoo-lab/openhaystack.git
synced 2026-02-18 19:49:55 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7a6135d95 | ||
|
|
9406f817f3 | ||
|
|
ab1c3eb83a | ||
|
|
b56aa1faa7 |
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user