From e55a0959afbfd3bdfc15654f6f62f768a09bc142 Mon Sep 17 00:00:00 2001 From: Alexander Heinrich Date: Mon, 29 Nov 2021 17:20:14 +0100 Subject: [PATCH] Adding a class that automatically checks for updates of the app --- .../OpenHaystack.xcodeproj/project.pbxproj | 8 + .../xcschemes/OpenHaystack.xcscheme | 6 + .../HaystackApp/DataToHexExtension.swift | 24 +-- .../HaystackApp/Model/Accessory.swift | 93 ++++---- .../HaystackApp/Model/PreviewData.swift | 2 +- .../HaystackApp/NRFController.swift | 17 +- .../HaystackApp/UpdateCheckController.swift | 201 ++++++++++++++++++ .../Views/AccessoryListEntry.swift | 18 +- .../Views/ManageAccessoriesView.swift | 6 +- .../HaystackApp/Views/NRFInstallSheet.swift | 56 +++-- .../OpenHaystack/OpenHaystackApp.swift | 11 + .../OpenHaystackTests/UpdateCheckTests.swift | 73 +++++++ 12 files changed, 406 insertions(+), 109 deletions(-) create mode 100644 OpenHaystack/OpenHaystack/HaystackApp/UpdateCheckController.swift create mode 100644 OpenHaystack/OpenHaystackTests/UpdateCheckTests.swift diff --git a/OpenHaystack/OpenHaystack.xcodeproj/project.pbxproj b/OpenHaystack/OpenHaystack.xcodeproj/project.pbxproj index 0f3e76d..3c2f1ff 100644 --- a/OpenHaystack/OpenHaystack.xcodeproj/project.pbxproj +++ b/OpenHaystack/OpenHaystack.xcodeproj/project.pbxproj @@ -33,6 +33,8 @@ 781EB43125DADF2B00FEAA19 /* AnisetteDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 781EB40F25DADB0600FEAA19 /* AnisetteDataManager.swift */; }; 7821DAD125F7B2C10054DC33 /* FileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7821DAD025F7B2C10054DC33 /* FileManager.swift */; }; 7821DAD325F7C39A0054DC33 /* ESP32InstallSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7821DAD225F7C39A0054DC33 /* ESP32InstallSheet.swift */; }; + 782853C22755103A00B18EDE /* UpdateCheckController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 782853C12755103A00B18EDE /* UpdateCheckController.swift */; }; + 782853C427551B4400B18EDE /* UpdateCheckTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 782853C327551B4400B18EDE /* UpdateCheckTests.swift */; }; 78286CB225E3ACE700F65511 /* OpenHaystackPluginService.m in Sources */ = {isa = PBXBuildFile; fileRef = 78286CAF25E3ACE700F65511 /* OpenHaystackPluginService.m */; }; 78286D1F25E3D8B800F65511 /* ALTAnisetteData.m in Sources */ = {isa = PBXBuildFile; fileRef = 78286CB025E3ACE700F65511 /* ALTAnisetteData.m */; }; 78286D2A25E3EC3200F65511 /* AppleAccountData.m in Sources */ = {isa = PBXBuildFile; fileRef = 78286D2925E3EC3200F65511 /* AppleAccountData.m */; }; @@ -135,6 +137,8 @@ 781EB40F25DADB0600FEAA19 /* AnisetteDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnisetteDataManager.swift; sourceTree = ""; }; 7821DAD025F7B2C10054DC33 /* FileManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileManager.swift; sourceTree = ""; }; 7821DAD225F7C39A0054DC33 /* ESP32InstallSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ESP32InstallSheet.swift; sourceTree = ""; }; + 782853C12755103A00B18EDE /* UpdateCheckController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateCheckController.swift; sourceTree = ""; }; + 782853C327551B4400B18EDE /* UpdateCheckTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateCheckTests.swift; sourceTree = ""; }; 78286C8E25E3AC0400F65511 /* OpenHaystackMail.mailbundle */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OpenHaystackMail.mailbundle; sourceTree = BUILT_PRODUCTS_DIR; }; 78286C9025E3AC0400F65511 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 78286CAE25E3ACE700F65511 /* OpenHaystackPluginService.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OpenHaystackPluginService.h; sourceTree = ""; }; @@ -337,6 +341,7 @@ 78EC226525DAE0BE0042B775 /* Info.plist */, 78023CB025F7841F00B083EF /* MicrocontrollerTests.swift */, F1647C1525FF6C61004144D6 /* BluetoothTests.swift */, + 782853C327551B4400B18EDE /* UpdateCheckTests.swift */, ); path = OpenHaystackTests; sourceTree = ""; @@ -357,6 +362,7 @@ F1647C1A25FF7954004144D6 /* AccessoryNearbyMonitor.swift */, 5A2C908A2734266A0044407E /* DataToHexExtension.swift */, 5A2C908C273429360044407E /* NRFController.swift */, + 782853C12755103A00B18EDE /* UpdateCheckController.swift */, ); path = HaystackApp; sourceTree = ""; @@ -645,6 +651,7 @@ 78F8BB4C261C50EB00D9F37F /* LargeButtonStyle.swift in Sources */, 781EB3F425DAD7EA00FEAA19 /* FindMyController.swift in Sources */, 781EB3F525DAD7EA00FEAA19 /* BoringSSL.m in Sources */, + 782853C22755103A00B18EDE /* UpdateCheckController.swift in Sources */, F12D5A5A25FA4F3500CBBA09 /* BluetoothAccessoryScanner.swift in Sources */, 78286D5625E401F000F65511 /* MailPluginManager.swift in Sources */, ); @@ -664,6 +671,7 @@ buildActionMask = 2147483647; files = ( 78023CB125F7841F00B083EF /* MicrocontrollerTests.swift in Sources */, + 782853C427551B4400B18EDE /* UpdateCheckTests.swift in Sources */, F1647C1625FF6C61004144D6 /* BluetoothTests.swift in Sources */, 78EC226425DAE0BE0042B775 /* OpenHaystackTests.swift in Sources */, ); diff --git a/OpenHaystack/OpenHaystack.xcodeproj/xcshareddata/xcschemes/OpenHaystack.xcscheme b/OpenHaystack/OpenHaystack.xcodeproj/xcshareddata/xcschemes/OpenHaystack.xcscheme index 624c806..c1086ed 100644 --- a/OpenHaystack/OpenHaystack.xcodeproj/xcshareddata/xcschemes/OpenHaystack.xcscheme +++ b/OpenHaystack/OpenHaystack.xcodeproj/xcshareddata/xcschemes/OpenHaystack.xcscheme @@ -74,6 +74,12 @@ ReferencedContainer = "container:OpenHaystack.xcodeproj"> + + + + String { - let hexDigits = Array("0123456789abcdef".utf16) - var hexChars = [UTF16.CodeUnit]() - hexChars.reserveCapacity(count * 2) + /// A hexadecimal string representation of the bytes. + func hexEncodedString() -> String { + let hexDigits = Array("0123456789abcdef".utf16) + var hexChars = [UTF16.CodeUnit]() + hexChars.reserveCapacity(count * 2) - for byte in self { - let (index1, index2) = Int(byte).quotientAndRemainder(dividingBy: 16) - hexChars.append(hexDigits[index1]) - hexChars.append(hexDigits[index2]) + for byte in self { + let (index1, index2) = Int(byte).quotientAndRemainder(dividingBy: 16) + hexChars.append(hexDigits[index1]) + hexChars.append(hexDigits[index2]) + } + + return String(utf16CodeUnits: hexChars, count: hexChars.count) } - - return String(utf16CodeUnits: hexChars, count: hexChars.count) - } } diff --git a/OpenHaystack/OpenHaystack/HaystackApp/Model/Accessory.swift b/OpenHaystack/OpenHaystack/HaystackApp/Model/Accessory.swift index 0f3113b..44a41d9 100644 --- a/OpenHaystack/OpenHaystack/HaystackApp/Model/Accessory.swift +++ b/OpenHaystack/OpenHaystack/HaystackApp/Model/Accessory.swift @@ -49,7 +49,7 @@ class Accessory: ObservableObject, Codable, Identifiable, Equatable, Hashable { self.usesDerivation = false } else if wasDeployed && !isDeployed { self.usesDerivation = false - self.updateInterval = TimeInterval(60*60*24) + self.updateInterval = TimeInterval(60 * 60 * 24) } } } @@ -79,7 +79,7 @@ class Accessory: ObservableObject, Codable, Identifiable, Equatable, Hashable { self.usesDerivation = false self.oldestRelevantSymmetricKey = self.symmetricKey self.lastDerivationTimestamp = Date() - self.updateInterval = TimeInterval(60*60) + self.updateInterval = TimeInterval(60 * 60) self.color = color self.icon = iconName self.isDeployed = false @@ -90,11 +90,11 @@ class Accessory: ObservableObject, Codable, Identifiable, Equatable, Hashable { self.name = try container.decode(String.self, forKey: .name) self.id = try container.decode(Int.self, forKey: .id) self.privateKey = try container.decode(Data.self, forKey: .privateKey) - self.symmetricKey = (try? container.decode(Data.self, forKey: .symmetricKey)) ?? SymmetricKey(size: .bits256).withUnsafeBytes{ return Data($0) } + self.symmetricKey = (try? container.decode(Data.self, forKey: .symmetricKey)) ?? SymmetricKey(size: .bits256).withUnsafeBytes { return Data($0) } self.usesDerivation = (try? container.decode(Bool.self, forKey: .usesDerivation)) ?? false self.oldestRelevantSymmetricKey = (try? container.decode(Data.self, forKey: .oldestRelevantSymmetricKey)) ?? self.symmetricKey self.lastDerivationTimestamp = (try? container.decode(Date.self, forKey: .lastDerivationTimestamp)) ?? Date() - self.updateInterval = (try? container.decode(TimeInterval.self, forKey: .updateInterval)) ?? TimeInterval(60*60*24) + self.updateInterval = (try? container.decode(TimeInterval.self, forKey: .updateInterval)) ?? TimeInterval(60 * 60 * 24) 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 @@ -149,7 +149,7 @@ class Accessory: ObservableObject, Codable, Identifiable, Equatable, Hashable { } return publicKey } - + func getAdvertisementKey() throws -> Data { guard var publicKey = BoringSSL.derivePublicKey(fromPrivateKey: self.privateKey) else { throw KeyError.keyDerivationFailed @@ -182,7 +182,7 @@ class Accessory: ObservableObject, Codable, Identifiable, Equatable, Hashable { return Data(digest) } - + func getNewestSymmetricKey() -> Data { var derivationTimestamp = self.lastDerivationTimestamp var symmetricKey = self.oldestRelevantSymmetricKey @@ -193,87 +193,87 @@ class Accessory: ObservableObject, Codable, Identifiable, Equatable, Hashable { return symmetricKey } - func toFindMyDevice() throws -> FindMyDevice { - + var findMyKey = [FindMyKey]() - + /// Always append first FindMyKey to support devices without derivation - findMyKey.append(FindMyKey( - advertisedKey: try self.getAdvertisementKey(), - hashedKey: try self.hashedPublicKey(), - privateKey: self.privateKey, - startTime: nil, - duration: nil, - pu: nil, - yCoordinate: nil, - fullKey: nil) + findMyKey.append( + FindMyKey( + advertisedKey: try self.getAdvertisementKey(), + hashedKey: try self.hashedPublicKey(), + privateKey: self.privateKey, + startTime: nil, + duration: nil, + pu: nil, + yCoordinate: nil, + fullKey: nil) ) if self.usesDerivation { /// Derive FindMyKeys until we have symmetric key from one week before now - while self.lastDerivationTimestamp < Date() - TimeInterval(7*24*60*60) { + while self.lastDerivationTimestamp < Date() - TimeInterval(7 * 24 * 60 * 60) { self.lastDerivationTimestamp.addTimeInterval(self.updateInterval) self.oldestRelevantSymmetricKey = Accessory.kdf(inputData: self.symmetricKey, sharedInfo: "update".data(using: .ascii)!, bytesToReturn: 32) } - + /// we need to generate Keys from seven days in the past until now and 10 extra keys in case of desynchronization let untilDate = Date() + TimeInterval(self.updateInterval * 11) var derivationTimestamp = self.lastDerivationTimestamp var derivedSymmetricKey = self.oldestRelevantSymmetricKey - + print("--- Derived keys for \(self.name) ---") print("Masterbacon symmetric key \(self.symmetricKey.hexEncodedString())") do { - let uncompressedMasterBeaconKey = try self.getUncompressedPublicKey() + let uncompressedMasterBeaconKey = try self.getUncompressedPublicKey() print("Masterbeacon public key (uncompressed) \(uncompressedMasterBeaconKey.hexEncodedString())") } catch { print("Failed to get master beacon public key (only needed for printing)") } - - + while derivationTimestamp < untilDate { /// Step 1: derive SKN_i derivedSymmetricKey = Accessory.kdf(inputData: derivedSymmetricKey, sharedInfo: "update".data(using: .ascii)!, bytesToReturn: 32) /// Step 2: derive u_i and v_i let derivedAntiTrackingKeys = Accessory.kdf(inputData: derivedSymmetricKey, sharedInfo: "diversify".data(using: .ascii)!, bytesToReturn: 72) /// Step 3 & 4: compute private and public key - guard let derivedPrivateKey = BoringSSL.calculatePrivateKey(fromSharedData: derivedAntiTrackingKeys, masterBeaconPrivateKey: self.privateKey) else{ + guard let derivedPrivateKey = BoringSSL.calculatePrivateKey(fromSharedData: derivedAntiTrackingKeys, masterBeaconPrivateKey: self.privateKey) else { throw KeyError.keyDerivationFailed } guard let derivedPublicKey = BoringSSL.derivePublicKey(fromPrivateKey: derivedPrivateKey) else { throw KeyError.keyDerivationFailed } - + /// Drop first byte to get advertisment key let derivedAdvertisementKey = derivedPublicKey.dropFirst() guard derivedAdvertisementKey.count == 28 else { throw KeyError.keyDerivationFailed } - + /// Get hash of advertisment key var sha = SHA256() sha.update(data: derivedAdvertisementKey) let derivedAdvertisementKeyHash = Data(sha.finalize()) - + print("-> Derived keys for \(derivationTimestamp):") //print("Dervided anti tracking keys \(derivedAntiTrackingKeys.hexEncodedString())") //print("SymmetricKey \(derivedSymmetricKey.hexEncodedString())") print("Derived public key \(derivedPublicKey.hexEncodedString())") - - findMyKey.append(FindMyKey( - advertisedKey: derivedAdvertisementKey, - hashedKey: derivedAdvertisementKeyHash, - privateKey: derivedPrivateKey, - startTime: nil, - duration: nil, - pu: nil, - yCoordinate: nil, - fullKey: nil) + + findMyKey.append( + FindMyKey( + advertisedKey: derivedAdvertisementKey, + hashedKey: derivedAdvertisementKeyHash, + privateKey: derivedPrivateKey, + startTime: nil, + duration: nil, + pu: nil, + yCoordinate: nil, + fullKey: nil) ) - + /// Add time interval to derivation timestamp derivationTimestamp.addTimeInterval(self.updateInterval) } } - + return FindMyDevice( deviceId: String(self.id), keys: findMyKey, @@ -281,11 +281,11 @@ class Accessory: ObservableObject, Codable, Identifiable, Equatable, Hashable { reports: nil, decryptedReports: nil) } - - static func kdf(inputData: Data, sharedInfo: Data, bytesToReturn: Int) -> Data{ + + static func kdf(inputData: Data, sharedInfo: Data, bytesToReturn: Int) -> Data { var derivedKey = Data() var counter: Int32 = 1 - + /// derive from input and shared info until we have enough data while derivedKey.count < bytesToReturn { var shaDigest = SHA256() @@ -296,18 +296,17 @@ class Accessory: ObservableObject, Codable, Identifiable, Equatable, Hashable { derivedKey.append(Data(shaDigest.finalize())) counter += 1 } - + /// drop bytes which are not needed and return derivedKey = derivedKey.dropLast(derivedKey.count - bytesToReturn) return derivedKey } - - func resetDerivationState(){ + + func resetDerivationState() { /// reset keys and derivation time in case an accessory is reflashed with old keys self.oldestRelevantSymmetricKey = self.symmetricKey self.lastDerivationTimestamp = Date() } - enum CodingKeys: String, CodingKey { case name diff --git a/OpenHaystack/OpenHaystack/HaystackApp/Model/PreviewData.swift b/OpenHaystack/OpenHaystack/HaystackApp/Model/PreviewData.swift index 6726d46..414e209 100644 --- a/OpenHaystack/OpenHaystack/HaystackApp/Model/PreviewData.swift +++ b/OpenHaystack/OpenHaystack/HaystackApp/Model/PreviewData.swift @@ -7,8 +7,8 @@ // SPDX-License-Identifier: AGPL-3.0-only // -import Foundation import CoreLocation +import Foundation import SwiftUI import CoreLocation diff --git a/OpenHaystack/OpenHaystack/HaystackApp/NRFController.swift b/OpenHaystack/OpenHaystack/HaystackApp/NRFController.swift index f858b99..8ba28b0 100644 --- a/OpenHaystack/OpenHaystack/HaystackApp/NRFController.swift +++ b/OpenHaystack/OpenHaystack/HaystackApp/NRFController.swift @@ -10,7 +10,7 @@ import Foundation struct NRFController { - + static var nrfFirmwareDirectory: URL? { Bundle.main.resourceURL?.appendingPathComponent("NRF") } @@ -28,9 +28,9 @@ struct NRFController { try FileManager.default.copyFolder(from: nrfDirectory, to: urlTemp) let urlScript = urlTemp.appendingPathComponent("flash_nrf.sh") - try FileManager.default.setAttributes([FileAttributeKey.posixPermissions : 0o755], ofItemAtPath: urlScript.path) - try FileManager.default.setAttributes([FileAttributeKey.posixPermissions : 0o755], ofItemAtPath: urlTemp.appendingPathComponent("flash_nrf.py").path) - + try FileManager.default.setAttributes([FileAttributeKey.posixPermissions: 0o755], ofItemAtPath: urlScript.path) + try FileManager.default.setAttributes([FileAttributeKey.posixPermissions: 0o755], ofItemAtPath: urlTemp.appendingPathComponent("flash_nrf.py").path) + // Get public key, newest relevant symmetric key and updateInterval for flashing let masterBeaconPublicKey = try accessory.getUncompressedPublicKey() let masterBeaconSymmetricKey = accessory.getNewestSymmetricKey() @@ -40,7 +40,7 @@ struct NRFController { let loggingFileUrl = urlTemp.appendingPathComponent("nrf_installer.log") try "".write(to: loggingFileUrl, atomically: true, encoding: .utf8) let loggingFileHandle = FileHandle.init(forWritingAtPath: loggingFileUrl.path)! - + // Run script let task = try NSUserUnixTask(url: urlScript) task.standardOutput = loggingFileHandle @@ -54,17 +54,16 @@ struct NRFController { } } } - + try loggingFileHandle.close() } } enum ClosureResult { - case success(URL) - case failure(URL, Error) + case success(URL) + case failure(URL, Error) } - enum NRFFirmwareFlashError: Error { /// Missing files for flashing case notFound diff --git a/OpenHaystack/OpenHaystack/HaystackApp/UpdateCheckController.swift b/OpenHaystack/OpenHaystack/HaystackApp/UpdateCheckController.swift new file mode 100644 index 0000000..66af6cd --- /dev/null +++ b/OpenHaystack/OpenHaystack/HaystackApp/UpdateCheckController.swift @@ -0,0 +1,201 @@ +// +// 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 +import AppKit + + +/// Can check if a new OpenHaystack version is needed and download it. +public struct UpdateCheckController { + + public static func checkForNewVersion() { + // Load the GitHub Releases page + let releasesURL = URL(string: "https://github.com/seemoo-lab/openhaystack/releases")! + URLSession.shared.dataTask(with: releasesURL) { optionalData, response, error in + guard let data = optionalData, + (response as? HTTPURLResponse)?.statusCode == 200, + let htmlString = String(data:data, encoding: .utf8) + else { + return + } + + + guard let availableVersion = getVersion(from: htmlString) else { + return + } + + //Get installed version + let version = Bundle.main.infoDictionary?["CFBundleVersionShortString"] as? String ?? "0" + + let comparisonResult = compareVersions(availableVersion: availableVersion, installedVersion: version) + + DispatchQueue.main.async { + if comparisonResult == .older, askToDownloadUpdate() == .alertSecondButtonReturn { + //The currently installed version is older. Install an update + self.downloadUpdate(version: availableVersion, finished: { success in + if success { + let result = successDownloadAlert() + if result == .alertSecondButtonReturn { + //Open the download folder + let downloadURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0] + NSWorkspace.shared.open(downloadURL) + } + }else { + if downloadFailedAlert() == .alertSecondButtonReturn { + NSWorkspace.shared.open(URL(string: "https://github.com/seemoo-lab/openhaystack/releases")!) + } + } + }) + } + } + + }.resume() + } + + internal static func getVersion(from htmlString: String) -> String? { + guard let regex = try? NSRegularExpression(pattern: "Release (v[0-9]+(.[0-9]+)?(.[0-9]+)?)") else { + return nil + } + + let htmlNSString = htmlString as NSString + + let htmlRange = NSRange(location: 0, length: htmlNSString.length) + + if let checkResult = regex.firstMatch(in: htmlNSString as String, options: [], range: htmlRange), + checkResult.numberOfRanges >= 2 { + + //Get the latest release version range + // A result should have multiple ranges for each capture group. 1 is the capture group for the version number + let releaseVersionRange = checkResult.range(at: 1) + let releaseVersion = htmlNSString.substring(with: releaseVersionRange) + + let releaseVersionNumber = releaseVersion.replacingOccurrences(of: "v", with: "") + + return releaseVersionNumber + } + + return nil + } + + + /// Compares two version strings and returns if the installed version is older, newer or the same + /// - Parameters: + /// - availableVersion: The latest available version + /// - installedVersion: The currently installed version + /// - Returns: .older when a newer version is available. .newer when the installed version is newer .same, if both versions are equal + internal static func compareVersions(availableVersion: String, installedVersion: String) -> VersionCompare { + let availableVersionSplit = availableVersion.split(separator: ".") + let installedVersionSplit = installedVersion.split(separator: ".") + + for (idx, availableVersionPart) in availableVersionSplit.enumerated() { + + if idx < installedVersionSplit.count { + guard let avpi = Int(availableVersionPart), + let ivpi = Int(installedVersionSplit[idx]) else {return .older} + + if avpi > ivpi { + return .older + }else if ivpi > avpi { + return .newer + } + + }else { + //The installed version is x.x + // The new version is x.x.y so it must be older + return .older + } + } + + if installedVersionSplit.count > availableVersionSplit.count { + //The installed version has a higher sub-version. So it must be newer + return .newer + } + + // All numbers were equal + return .same + } + + enum VersionCompare { + case same, newer, older + } + + + static func downloadUpdate(version: String, finished: @escaping (Bool)->()) { + + //Download the current version into a file in Downloads + let downloadURL = URL(string: "https://github.com/seemoo-lab/openhaystack/releases/download/v\(version)/OpenHaystack.zip")! + + let task = URLSession.shared.downloadTask(with: downloadURL) { optionalFileURL, response, error in + + guard let downloadLocation = optionalFileURL else { + finished(false) + return + } + + //Move the file to the downloads folder + let downloadURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0] + let openHaystackURL = downloadURL.appendingPathComponent("OpenHaystack.zip") + do { + let fm = FileManager.default + if fm.fileExists(atPath: openHaystackURL.path) { + _ = try fm.replaceItemAt(openHaystackURL, withItemAt: downloadLocation) + }else { + try fm.moveItem(at: downloadLocation, to: openHaystackURL) + } + + DispatchQueue.main.async {finished(true)} + }catch let error { + print(error.localizedDescription) + DispatchQueue.main.async {finished(false)} + + } + } + + task.resume() + } + + private static func askToDownloadUpdate() -> NSApplication.ModalResponse { + let alert = NSAlert() + alert.messageText = NSLocalizedString("New version available", comment: "Alert title") + alert.informativeText = NSLocalizedString("A new version of OpenHaystack is available. Do you want to download it now?", comment: "Alert text") + alert.addButton(withTitle: "Cancel") + alert.addButton(withTitle: "Download") + + return alert.runModal() + } + + private static func successDownloadAlert() -> NSApplication.ModalResponse { + let alert = NSAlert() + alert.messageText = NSLocalizedString("Successfully downloaded update", comment: "Alert title") + alert.informativeText = NSLocalizedString("The new version has been downloaded successfully and it was placed in your Downloads folder.", comment: "Alert text") + alert.addButton(withTitle: "Okay") + alert.addButton(withTitle: "Open folder") + + return alert.runModal() + } + + private static func downloadFailedAlert() -> NSApplication.ModalResponse { + let alert = NSAlert() + alert.messageText = NSLocalizedString("Download failed", comment: "Alert title") + alert.informativeText = NSLocalizedString("To update to the newest version, please open the releases page on GitHub", comment: "Alert text") + alert.addButton(withTitle: "Cancel") + alert.addButton(withTitle: "Open") + + return alert.runModal() + } + +} + +extension String { + func substring(from range: NSRange) -> String { + let substring = self[self.index(startIndex, offsetBy: range.lowerBound).. some View { let intervalFormatter = DateComponentsFormatter() intervalFormatter.unitsStyle = .abbreviated - + return Group { Text("Key derivation interval: \(intervalFormatter.string(from: accessory.updateInterval)!)") }.font(.footnote) @@ -158,14 +158,14 @@ struct AccessoryListEntry: View { assert(false) } } - - func copySymmetricAndPublicKey(of accessory: Accessory){ - do{ + + func copySymmetricAndPublicKey(of accessory: Accessory) { + do { let symmetricKey = accessory.symmetricKey let publicKey = try accessory.getUncompressedPublicKey() let publicKeyString = [UInt8](publicKey).map { "\\x\(String($0, radix: 16))" }.joined() let symmetricKeyString = [UInt8](symmetricKey).map { "\\x\(String($0, radix: 16))" }.joined() - + let pasteboard = NSPasteboard.general pasteboard.prepareForNewContents(with: .currentHostOnly) pasteboard.setString("Symmetric key: \(symmetricKeyString)\n Uncompressed public key: \(publicKeyString) ", forType: .string) @@ -175,11 +175,11 @@ struct AccessoryListEntry: View { } } - func copySymmetricAndPublicKeyBase64(of accessory: Accessory){ - do{ + func copySymmetricAndPublicKeyBase64(of accessory: Accessory) { + do { let symmetricKey = accessory.symmetricKey let publicKey = try accessory.getUncompressedPublicKey() - + let pasteboard = NSPasteboard.general pasteboard.prepareForNewContents(with: .currentHostOnly) pasteboard.setString("Symmetric key: \(symmetricKey.base64EncodedString())\n Uncompressed public key: \(publicKey.base64EncodedString()) ", forType: .string) diff --git a/OpenHaystack/OpenHaystack/HaystackApp/Views/ManageAccessoriesView.swift b/OpenHaystack/OpenHaystack/HaystackApp/Views/ManageAccessoriesView.swift index 85c9b73..f87bbd4 100644 --- a/OpenHaystack/OpenHaystack/HaystackApp/Views/ManageAccessoriesView.swift +++ b/OpenHaystack/OpenHaystack/HaystackApp/Views/ManageAccessoriesView.swift @@ -157,7 +157,7 @@ struct ManageAccessoriesView: View { self.sheetShown = .nrfDeviceInstall } ).buttonStyle(LargeButtonStyle()) - + Button( "Cancel", action: { @@ -282,7 +282,9 @@ struct ManageAccessoriesView_Previews: PreviewProvider { @State static var showESPSheet: Bool = true static var previews: some View { - ManageAccessoriesView(alertType: self.$alertType, scriptOutput: self.$scriptOutput, focusedAccessory: self.$focussed, accessoryToDeploy: self.$deploy, showESP32DeploySheet: self.$showESPSheet) + ManageAccessoriesView( + alertType: self.$alertType, scriptOutput: self.$scriptOutput, focusedAccessory: self.$focussed, accessoryToDeploy: self.$deploy, + showESP32DeploySheet: self.$showESPSheet) } } diff --git a/OpenHaystack/OpenHaystack/HaystackApp/Views/NRFInstallSheet.swift b/OpenHaystack/OpenHaystack/HaystackApp/Views/NRFInstallSheet.swift index 711222c..f64e29a 100644 --- a/OpenHaystack/OpenHaystack/HaystackApp/Views/NRFInstallSheet.swift +++ b/OpenHaystack/OpenHaystack/HaystackApp/Views/NRFInstallSheet.swift @@ -7,7 +7,6 @@ // SPDX-License-Identifier: AGPL-3.0-only // - import OSLog import SwiftUI @@ -16,14 +15,13 @@ struct NRFInstallSheet: View { @Binding var alertType: OpenHaystackMainView.AlertType? @Binding var scriptOutput: String? @State var isFlashing = false - - + @ObservedObject var days = NumbersOnly() @ObservedObject var hours = NumbersOnly() @ObservedObject var minutes = NumbersOnly() - + @Environment(\.presentationMode) var presentationMode - + var body: some View { VStack { self.flashView @@ -34,25 +32,25 @@ struct NRFInstallSheet: View { .onAppear { } } - + var flashView: some View { VStack { Text("Flash your NRF Device") .font(.title2) - + Text("Fill out options for flashing firmware") .foregroundColor(.gray) - + Divider() - + Text("Put key update time:") self.timePicker - + Spacer() - + HStack { Spacer() - + Button( "Deploy", action: { @@ -60,17 +58,17 @@ struct NRFInstallSheet: View { let daysInt = Int(days.value) ?? 0 let hoursInt = Int(hours.value) ?? 0 let minutesInt = Int(minutes.value) ?? 0 - + let updateInterval = daysInt * 24 * 60 + hoursInt * 60 + minutesInt //warn user if no update interval was given if updateInterval > 0 { - deployAccessoryToNRFDevice(accessory: accessory, updateInterval: updateInterval) + deployAccessoryToNRFDevice(accessory: accessory, updateInterval: updateInterval) } else { - + } } }) - + Button( "Cancel", action: { @@ -81,8 +79,8 @@ struct NRFInstallSheet: View { } var timePicker: some View { - Group{ - HStack{ + Group { + HStack { TextField("", text: $days.value).textFieldStyle(RoundedBorderTextFieldStyle()) Text("Day(s)") TextField("", text: $hours.value).textFieldStyle(RoundedBorderTextFieldStyle()) @@ -92,40 +90,40 @@ struct NRFInstallSheet: View { } }.padding() } - + var loadingOverlay: some View { ZStack { if isFlashing { Rectangle() .fill(Color.gray) .opacity(0.5) - + VStack { ActivityIndicator(size: .large) Text("This can take up to 3min") } - + } } } - + func deployAccessoryToNRFDevice(accessory: Accessory, updateInterval: Int) { do { self.isFlashing = true - + try NRFController.flashToNRF( accessory: accessory, updateInterval: updateInterval, completion: { result in presentationMode.wrappedValue.dismiss() - + self.isFlashing = false switch result { case .success(_): self.alertType = .deployedSuccessfully accessory.isDeployed = true accessory.usesDerivation = true - accessory.updateInterval = TimeInterval(updateInterval*60) + accessory.updateInterval = TimeInterval(updateInterval * 60) case .failure(let loggingFileUrl, let error): os_log(.error, "Flashing to NRF device failed %@", String(describing: error)) self.presentationMode.wrappedValue.dismiss() @@ -143,17 +141,17 @@ struct NRFInstallSheet: View { self.alertType = .deployFailed self.isFlashing = false } - + self.accessory = nil } } struct NRFInstallSheet_Previews: PreviewProvider { @State static var acc: Accessory? = try! Accessory(name: "Sample") - + @State static var alert: OpenHaystackMainView.AlertType? @State static var scriptOutput: String? - + static var previews: some View { NRFInstallSheet(accessory: $acc, alertType: $alert, scriptOutput: $scriptOutput) } @@ -163,7 +161,7 @@ class NumbersOnly: ObservableObject { @Published var value = "" { didSet { let filtered = value.filter { $0.isNumber } - + if value != filtered { value = filtered } diff --git a/OpenHaystack/OpenHaystack/OpenHaystackApp.swift b/OpenHaystack/OpenHaystack/OpenHaystackApp.swift index 27e3679..81d7947 100644 --- a/OpenHaystack/OpenHaystack/OpenHaystackApp.swift +++ b/OpenHaystack/OpenHaystack/OpenHaystackApp.swift @@ -15,6 +15,8 @@ struct OpenHaystackApp: App { var accessoryNearbyMonitor: AccessoryNearbyMonitor? var frameWidth: CGFloat? = nil var frameHeight: CGFloat? = nil + + @State var checkedForUpdates = false init() { let accessoryController: AccessoryController @@ -35,9 +37,18 @@ struct OpenHaystackApp: App { OpenHaystackMainView() .environmentObject(self.accessoryController) .frame(width: self.frameWidth, height: self.frameHeight) + .onAppear { + self.checkForUpdates() + } } .commands { SidebarCommands() } } + + func checkForUpdates() { + guard checkedForUpdates == false, ProcessInfo().arguments.contains("-stopUpdateCheck") == false else {return} + UpdateCheckController.checkForNewVersion() + checkedForUpdates = true + } } diff --git a/OpenHaystack/OpenHaystackTests/UpdateCheckTests.swift b/OpenHaystack/OpenHaystackTests/UpdateCheckTests.swift new file mode 100644 index 0000000..daadbd9 --- /dev/null +++ b/OpenHaystack/OpenHaystackTests/UpdateCheckTests.swift @@ -0,0 +1,73 @@ +// +// 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 +import XCTest +@testable import OpenHaystack + +class UpdateCheckTests: XCTestCase { + + func testCompareVersions() { + let i1 = "1.0.3" + let a1 = "1.0.4" + XCTAssertEqual(UpdateCheckController.compareVersions(availableVersion: a1, installedVersion: i1), .older) + let a11 = "1.1" + XCTAssertEqual(UpdateCheckController.compareVersions(availableVersion: a11, installedVersion: i1), .older) + let a12 = "2" + XCTAssertEqual(UpdateCheckController.compareVersions(availableVersion: a12, installedVersion: i1), .older) + + let a2 = "1.0.3" + XCTAssertEqual(UpdateCheckController.compareVersions(availableVersion: a2, installedVersion: i1), .same) + + let a3 = "1.0.2" + XCTAssertEqual(UpdateCheckController.compareVersions(availableVersion: a3, installedVersion: i1), .newer) + let a31 = "1.0" + XCTAssertEqual(UpdateCheckController.compareVersions(availableVersion: a31, installedVersion: i1), .newer) + let a32 = "0.10.1" + XCTAssertEqual(UpdateCheckController.compareVersions(availableVersion: a32, installedVersion: i1), .newer) + + let a4 = "1.1.1" + let i4 = "1.1.2" + XCTAssertEqual(UpdateCheckController.compareVersions(availableVersion: a4, installedVersion: i4), .newer) + let a41 = "1.0.2" + XCTAssertEqual(UpdateCheckController.compareVersions(availableVersion: a41, installedVersion: i1), .newer) + } + + func testHTMLVersionCompare() { + let github = + """ +

Release v0.4.1

+

Release v0.4.1

+ Release v0.4.1 + """ + + XCTAssertEqual(UpdateCheckController.getVersion(from: github), "0.4.1") + + let h1 = "

Release v0.4.1

Release v0.3.1

" + XCTAssertEqual(UpdateCheckController.getVersion(from: h1), "0.4.1") + let h2 = "

Release v0.5

" + XCTAssertEqual(UpdateCheckController.getVersion(from: h2), "0.5") + let h3 = "

Release v1.5

" + XCTAssertEqual(UpdateCheckController.getVersion(from: h3), "1.5") + let h4 = "

Release v1

" + XCTAssertEqual(UpdateCheckController.getVersion(from: h4), "1") + } + + func testDownload() { + let expect = expectation(description: "Update download") + UpdateCheckController.downloadUpdate(version: "0.4.1", finished: { success in + XCTAssertTrue(success) + expect.fulfill() + }) + wait(for: [expect], timeout: 20.0) + + } +} + +