diff --git a/Sesame.xcodeproj/project.pbxproj b/Sesame.xcodeproj/project.pbxproj index b3132e3..a3894c1 100644 --- a/Sesame.xcodeproj/project.pbxproj +++ b/Sesame.xcodeproj/project.pbxproj @@ -22,6 +22,10 @@ E24EE77927FF95E00011CFD2 /* Message.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24EE77827FF95E00011CFD2 /* Message.swift */; }; E28DED2D281E840B00259690 /* KeyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E28DED2C281E840B00259690 /* KeyView.swift */; }; E28DED2F281E8A0500259690 /* SingleKeyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E28DED2E281E8A0500259690 /* SingleKeyView.swift */; }; + E28DED31281EAE9100259690 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E28DED30281EAE9100259690 /* HistoryView.swift */; }; + E28DED33281EB15B00259690 /* HistoryListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E28DED32281EB15B00259690 /* HistoryListItem.swift */; }; + E28DED35281EB17600259690 /* HistoryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E28DED34281EB17600259690 /* HistoryItem.swift */; }; + E28DED37281EC7FB00259690 /* HistoryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E28DED36281EC7FB00259690 /* HistoryManager.swift */; }; E2C5C1DB2806FE8900769EF6 /* RouteAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5C1DA2806FE8900769EF6 /* RouteAPI.swift */; }; E2C5C1DD281B3AC400769EF6 /* UInt32+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5C1DC281B3AC400769EF6 /* UInt32+Extensions.swift */; }; E2C5C1F8281E769F00769EF6 /* ServerMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5C1F7281E769F00769EF6 /* ServerMessage.swift */; }; @@ -43,6 +47,11 @@ E24EE77827FF95E00011CFD2 /* Message.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Message.swift; sourceTree = ""; }; E28DED2C281E840B00259690 /* KeyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyView.swift; sourceTree = ""; }; E28DED2E281E8A0500259690 /* SingleKeyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleKeyView.swift; sourceTree = ""; }; + E28DED30281EAE9100259690 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = ""; }; + E28DED32281EB15B00259690 /* HistoryListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryListItem.swift; sourceTree = ""; }; + E28DED34281EB17600259690 /* HistoryItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryItem.swift; sourceTree = ""; }; + E28DED36281EC7FB00259690 /* HistoryManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryManager.swift; sourceTree = ""; }; + E28DED38281EE9CF00259690 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; E2C5C1DA2806FE8900769EF6 /* RouteAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteAPI.swift; sourceTree = ""; }; E2C5C1DC281B3AC400769EF6 /* UInt32+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UInt32+Extensions.swift"; sourceTree = ""; }; E2C5C1F7281E769F00769EF6 /* ServerMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerMessage.swift; sourceTree = ""; }; @@ -79,9 +88,14 @@ 884A45B5279F48C100D6E650 /* Sesame */ = { isa = PBXGroup; children = ( + E28DED38281EE9CF00259690 /* Info.plist */, E2C5C1D92806FE4A00769EF6 /* API */, 884A45B6279F48C100D6E650 /* SesameApp.swift */, 884A45B8279F48C100D6E650 /* ContentView.swift */, + E28DED30281EAE9100259690 /* HistoryView.swift */, + E28DED32281EB15B00259690 /* HistoryListItem.swift */, + E28DED34281EB17600259690 /* HistoryItem.swift */, + E28DED36281EC7FB00259690 /* HistoryManager.swift */, E28DED2C281E840B00259690 /* KeyView.swift */, E28DED2E281E8A0500259690 /* SingleKeyView.swift */, 884A45CC27A465F500D6E650 /* Client.swift */, @@ -195,14 +209,18 @@ 884A45CF27A5402D00D6E650 /* MessageResult.swift in Sources */, 884A45B9279F48C100D6E650 /* ContentView.swift in Sources */, E28DED2F281E8A0500259690 /* SingleKeyView.swift in Sources */, + E28DED37281EC7FB00259690 /* HistoryManager.swift in Sources */, E2C5C1DB2806FE8900769EF6 /* RouteAPI.swift in Sources */, E2C5C1DD281B3AC400769EF6 /* UInt32+Extensions.swift in Sources */, 884A45CD27A465F500D6E650 /* Client.swift in Sources */, E24EE77227FDCCC00011CFD2 /* Data+Extensions.swift in Sources */, E24EE77427FF95920011CFD2 /* DeviceResponse.swift in Sources */, 884A45CB27A464C000D6E650 /* SymmetricKey+Extensions.swift in Sources */, + E28DED31281EAE9100259690 /* HistoryView.swift in Sources */, E24EE77927FF95E00011CFD2 /* Message.swift in Sources */, + E28DED35281EB17600259690 /* HistoryItem.swift in Sources */, 884A45C927A43D7900D6E650 /* ClientState.swift in Sources */, + E28DED33281EB15B00259690 /* HistoryListItem.swift in Sources */, E28DED2D281E840B00259690 /* KeyView.swift in Sources */, 884A45B7279F48C100D6E650 /* SesameApp.swift in Sources */, 884A45C5279F4BBE00D6E650 /* KeyManagement.swift in Sources */, @@ -340,12 +358,13 @@ DEVELOPMENT_TEAM = H8WR4M6QQ4; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Sesame/Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -370,12 +389,13 @@ DEVELOPMENT_TEAM = H8WR4M6QQ4; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Sesame/Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Sesame.xcodeproj/project.xcworkspace/xcuserdata/ch.xcuserdatad/UserInterfaceState.xcuserstate b/Sesame.xcodeproj/project.xcworkspace/xcuserdata/ch.xcuserdatad/UserInterfaceState.xcuserstate index a4bb03d..763be60 100644 Binary files a/Sesame.xcodeproj/project.xcworkspace/xcuserdata/ch.xcuserdatad/UserInterfaceState.xcuserstate and b/Sesame.xcodeproj/project.xcworkspace/xcuserdata/ch.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/Sesame/API/Message.swift b/Sesame/API/Message.swift index b29024c..7403182 100644 --- a/Sesame/API/Message.swift +++ b/Sesame/API/Message.swift @@ -96,6 +96,14 @@ extension Message { self.init(decodeFrom: data) } + init?(decodeFrom data: Data, index: inout Int) { + guard index + Message.length <= data.count else { + return nil + } + self.init(decodeFrom: data.advanced(by: index)) + index += Message.length + } + /// The message encoded to data var encoded: Data { mac + content.encoded diff --git a/Sesame/ClientState.swift b/Sesame/ClientState.swift index 356be3f..b06af68 100644 --- a/Sesame/ClientState.swift +++ b/Sesame/ClientState.swift @@ -23,6 +23,7 @@ enum RejectionCause { case invalidTime case invalidAuthentication case timeout + case missingKey } extension RejectionCause: CustomStringConvertible { @@ -37,6 +38,8 @@ extension RejectionCause: CustomStringConvertible { return "Invalid authentication" case .timeout: return "Device not responding" + case .missingKey: + return "No key to verify message" } } } @@ -61,6 +64,8 @@ enum ClientState { /// The transmitted message was rejected (multiple possible reasons) case messageRejected(RejectionCause) + case responseRejected(RejectionCause) + /// The device responded that the opening action was started case openSesame @@ -104,7 +109,7 @@ enum ClientState { var requiresDescription: Bool { switch self { - case .deviceNotAvailable, .messageRejected, .internalError: + case .deviceNotAvailable, .messageRejected, .internalError, .responseRejected: return true default: return false @@ -114,21 +119,19 @@ enum ClientState { var color: Color { switch self { case .noKeyAvailable: - return .gray - case .requestingStatus: - return .yellow + return Color(red: 50/255, green: 50/255, blue: 50/255) case .deviceNotAvailable: - return Color(red: 1.0, green: 0.6, blue: 0.6) - case .messageRejected: - return .red + return Color(red: 150/255, green: 90/255, blue: 90/255) + case .messageRejected, .responseRejected: + return Color(red: 160/255, green: 30/255, blue: 30/255) case .internalError: - return Color(red: 0.7, green: 0, blue: 0) + return Color(red: 100/255, green: 0/255, blue: 0/255) case .ready: - return Color(red: 0.7, green: 1.0, blue: 0.5) - case .waitingForResponse: - return Color(red: 0.9, green: 1.0, blue: 0.5) + return Color(red: 115/255, green: 140/255, blue: 90/255) + case .requestingStatus, .waitingForResponse: + return Color(red: 160/255, green: 170/255, blue: 110/255) case .openSesame: - return .green + return Color(red: 65/255, green: 110/255, blue: 60/255) } } @@ -166,7 +169,115 @@ extension ClientState: CustomStringConvertible { return "Unlocked" case .internalError(let e): return "Error: \(e)" + case .responseRejected(let cause): + switch cause { + case .invalidAuthentication: + return "Device message not authenticated" + default: + return cause.description + } + } + } +} + +// MARK: Coding + +extension ClientState { + + var encoded: Data { + Data([code]) + } + + private var code: UInt8 { + switch self { + case .noKeyAvailable: + return 1 + case .requestingStatus: + return 2 + case .deviceNotAvailable(let connectionError): + switch connectionError { + case .serverNotReached: + return 3 + case .deviceDisconnected: + return 4 + } + case .ready: + return 5 + case .waitingForResponse: + return 6 + case .messageRejected(let rejectionCause): + switch rejectionCause { + case .invalidCounter: + return 7 + case .invalidTime: + return 8 + case .invalidAuthentication: + return 9 + case .timeout: + return 10 + case .missingKey: + return 11 + } + case .responseRejected(let rejectionCause): + switch rejectionCause { + case .invalidCounter: + return 12 + case .invalidTime: + return 13 + case .invalidAuthentication: + return 14 + case .timeout: + return 15 + case .missingKey: + return 16 + } + case .openSesame: + return 17 + case .internalError(_): + return 18 + } + } + + init(code: UInt8) { + switch code { + case 1: + self = .noKeyAvailable + case 2: + self = .requestingStatus + case 3: + self = .deviceNotAvailable(.serverNotReached) + case 4: + self = .deviceNotAvailable(.deviceDisconnected) + case 5: + self = .ready + case 6: + self = .waitingForResponse + case 7: + self = .messageRejected(.invalidCounter) + case 8: + self = .messageRejected(.invalidTime) + case 9: + self = .messageRejected(.invalidAuthentication) + case 10: + self = .messageRejected(.timeout) + case 11: + self = .messageRejected(.missingKey) + case 12: + self = .responseRejected(.invalidCounter) + case 13: + self = .responseRejected(.invalidTime) + case 14: + self = .responseRejected(.invalidAuthentication) + case 15: + self = .responseRejected(.timeout) + case 16: + self = .responseRejected(.missingKey) + case 17: + self = .openSesame + case 18: + self = .internalError("") + default: + self = .internalError("Unknown code \(code)") } } - } diff --git a/Sesame/ContentView.swift b/Sesame/ContentView.swift index 41dff83..491a5c1 100644 --- a/Sesame/ContentView.swift +++ b/Sesame/ContentView.swift @@ -10,6 +10,8 @@ struct ContentView: View { @State var keyManager = KeyManagement() + + let history = HistoryManager() @State var state: ClientState = .noKeyAvailable @@ -109,7 +111,11 @@ struct ContentView: View { .sheet(isPresented: $showKeySheet) { KeyView(keyManager: $keyManager) } + .sheet(isPresented: $showHistorySheet) { + HistoryView(manager: history) + } } + .preferredColorScheme(.dark) } func mainButtonPressed() { @@ -119,51 +125,52 @@ struct ContentView: View { } let count = UInt32(nextMessageCounter) - let now = Date() + let sentTime = Date() let content = Message.Content( - time: now.timestamp, + time: sentTime.timestamp, id: count) let message = content.authenticate(using: key) + let historyItem = HistoryItem(sent: message, date: sentTime) state = .waitingForResponse print("Sending message \(count)") Task { let (newState, message) = await server.send(message, authToken: token) - responseTime = now + let receivedTime = Date.now + responseTime = receivedTime state = newState - if let message = message { - processResponse(message, sendTime: now) - } + let finishedItem = historyItem.didReceive(response: newState, date: receivedTime, message: message) + print("Interval: \(receivedTime.timeIntervalSince(sentTime))", "\(finishedItem.roundTripTime ?? -1)") + process(item: finishedItem) } } - private func processResponse(_ message: Message, sendTime: Date) { + private func process(item: HistoryItem) { + guard let message = item.incomingMessage else { + save(historyItem: item) + return + } + guard let key = keyManager.get(.deviceKey) else { + save(historyItem: item.notAuthenticated()) return } guard message.isValid(using: key) else { + save(historyItem: item.invalidated()) return } nextMessageCounter = Int(message.content.id) - print("Next counter is \(message.content.id)") - let now = Date() - let total = now.timeIntervalSince(sendTime) - print("Total time: \(Int(total * 1000)) ms") - let deviceTime = Date(timestamp: message.content.time) - let time1 = deviceTime.timeIntervalSince(sendTime) - let time2 = now.timeIntervalSince(deviceTime) - if time1 < 0 { - print("Device time behind by at least \(Int(-time1 * 1000)) ms") - print("Device: \(deviceTime)") - print("Remote: \(now)") - } else if time2 < 0 { - print("Device time ahead by at least \(Int(-time2 * 1000)) ms") - print("Device: \(deviceTime)") - print("Remote: \(now)") - } else { - print("Device time synchronized") + save(historyItem: item) + } + + private func save(historyItem: HistoryItem) { + do { + try history.save(item: historyItem) + } catch { + print("Failed to save item: \(error)") } } + private func startRegularStatusUpdates() { guard timer == nil else { return @@ -187,7 +194,6 @@ struct ContentView: View { return } hasActiveRequest = true - print("Checking device status") Task { let newState = await server.deviceStatus(authToken: authToken.data) hasActiveRequest = false @@ -198,13 +204,14 @@ struct ContentView: View { state = newState case .waitingForResponse: return - case .messageRejected, .openSesame, .internalError: + case .messageRejected, .openSesame, .internalError, .responseRejected: guard let time = responseTime else { state = newState return } responseTime = nil // Wait at least 5 seconds after these states have been reached before changing the + // interface to allow sufficient time to see the result let elapsed = Date.now.timeIntervalSince(time) guard elapsed < 5 else { state = newState diff --git a/Sesame/HistoryItem.swift b/Sesame/HistoryItem.swift new file mode 100644 index 0000000..c863c75 --- /dev/null +++ b/Sesame/HistoryItem.swift @@ -0,0 +1,168 @@ +import Foundation + +struct HistoryItem { + + let outgoingDate: Date + + let outgoingMessage: Message + + let incomingDate: Date? + + let incomingMessage: Message? + + let response: ClientState? + + init(sent message: Message, date: Date) { + self.outgoingDate = date + self.outgoingMessage = message + self.incomingDate = nil + self.incomingMessage = nil + self.response = nil + } + + func didReceive(response: ClientState, date: Date?, message: Message?) -> HistoryItem { + .init(sent: self, response: response, date: date, message: message) + } + + func invalidated() -> HistoryItem { + didReceive(response: .responseRejected(.invalidAuthentication), date: incomingDate, message: incomingMessage) + } + + func notAuthenticated() -> HistoryItem { + didReceive(response: .responseRejected(.missingKey), date: incomingDate, message: incomingMessage) + } + + private init(sent: HistoryItem, response: ClientState, date: Date?, message: Message?) { + self.outgoingDate = sent.outgoingDate + self.outgoingMessage = sent.outgoingMessage + self.incomingDate = date + self.incomingMessage = message + self.response = response + } + + // MARK: Statistics + + var roundTripTime: TimeInterval? { + incomingDate?.timeIntervalSince(outgoingDate) + } + + var deviceTime: Date? { + guard let timestamp = incomingMessage?.content.time else { + return nil + } + return Date(timestamp: timestamp) + } + + var requestLatency: TimeInterval? { + deviceTime?.timeIntervalSince(outgoingDate) + } + + var responseLatency: TimeInterval? { + guard let deviceTime = deviceTime else { + return nil + } + return incomingDate?.timeIntervalSince(deviceTime) + } + + var clockOffset: Int? { + guard let interval = roundTripTime, let deviceTime = deviceTime else { + return nil + } + let estimatedArrival = outgoingDate.advanced(by: interval / 2) + return Int(deviceTime.timeIntervalSince(estimatedArrival)) + } + + // MARK: Coding + + static func testEncoding() { + + } + + var encoded: Data { + var result = outgoingDate.encoded + outgoingMessage.encoded + if let date = incomingDate { + result += Data([1]) + date.encoded + } else { + result += Data([0]) + } + if let message = incomingMessage { + result += Data([1]) + message.encoded + } else { + result += Data([0]) + } + result += response?.encoded ?? Data([0]) + return result + } + + init?(decodeFrom data: Data, index: inout Int) { + guard let outgoingDate = Date(decodeFrom: data, index: &index) else { + return nil + } + self.outgoingDate = outgoingDate + + guard let outgoingMessage = Message(decodeFrom: data, index: &index) else { + return nil + } + self.outgoingMessage = outgoingMessage + + if data[index] > 0 { + index += 1 + guard let incomingDate = Date(decodeFrom: data, index: &index) else { + return nil + } + self.incomingDate = incomingDate + } else { + self.incomingDate = nil + index += 1 + } + + if data[index] > 0 { + index += 1 + guard let incomingMessage = Message(decodeFrom: data, index: &index) else { + return nil + } + self.incomingMessage = incomingMessage + } else { + self.incomingMessage = nil + index += 1 + } + guard index < data.count else { + return nil + } + self.response = ClientState(code: data[index]) + index += 1 + } +} + +private extension Date { + + static var encodedSize: Int { + MemoryLayout.size + } + + var encoded: Data { + .init(from: timeIntervalSince1970) + } + + init?(decodeFrom data: Data, index: inout Int) { + guard index + Date.encodedSize <= data.count else { + return nil + } + self.init(timeIntervalSince1970: data.advanced(by: index).convert(into: .zero)) + index += Date.encodedSize + } +} + +extension HistoryItem: Identifiable { + + var id: UInt32 { + outgoingDate.timestamp + } +} + +extension HistoryItem: Comparable { + + static func < (lhs: HistoryItem, rhs: HistoryItem) -> Bool { + lhs.outgoingDate < rhs.outgoingDate + } +} diff --git a/Sesame/HistoryListItem.swift b/Sesame/HistoryListItem.swift new file mode 100644 index 0000000..27a620b --- /dev/null +++ b/Sesame/HistoryListItem.swift @@ -0,0 +1,89 @@ +import SwiftUI + +private let df: DateFormatter = { + let df = DateFormatter() + df.dateStyle = .short + df.timeStyle = .short + return df +}() + +struct HistoryListItem: View { + + let entry: HistoryItem + + var entryTime: String { + df.string(from: entry.outgoingDate) + } + + var roundTripText: String { + guard let time = entry.roundTripTime else { + return "" + } + return "⇆ \(Int(time * 1000)) ms" + } + + var counterText: String { + let sentCounter = entry.outgoingMessage.content.id + let startText = "🔗 \(sentCounter)" + guard let rCounter = entry.incomingMessage?.content.id else { + return startText + } + let diff = Int(rCounter) - Int(sentCounter) + guard diff != 1 else { + return startText + } + return startText + "→\(rCounter)" + } + + var timeOffsetText: String { + guard let offset = entry.clockOffset, offset != 0 else { + return "" + } + return "🕓 \(offset) s" + } + + var body: some View { + VStack { + HStack { + Text(entry.response?.description ?? "") + .font(.headline) + Spacer() + Text(entryTime) + }.padding(.bottom, 1) + HStack { + Text(roundTripText) + .font(.subheadline) + .foregroundColor(.secondary) + Text(counterText) + .font(.subheadline) + .foregroundColor(.secondary) + Text(timeOffsetText) + .font(.subheadline) + .foregroundColor(.secondary) + Spacer() + } + }.padding() + } +} + +struct HistoryListItem_Previews: PreviewProvider { + static var previews: some View { + HistoryListItem(entry: .mock) + } +} + +private extension HistoryItem { + + static var mock: HistoryItem { + let mac = Data(repeating: 42, count: 32) + let content = Message.Content(time: Date.now.timestamp, id: 123) + let content2 = Message.Content(time: (Date.now + 1).timestamp, id: 124) + return .init( + sent: Message(mac: mac, content: content), + date: .now) + .didReceive( + response: .openSesame, + date: .now + 2, + message: Message(mac: mac, content: content2)) + } +} diff --git a/Sesame/HistoryManager.swift b/Sesame/HistoryManager.swift new file mode 100644 index 0000000..86cade8 --- /dev/null +++ b/Sesame/HistoryManager.swift @@ -0,0 +1,59 @@ +import Foundation + +final class HistoryManager { + + private var fm: FileManager { + .default + } + + var documentDirectory: URL { + try! fm.url( + for: .documentDirectory, + in: .userDomainMask, + appropriateFor: nil, create: true) + } + + private var fileUrl: URL { + documentDirectory.appendingPathComponent("history.bin") + } + + func loadEntries() -> [HistoryItem] { + let url = fileUrl + guard fm.fileExists(atPath: url.path) else { + print("No history data found") + return [] + } + let content: Data + do { + content = try Data(contentsOf: url) + } catch { + print("Failed to read history data: \(error)") + return [] + } + var index = 0 + var entries = [HistoryItem]() + while index < content.count { + guard let entry = HistoryItem(decodeFrom: content, index: &index) else { + print("Failed to read entry at index \(index)") + return entries + } + entries.append(entry) + } + return entries.sorted().reversed() + } + + func save(item: HistoryItem) throws { + let url = fileUrl + let data = item.encoded + guard fm.fileExists(atPath: url.path) else { + try data.write(to: url) + print("First history item written") + return + } + let handle = try FileHandle(forWritingTo: url) + try handle.seekToEnd() + try handle.write(contentsOf: data) + try handle.close() + print("History item written") + } +} diff --git a/Sesame/HistoryView.swift b/Sesame/HistoryView.swift new file mode 100644 index 0000000..5c7573e --- /dev/null +++ b/Sesame/HistoryView.swift @@ -0,0 +1,18 @@ +import SwiftUI + +struct HistoryView: View { + + let manager: HistoryManager + + var body: some View { + List(manager.loadEntries()) { entry in + HistoryListItem(entry: entry) + } + } +} + +struct HistoryView_Previews: PreviewProvider { + static var previews: some View { + HistoryView(manager: .init()) + } +} diff --git a/Sesame/Info.plist b/Sesame/Info.plist new file mode 100644 index 0000000..4c2206e --- /dev/null +++ b/Sesame/Info.plist @@ -0,0 +1,8 @@ + + + + + UIStatusBarStyle + UIStatusBarStyleDarkContent + + diff --git a/Sesame/KeyManagement.swift b/Sesame/KeyManagement.swift index c6e8078..4bd56af 100644 --- a/Sesame/KeyManagement.swift +++ b/Sesame/KeyManagement.swift @@ -28,6 +28,15 @@ extension KeyManagement { var keyLength: SymmetricKeySize { .bits256 } + + var usesHashing: Bool { + switch self { + case .authToken: + return true + default: + return false + } + } } } @@ -78,7 +87,6 @@ private struct KeyChain { return nil } let key = item as! CFData - print("\(type) loaded from keychain") return SymmetricKey(data: key as Data) } diff --git a/Sesame/SingleKeyView.swift b/Sesame/SingleKeyView.swift index 4cdd976..28d59c1 100644 --- a/Sesame/SingleKeyView.swift +++ b/Sesame/SingleKeyView.swift @@ -1,4 +1,5 @@ import SwiftUI +import CryptoKit struct SingleKeyView: View { @@ -11,7 +12,7 @@ struct SingleKeyView: View { let type: KeyManagement.KeyType private var generateText: String { - hasKey ? "Generate" : "Regenerate" + hasKey ? "Regenerate" : "Generate" } var hasKey: Bool { @@ -22,20 +23,32 @@ struct SingleKeyView: View { keyManager.get(type)?.displayString ?? "-" } + var copyText: String { + guard let key = keyManager.get(type)?.data else { + return "" + } + guard type.usesHashing else { + return key.hexEncoded + } + return SHA256.hash(data: key).hexEncoded + } + var body: some View { VStack(alignment: .leading, spacing: 8) { Text(type.displayName) .bold() Text(needRefresh ? content : content) .font(.system(.body, design: .monospaced)) + .foregroundColor(.secondary) HStack() { Button(generateText) { keyManager.generate(type) needRefresh.toggle() } .padding() - Button("Copy") { - UIPasteboard.general.string = content + + Button(type.usesHashing ? "Copy hash" : "Copy") { + UIPasteboard.general.string = copyText } .disabled(!hasKey) .padding() diff --git a/Sesame/SymmetricKey+Extensions.swift b/Sesame/SymmetricKey+Extensions.swift index e0f11ee..2066a91 100644 --- a/Sesame/SymmetricKey+Extensions.swift +++ b/Sesame/SymmetricKey+Extensions.swift @@ -24,6 +24,13 @@ extension SymmetricKey { } } +extension SHA256.Digest { + + var hexEncoded: String { + Data(map { $0 }).hexEncoded + } +} + extension String { func split(by length: Int) -> [String] {