12 Commits

Author SHA1 Message Date
Milan Stute
a88f5abeb4 Move nearby marker to the right 2021-03-15 17:16:01 +01:00
Milan Stute
cf0416e174 Unmark devices as nearby when they stop sending advertisements 2021-03-15 17:16:01 +01:00
Milan Stute
eb07546640 Update preview mode 2021-03-15 17:16:01 +01:00
Milan Stute
37de037986 Mark devices as active (orange) if they have been active in the past 2021-03-15 17:16:01 +01:00
Milan Stute
5117674ac9 Mark accessories as online when receiving Bluetooth advertisements 2021-03-15 17:16:01 +01:00
Milan Stute
d5546e1fa8 Disable deploy tests (will hang if no accessory is connected) 2021-03-15 12:56:26 +01:00
Milan Stute
1b6eadb301 Run autoformat 2021-03-15 12:56:08 +01:00
Milan Stute
2f32efef24 Mark accessory as deployed when deploy was successful 2021-03-15 12:51:07 +01:00
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
16 changed files with 581 additions and 78 deletions

View File

@@ -51,6 +51,10 @@
78EC226C25DBC2E40042B775 /* OpenHaystackMainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78EC226B25DBC2E40042B775 /* OpenHaystackMainView.swift */; };
78EC227225DBC8CE0042B775 /* Accessory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78EC227125DBC8CE0042B775 /* Accessory.swift */; };
78EC227725DBDB7E0042B775 /* KeychainController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78EC227625DBDB7E0042B775 /* KeychainController.swift */; };
F12D5A5A25FA4F3500CBBA09 /* BluetoothAccessoryScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = F12D5A5925FA4F3500CBBA09 /* BluetoothAccessoryScanner.swift */; };
F12D5A6025FA79FA00CBBA09 /* Advertisement.swift in Sources */ = {isa = PBXBuildFile; fileRef = F12D5A5F25FA79FA00CBBA09 /* Advertisement.swift */; };
F1647C1625FF6C61004144D6 /* BluetoothTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1647C1525FF6C61004144D6 /* BluetoothTests.swift */; };
F1647C1B25FF7954004144D6 /* AccessoryNearbyMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1647C1A25FF7954004144D6 /* AccessoryNearbyMonitor.swift */; };
F16BA9E925E7DB2D00238183 /* NIOSSL in Frameworks */ = {isa = PBXBuildFile; productRef = F16BA9E825E7DB2D00238183 /* NIOSSL */; };
/* End PBXBuildFile section */
@@ -150,6 +154,10 @@
78EC226B25DBC2E40042B775 /* OpenHaystackMainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHaystackMainView.swift; sourceTree = "<group>"; };
78EC227125DBC8CE0042B775 /* Accessory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Accessory.swift; sourceTree = "<group>"; };
78EC227625DBDB7E0042B775 /* KeychainController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainController.swift; sourceTree = "<group>"; };
F12D5A5925FA4F3500CBBA09 /* BluetoothAccessoryScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothAccessoryScanner.swift; sourceTree = "<group>"; };
F12D5A5F25FA79FA00CBBA09 /* Advertisement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Advertisement.swift; sourceTree = "<group>"; };
F1647C1525FF6C61004144D6 /* BluetoothTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothTests.swift; sourceTree = "<group>"; };
F1647C1A25FF7954004144D6 /* AccessoryNearbyMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessoryNearbyMonitor.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -315,6 +323,7 @@
78EC226325DAE0BE0042B775 /* OpenHaystackTests.swift */,
78EC226525DAE0BE0042B775 /* Info.plist */,
78023CB025F7841F00B083EF /* MicrocontrollerTests.swift */,
F1647C1525FF6C61004144D6 /* BluetoothTests.swift */,
);
path = OpenHaystackTests;
sourceTree = "<group>";
@@ -322,6 +331,7 @@
78EC226E25DBC2FC0042B775 /* HaystackApp */ = {
isa = PBXGroup;
children = (
F12D5A5E25FA79D600CBBA09 /* Bluetooth */,
78023CAC25F7775300B083EF /* Firmwares */,
78286D3A25E4017400F65511 /* Mail Plugin */,
78EC227025DBC8BB0042B775 /* Views */,
@@ -331,6 +341,7 @@
787D8AC025DECD3C00148766 /* AccessoryController.swift */,
78023CAA25F7767000B083EF /* ESP32Controller.swift */,
7821DAD025F7B2C10054DC33 /* FileManager.swift */,
F1647C1A25FF7954004144D6 /* AccessoryNearbyMonitor.swift */,
);
path = HaystackApp;
sourceTree = "<group>";
@@ -360,6 +371,15 @@
path = Views;
sourceTree = "<group>";
};
F12D5A5E25FA79D600CBBA09 /* Bluetooth */ = {
isa = PBXGroup;
children = (
F12D5A5925FA4F3500CBBA09 /* BluetoothAccessoryScanner.swift */,
F12D5A5F25FA79FA00CBBA09 /* Advertisement.swift */,
);
path = Bluetooth;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -575,6 +595,7 @@
78D9B80625F7CF60009B9CE8 /* ManageAccessoriesView.swift in Sources */,
78486BEF25DD711E0007ED87 /* PopUpAlertView.swift in Sources */,
78014A2925DC08580089F6D9 /* MicrobitController.swift in Sources */,
F1647C1B25FF7954004144D6 /* AccessoryNearbyMonitor.swift in Sources */,
78286D1F25E3D8B800F65511 /* ALTAnisetteData.m in Sources */,
781EB3EC25DAD7EA00FEAA19 /* DecryptReports.swift in Sources */,
78EC226C25DBC2E40042B775 /* OpenHaystackMainView.swift in Sources */,
@@ -587,10 +608,12 @@
781EB3F125DAD7EA00FEAA19 /* FindMyKeyDecoder.swift in Sources */,
787D8AC125DECD3C00148766 /* AccessoryController.swift in Sources */,
78023CAB25F7767000B083EF /* ESP32Controller.swift in Sources */,
F12D5A6025FA79FA00CBBA09 /* Advertisement.swift in Sources */,
781EB3F225DAD7EA00FEAA19 /* OpenHaystackApp.swift in Sources */,
781EB3F325DAD7EA00FEAA19 /* Models.swift in Sources */,
781EB3F425DAD7EA00FEAA19 /* FindMyController.swift in Sources */,
781EB3F525DAD7EA00FEAA19 /* BoringSSL.m in Sources */,
F12D5A5A25FA4F3500CBBA09 /* BluetoothAccessoryScanner.swift in Sources */,
78286D5625E401F000F65511 /* MailPluginManager.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -609,6 +632,7 @@
buildActionMask = 2147483647;
files = (
78023CB125F7841F00B083EF /* MicrocontrollerTests.swift in Sources */,
F1647C1625FF6C61004144D6 /* BluetoothTests.swift in Sources */,
78EC226425DAE0BE0042B775 /* OpenHaystackTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;

View File

@@ -38,6 +38,15 @@
ReferencedContainer = "container:OpenHaystack.xcodeproj">
</BuildableReference>
<SkippedTests>
<Test
Identifier = "MicrocontrollerTests/testESP32Deploy()">
</Test>
<Test
Identifier = "MicrocontrollerTests/testFindESP32Port()">
</Test>
<Test
Identifier = "MicrocontrollerTests/testMicrobitDeploy()">
</Test>
<Test
Identifier = "OpenHaystackTests/testPluginInstallation()">
</Test>

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

@@ -9,21 +9,24 @@
import Combine
import Foundation
import OSLog
import SwiftUI
class AccessoryController: ObservableObject {
@Published var accessories: [Accessory]
var selfObserver: AnyCancellable?
var listElementsObserver = [AnyCancellable]()
let findMyController: FindMyController
init(accessories: [Accessory]) {
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

@@ -0,0 +1,79 @@
//
// 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 Foundation
class AccessoryNearbyMonitor: BluetoothAccessoryDelegate {
var accessoryController: AccessoryController
var scanner: BluetoothAccessoryScanner
var cleanup: Timer?
init(accessoryController: AccessoryController) {
self.accessoryController = accessoryController
self.scanner = BluetoothAccessoryScanner()
self.initScanner()
self.initTimer()
}
func initScanner() {
self.scanner.delegate = self
}
func initTimer() {
self.cleanup = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
self.removeNearbyAccessories()
}
}
func received(_ advertisement: Advertisement) {
guard let accessory = getAccessoryForAdvertisement(advertisement) else {
return
}
updateNearbyAccessory(accessory)
}
func updateNearbyAccessory(_ accessory: Accessory) {
if !accessory.isNearby {
// Only set on state change
accessory.isNearby = true
}
accessory.lastAdvertisement = Date()
}
func removeNearbyAccessories(now: Date = Date(), timeout: TimeInterval = 10.0) {
let nearbyAccessories = self.accessoryController.accessories.filter({ $0.isNearby })
for accessory in nearbyAccessories {
guard let lastAdvertisement = accessory.lastAdvertisement else {
continue
}
if lastAdvertisement + timeout < now {
accessory.isNearby = false
}
}
}
func getAccessoryForAdvertisement(_ advertisement: Advertisement) -> Accessory? {
let accessory =
self.accessoryController.accessories.first {
isAdvertisement(advertisement, from: $0)
} ?? nil
return accessory
}
func isAdvertisement(_ advertisement: Advertisement, from: Accessory) -> Bool {
do {
let accessoryPublicKey = try from.getAdvertisementKey().advanced(by: 6)
return accessoryPublicKey == advertisement.publicKeyPayload
} catch {
return false
}
}
}

View File

@@ -0,0 +1,55 @@
//
// 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 CoreBluetooth
import Foundation
struct Advertisement {
let publicKeyPayload: Data
init?(fromAdvertisementData: [String: Any]) {
guard let manufacturerData = fromAdvertisementData[CBAdvertisementDataManufacturerDataKey] as? Data else {
return nil
}
self.init(fromManufacturerData: manufacturerData)
}
init?(fromManufacturerData: Data) {
guard let publicKey = Advertisement.extractPublicKeyFromPayload(fromManufacturerData) else {
return nil
}
self.publicKeyPayload = publicKey
}
static let publicKeyPayloadLength = 22
static func extractPublicKeyFromPayload(_ payload: Data) -> Data? {
guard payload.count == 29 else {
return nil
}
// Apple company ID
guard payload.subdata(in: 0..<2) == Data([0x4c, 0x00]) else {
return nil
}
// Offline finding sub type
guard payload.subdata(in: 2..<3) == Data([0x12]) else {
return nil
}
// Offline finding sub type length
guard payload.subdata(in: 3..<4) == Data([0x19]) else {
return nil
}
let publicKey = payload.subdata(in: 5..<5 + publicKeyPayloadLength)
guard publicKey.count == publicKeyPayloadLength else {
return nil
}
return publicKey
}
}

View File

@@ -0,0 +1,47 @@
//
// 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 CoreBluetooth
import Foundation
protocol BluetoothAccessoryDelegate {
func received(_ advertisement: Advertisement)
}
public class BluetoothAccessoryScanner: NSObject, CBCentralManagerDelegate {
var scanner: CBCentralManager!
var delegate: BluetoothAccessoryDelegate?
override init() {
super.init()
scanner = CBCentralManager(delegate: self, queue: DispatchQueue.main)
}
public func centralManagerDidUpdateState(_ central: CBCentralManager) {
startScanning(central)
}
private func startScanning(_ central: CBCentralManager) {
guard central.state == .poweredOn else {
return
}
let scanOptions = [
CBCentralManagerScanOptionAllowDuplicatesKey: false
]
scanner.scanForPeripherals(withServices: nil, options: scanOptions)
}
public func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
guard let adv = Advertisement(fromAdvertisementData: advertisementData) else {
return
}
self.delegate?.received(adv)
}
}

View File

@@ -35,7 +35,25 @@ class Accessory: ObservableObject, Codable, Identifiable, Equatable, Hashable {
@Published var icon: String
@Published var lastLocation: CLLocation?
@Published var locationTimestamp: Date?
@Published var isDeployed: Bool
@Published var isDeployed: Bool {
didSet(wasDeployed) {
// Reset active status if deployed
if !wasDeployed && isDeployed {
self.isActive = false
}
}
}
/// Whether the accessory is correctly advertising.
@Published var isActive: Bool = false
/// Whether this accessory is currently nearby.
@Published var isNearby: Bool = false {
didSet {
if isNearby {
self.isActive = true
}
}
}
var lastAdvertisement: Date?
init(name: String = "New accessory", color: Color = randomColor(), iconName: String = randomIcon()) throws {
self.name = name
@@ -56,6 +74,7 @@ class Accessory: ObservableObject, Codable, Identifiable, Equatable, Hashable {
self.privateKey = try container.decode(Data.self, forKey: .privateKey)
self.icon = (try? container.decode(String.self, forKey: .icon)) ?? ""
self.isDeployed = (try? container.decode(Bool.self, forKey: .isDeployed)) ?? false
self.isActive = (try? container.decode(Bool.self, forKey: .isActive)) ?? false
if var colorComponents = try? container.decode([CGFloat].self, forKey: .colorComponents),
let spaceName = try? container.decode(String.self, forKey: .colorSpaceName),
@@ -75,6 +94,7 @@ class Accessory: ObservableObject, Codable, Identifiable, Equatable, Hashable {
try container.encode(self.privateKey, forKey: .privateKey)
try container.encode(self.icon, forKey: .icon)
try container.encode(self.isDeployed, forKey: .isDeployed)
try container.encode(self.isActive, forKey: .isActive)
if let colorComponents = self.color.cgColor?.components,
let colorSpace = self.color.cgColor?.colorSpace?.name
@@ -154,6 +174,7 @@ class Accessory: ObservableObject, Codable, Identifiable, Equatable, Hashable {
case colorSpaceName
case icon
case isDeployed
case isActive
}
static func == (lhs: Accessory, rhs: Accessory) -> Bool {

View File

@@ -35,6 +35,8 @@ struct PreviewData {
accessory.lastLocation = randomLocation()
accessory.locationTimestamp = randomTimestamp()
accessory.isDeployed = true
accessory.isActive = true
accessory.isNearby = Bool.random()
return accessory
}

View File

@@ -60,6 +60,9 @@ struct AccessoryListEntry: View {
label: { Text("Deploy") }
)
}
Circle()
.fill(accessory.isNearby ? Color.green : accessory.isActive ? Color.orange : Color.red)
.frame(width: 8, height: 8)
}
.padding(EdgeInsets(top: 5, leading: 0, bottom: 5, trailing: 0))
.contextMenu {

View File

@@ -22,6 +22,8 @@ struct ManageAccessoriesView: View {
@Binding var accessoryToDeploy: Accessory?
@Binding var showESP32DeploySheet: Bool
@State var showMailPopup = false
var body: some View {
VStack {
Text("Your accessories")
@@ -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,
@@ -76,6 +75,34 @@ struct ManageAccessoriesView: View {
}
/// 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) {
do {
@@ -99,6 +126,29 @@ struct ManageAccessoriesView: View {
}
}
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
}
}
}
struct ManageAccessoriesView_Previews: PreviewProvider {

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,6 +29,9 @@ 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
@@ -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,
@@ -111,6 +105,45 @@ 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() {
/// 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)
@@ -135,42 +168,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) {
@@ -189,7 +217,7 @@ struct OpenHaystackMainView: View {
}
self.alertType = .deployedSuccessfully
accessory.isDeployed = true
self.accessoryToDeploy = nil
}
@@ -210,7 +238,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 +246,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 +265,15 @@ 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 +367,25 @@ 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 +396,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

@@ -28,5 +28,7 @@
<true/>
<key>NSSupportsSuddenTermination</key>
<true/>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>OpenHaystack uses Bluetooth to detect the presence of nearby accessories.</string>
</dict>
</plist>

View File

@@ -12,28 +12,27 @@ import SwiftUI
@main
struct OpenHaystackApp: App {
@StateObject var accessoryController: AccessoryController
@StateObject var findMyController: FindMyController
var accessoryNearbyMonitor: AccessoryNearbyMonitor?
init() {
var accessoryController: AccessoryController
let accessoryController: AccessoryController
if ProcessInfo().arguments.contains("-preview") {
accessoryController = AccessoryControllerPreview(accessories: PreviewData.accessories)
accessoryController = AccessoryControllerPreview(accessories: PreviewData.accessories, findMyController: FindMyController())
self.accessoryNearbyMonitor = nil
} else {
accessoryController = AccessoryController()
self.accessoryNearbyMonitor = AccessoryNearbyMonitor(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()
}
}
}

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

View File

@@ -0,0 +1,61 @@
//
// 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 CoreBluetooth
import XCTest
@testable import OpenHaystack
class BluetoothTests: XCTestCase {
override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class.
}
override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
}
func testNoManufacturerData() throws {
let data: [String: Any] = [
"": Data()
]
let adv = Advertisement(fromAdvertisementData: data)
XCTAssertNil(adv)
}
func testEmptyManufacturerData() throws {
let data: [String: Any] = [
CBAdvertisementDataManufacturerDataKey: Data()
]
let adv = Advertisement(fromAdvertisementData: data)
XCTAssertNil(adv)
}
func testCorrectAdvertisement() throws {
let publicKey = "11111111111111111111111111111111111111111111".hexaData
let data = "4c00121900111111111111111111111111111111111111111111110100".hexaData
let adv = Advertisement(fromManufacturerData: data)
XCTAssertNotNil(adv)
XCTAssertEqual(adv?.publicKeyPayload, publicKey)
}
}
extension StringProtocol {
var hexaData: Data { .init(hexa) }
var hexaBytes: [UInt8] { .init(hexa) }
private var hexa: UnfoldSequence<UInt8, Index> {
sequence(state: startIndex) { startIndex in
guard startIndex < self.endIndex else { return nil }
let endIndex = self.index(startIndex, offsetBy: 2, limitedBy: self.endIndex) ?? self.endIndex
defer { startIndex = endIndex }
return UInt8(self[startIndex..<endIndex], radix: 16)
}
}
}