diff --git a/Sesame.xcodeproj/project.pbxproj b/Sesame.xcodeproj/project.pbxproj index a3894c1..995eafd 100644 --- a/Sesame.xcodeproj/project.pbxproj +++ b/Sesame.xcodeproj/project.pbxproj @@ -16,11 +16,13 @@ 884A45CB27A464C000D6E650 /* SymmetricKey+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 884A45CA27A464C000D6E650 /* SymmetricKey+Extensions.swift */; }; 884A45CD27A465F500D6E650 /* Client.swift in Sources */ = {isa = PBXBuildFile; fileRef = 884A45CC27A465F500D6E650 /* Client.swift */; }; 884A45CF27A5402D00D6E650 /* MessageResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 884A45CE27A5402D00D6E650 /* MessageResult.swift */; }; + 8864664F29E5684C004FE2BE /* CBORCoding in Frameworks */ = {isa = PBXBuildFile; productRef = 8864664E29E5684C004FE2BE /* CBORCoding */; }; + 8864665229E5939C004FE2BE /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = 8864665129E5939C004FE2BE /* SFSafeSymbols */; }; E24EE77227FDCCC00011CFD2 /* Data+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24EE77127FDCCC00011CFD2 /* Data+Extensions.swift */; }; E24EE77427FF95920011CFD2 /* DeviceResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24EE77327FF95920011CFD2 /* DeviceResponse.swift */; }; E24EE77727FF95C00011CFD2 /* NIOCore in Frameworks */ = {isa = PBXBuildFile; productRef = E24EE77627FF95C00011CFD2 /* NIOCore */; }; E24EE77927FF95E00011CFD2 /* Message.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24EE77827FF95E00011CFD2 /* Message.swift */; }; - E28DED2D281E840B00259690 /* KeyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E28DED2C281E840B00259690 /* KeyView.swift */; }; + E28DED2D281E840B00259690 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E28DED2C281E840B00259690 /* SettingsView.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 */; }; @@ -45,7 +47,7 @@ E24EE77127FDCCC00011CFD2 /* Data+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Extensions.swift"; sourceTree = ""; }; E24EE77327FF95920011CFD2 /* DeviceResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceResponse.swift; sourceTree = ""; }; E24EE77827FF95E00011CFD2 /* Message.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Message.swift; sourceTree = ""; }; - E28DED2C281E840B00259690 /* KeyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyView.swift; sourceTree = ""; }; + E28DED2C281E840B00259690 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.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 = ""; }; @@ -62,6 +64,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 8864665229E5939C004FE2BE /* SFSafeSymbols in Frameworks */, + 8864664F29E5684C004FE2BE /* CBORCoding in Frameworks */, E24EE77727FF95C00011CFD2 /* NIOCore in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -96,7 +100,7 @@ E28DED32281EB15B00259690 /* HistoryListItem.swift */, E28DED34281EB17600259690 /* HistoryItem.swift */, E28DED36281EC7FB00259690 /* HistoryManager.swift */, - E28DED2C281E840B00259690 /* KeyView.swift */, + E28DED2C281E840B00259690 /* SettingsView.swift */, E28DED2E281E8A0500259690 /* SingleKeyView.swift */, 884A45CC27A465F500D6E650 /* Client.swift */, 884A45C827A43D7900D6E650 /* ClientState.swift */, @@ -148,6 +152,8 @@ name = Sesame; packageProductDependencies = ( E24EE77627FF95C00011CFD2 /* NIOCore */, + 8864664E29E5684C004FE2BE /* CBORCoding */, + 8864665129E5939C004FE2BE /* SFSafeSymbols */, ); productName = Sesame; productReference = 884A45B3279F48C100D6E650 /* Sesame.app */; @@ -165,6 +171,7 @@ TargetAttributes = { 884A45B2279F48C100D6E650 = { CreatedOnToolsVersion = 13.2.1; + LastSwiftMigration = 1430; }; }; }; @@ -179,6 +186,8 @@ mainGroup = 884A45AA279F48C100D6E650; packageReferences = ( E24EE77527FF95C00011CFD2 /* XCRemoteSwiftPackageReference "swift-nio" */, + 8864664D29E5684C004FE2BE /* XCRemoteSwiftPackageReference "CBORCoding" */, + 8864665029E5939C004FE2BE /* XCRemoteSwiftPackageReference "SFSafeSymbols" */, ); productRefGroup = 884A45B4279F48C100D6E650 /* Products */; projectDirPath = ""; @@ -221,7 +230,7 @@ E28DED35281EB17600259690 /* HistoryItem.swift in Sources */, 884A45C927A43D7900D6E650 /* ClientState.swift in Sources */, E28DED33281EB15B00259690 /* HistoryListItem.swift in Sources */, - E28DED2D281E840B00259690 /* KeyView.swift in Sources */, + E28DED2D281E840B00259690 /* SettingsView.swift in Sources */, 884A45B7279F48C100D6E650 /* SesameApp.swift in Sources */, 884A45C5279F4BBE00D6E650 /* KeyManagement.swift in Sources */, E2C5C1F8281E769F00769EF6 /* ServerMessage.swift in Sources */, @@ -352,6 +361,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Sesame/Preview Content\""; @@ -373,6 +383,8 @@ PRODUCT_BUNDLE_IDENTIFIER = de.christophhagen.Sesame; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; }; @@ -383,6 +395,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Sesame/Preview Content\""; @@ -404,6 +417,7 @@ PRODUCT_BUNDLE_IDENTIFIER = de.christophhagen.Sesame; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = ""; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; }; @@ -433,6 +447,22 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + 8864664D29E5684C004FE2BE /* XCRemoteSwiftPackageReference "CBORCoding" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/christophhagen/CBORCoding"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.0; + }; + }; + 8864665029E5939C004FE2BE /* XCRemoteSwiftPackageReference "SFSafeSymbols" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/SFSafeSymbols/SFSafeSymbols"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 4.0.0; + }; + }; E24EE77527FF95C00011CFD2 /* XCRemoteSwiftPackageReference "swift-nio" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/apple/swift-nio.git"; @@ -444,6 +474,16 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 8864664E29E5684C004FE2BE /* CBORCoding */ = { + isa = XCSwiftPackageProductDependency; + package = 8864664D29E5684C004FE2BE /* XCRemoteSwiftPackageReference "CBORCoding" */; + productName = CBORCoding; + }; + 8864665129E5939C004FE2BE /* SFSafeSymbols */ = { + isa = XCSwiftPackageProductDependency; + package = 8864665029E5939C004FE2BE /* XCRemoteSwiftPackageReference "SFSafeSymbols" */; + productName = SFSafeSymbols; + }; E24EE77627FF95C00011CFD2 /* NIOCore */ = { isa = XCSwiftPackageProductDependency; package = E24EE77527FF95C00011CFD2 /* XCRemoteSwiftPackageReference "swift-nio" */; diff --git a/Sesame.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Sesame.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index aa0d3f4..9cf9c14 100644 --- a/Sesame.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Sesame.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,23 @@ { "pins" : [ + { + "identity" : "cborcoding", + "kind" : "remoteSourceControl", + "location" : "https://github.com/christophhagen/CBORCoding", + "state" : { + "revision" : "1e52c77523fca12cc290b17eed12fadb50ad72af", + "version" : "1.0.0" + } + }, + { + "identity" : "sfsafesymbols", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SFSafeSymbols/SFSafeSymbols", + "state" : { + "revision" : "7cca2d60925876b5953a2cf7341cd80fbeac983c", + "version" : "4.1.1" + } + }, { "identity" : "swift-nio", "kind" : "remoteSourceControl", diff --git a/Sesame/API/Message.swift b/Sesame/API/Message.swift index 7403182..55f784e 100644 --- a/Sesame/API/Message.swift +++ b/Sesame/API/Message.swift @@ -29,6 +29,14 @@ struct Message: Equatable, Hashable { } } +extension Message: Codable { + + enum CodingKeys: Int, CodingKey { + case mac = 1 + case content = 2 + } +} + extension Message { /** @@ -74,7 +82,14 @@ extension Message { time.encoded + id.encoded } } +} +extension Message.Content: Codable { + + enum CodingKeys: Int, CodingKey { + case time = 1 + case id = 2 + } } extension Message { diff --git a/Sesame/Client.swift b/Sesame/Client.swift index 2e706f0..02e1bfa 100644 --- a/Sesame/Client.swift +++ b/Sesame/Client.swift @@ -1,30 +1,49 @@ import Foundation import CryptoKit -struct Client { - - let server: URL +final class Client { + // TODO: Use or delete private let delegate = NeverCacheDelegate() - init(server: URL) { - self.server = server - } + init() {} - func deviceStatus(authToken: Data) async -> ClientState { - await send(path: .getDeviceStatus, data: authToken).state + func deviceStatus(authToken: Data, server: String) async -> ClientState { + await send(path: .getDeviceStatus, server: server, data: authToken).state } - func send(_ message: Message, authToken: Data) async -> (state: ClientState, response: Message?) { + func sendMessageOverLocalNetwork(_ message: Message, server: String) async -> (state: ClientState, response: Message?) { + let data = message.encoded.hexEncoded + guard let url = URL(string: server + "message?m=\(data)") else { + return (.internalError("Invalid server url"), nil) + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + return await requestAndDecode(request) + } + + func send(_ message: Message, server: String, authToken: Data) async -> (state: ClientState, response: Message?) { let serverMessage = ServerMessage(authToken: authToken, message: message) - return await send(path: .postMessage, data: serverMessage.encoded) + return await send(path: .postMessage, server: server, data: serverMessage.encoded) + } + + private func send(path: RouteAPI, server: String, data: Data) async -> (state: ClientState, response: Message?) { + guard let url = URL(string: server) else { + return (.internalError("Invalid server url"), nil) + } + let fullUrl = url.appendingPathComponent(path.rawValue) + return await send(to: fullUrl, data: data) } - private func send(path: RouteAPI, data: Data) async -> (state: ClientState, response: Message?) { - let url = server.appendingPathComponent(path.rawValue) + private func send(to url: URL, data: Data) async -> (state: ClientState, response: Message?) { var request = URLRequest(url: url) request.httpBody = data request.httpMethod = "POST" + return await requestAndDecode(request) + } + + private func requestAndDecode(_ request: URLRequest) async -> (state: ClientState, response: Message?) { guard let data = await fulfill(request) else { return (.deviceNotAvailable(.serverNotReached), nil) } @@ -36,6 +55,9 @@ struct Client { } let result = ClientState(keyResult: status) guard data.count == Message.length + 1 else { + if data.count != 1 { + print("Device response with only \(data.count) bytes") + } return (result, nil) } let messageData = Array(data.advanced(by: 1)) diff --git a/Sesame/ClientState.swift b/Sesame/ClientState.swift index b06af68..3210ea7 100644 --- a/Sesame/ClientState.swift +++ b/Sesame/ClientState.swift @@ -137,7 +137,7 @@ enum ClientState { var allowsAction: Bool { switch self { - case .requestingStatus, .deviceNotAvailable, .waitingForResponse, .noKeyAvailable: + case .noKeyAvailable: return false default: return true @@ -188,7 +188,7 @@ extension ClientState { Data([code]) } - private var code: UInt8 { + var code: UInt8 { switch self { case .noKeyAvailable: return 1 diff --git a/Sesame/ContentView.swift b/Sesame/ContentView.swift index 735754d..3776ba9 100644 --- a/Sesame/ContentView.swift +++ b/Sesame/ContentView.swift @@ -1,15 +1,22 @@ import SwiftUI import CryptoKit -let server = Client(server: URL(string: "https://christophhagen.de/sesame/")!) - struct ContentView: View { + + @AppStorage("server") + var serverPath: String = "https://christophhagen.de/sesame/" + + @AppStorage("localIP") + var localAddress: String = "192.168.178.104/" @AppStorage("counter") var nextMessageCounter: Int = 0 @AppStorage("compensate") var isCompensatingDaylightTime: Bool = false + + @AppStorage("local") + private var useLocalConnection = false @State var keyManager = KeyManagement() @@ -29,10 +36,15 @@ struct ContentView: View { private var responseTime: Date? = nil @State - private var showKeySheet = false + private var showSettingsSheet = false @State private var showHistorySheet = false + + @State + private var didShowKeySheetOnce = false + + let server = Client() var compensationTime: UInt32 { isCompensatingDaylightTime ? 3600 : 0 @@ -77,7 +89,7 @@ struct ContentView: View { .font(.title2) .padding() Spacer() - Button("Keys", action: { showKeySheet = true }) + Button("Settings", action: { showSettingsSheet = true }) .frame(width: smallButtonWidth, height: smallButtonHeight) .background(.white.opacity(0.2)) @@ -97,7 +109,8 @@ struct ContentView: View { height: buttonWidth) .background(buttonBackground) .cornerRadius(buttonWidth / 2) - .overlay(RoundedRectangle(cornerRadius: buttonWidth / 2).stroke(lineWidth: buttonBorderWidth).foregroundColor(buttonColor)) + .overlay(RoundedRectangle(cornerRadius: buttonWidth / 2) + .stroke(lineWidth: buttonBorderWidth).foregroundColor(buttonColor)) .foregroundColor(buttonColor) .font(.title) .disabled(!state.allowsAction) @@ -115,8 +128,13 @@ struct ContentView: View { } .frame(width: geo.size.width, height: geo.size.height) .animation(.easeInOut, value: state.color) - .sheet(isPresented: $showKeySheet) { - KeyView(keyManager: $keyManager, isCompensatingDaylightTime: $isCompensatingDaylightTime) + .sheet(isPresented: $showSettingsSheet) { + SettingsView( + keyManager: $keyManager, + serverAddress: $serverPath, + localAddress: $localAddress, + isCompensatingDaylightTime: $isCompensatingDaylightTime, + useLocalConnection: $useLocalConnection) } .sheet(isPresented: $showHistorySheet) { HistoryView(manager: history) @@ -138,35 +156,39 @@ struct ContentView: View { time: sentTime.timestamp + compensationTime, id: count) let message = content.authenticate(using: key) - let historyItem = HistoryItem(sent: message, date: sentTime) + let historyItem = HistoryItem(sent: message.content, date: sentTime, local: useLocalConnection) state = .waitingForResponse print("Sending message \(count)") Task { - let (newState, message) = await server.send(message, authToken: token) + let (newState, responseMessage) = await send(message, authToken: token) let receivedTime = Date.now responseTime = receivedTime state = newState - let finishedItem = historyItem.didReceive(response: newState, date: receivedTime, message: message) - process(item: finishedItem) + let finishedItem = historyItem.didReceive(response: newState, date: receivedTime, message: responseMessage?.content) + guard let key = keyManager.get(.deviceKey) else { + save(historyItem: finishedItem.notAuthenticated()) + return + } + guard let responseMessage else { + save(historyItem: finishedItem) + return + } + guard responseMessage.isValid(using: key) else { + save(historyItem: finishedItem.invalidated()) + return + } + + nextMessageCounter = Int(responseMessage.content.id) + save(historyItem: finishedItem) } } - - private func process(item: HistoryItem) { - guard let message = item.incomingMessage else { - save(historyItem: item) - return + + private func send(_ message: Message, authToken: Data) async -> (state: ClientState, response: Message?) { + if useLocalConnection { + return await server.sendMessageOverLocalNetwork(message, server: localAddress) + } else { + return await server.send(message, server: serverPath, authToken: authToken) } - - 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) - save(historyItem: item) } private func save(historyItem: HistoryItem) { @@ -194,7 +216,14 @@ struct ContentView: View { } func checkDeviceStatus(_ timer: Timer) { + guard !useLocalConnection else { + return + } guard let authToken = keyManager.get(.authToken) else { + if !didShowKeySheetOnce { + didShowKeySheetOnce = true + //showSettingsSheet = true + } return } guard !hasActiveRequest else { @@ -202,7 +231,7 @@ struct ContentView: View { } hasActiveRequest = true Task { - let newState = await server.deviceStatus(authToken: authToken.data) + let newState = await server.deviceStatus(authToken: authToken.data, server: serverPath) hasActiveRequest = false switch state { case .noKeyAvailable: diff --git a/Sesame/HistoryItem.swift b/Sesame/HistoryItem.swift index c863c75..bfeac93 100644 --- a/Sesame/HistoryItem.swift +++ b/Sesame/HistoryItem.swift @@ -1,168 +1,131 @@ import Foundation + struct HistoryItem { + + + /// The sent/received date (local time, not including compensation offset) + let requestDate: Date - let outgoingDate: Date - - let outgoingMessage: Message - - let incomingDate: Date? - - let incomingMessage: Message? - + let request: Message.Content + + let usedLocalConnection: Bool + let response: ClientState? + + let responseMessage: Message.Content? + + let responseDate: Date? - init(sent message: Message, date: Date) { - self.outgoingDate = date - self.outgoingMessage = message - self.incomingDate = nil - self.incomingMessage = nil + init(sent message: Message.Content, date: Date, local: Bool) { + self.requestDate = date + self.request = message + self.responseMessage = nil self.response = nil + self.responseDate = nil + self.usedLocalConnection = local } - func didReceive(response: ClientState, date: Date?, message: Message?) -> HistoryItem { + func didReceive(response: ClientState, date: Date?, message: Message.Content?) -> HistoryItem { .init(sent: self, response: response, date: date, message: message) } func invalidated() -> HistoryItem { - didReceive(response: .responseRejected(.invalidAuthentication), date: incomingDate, message: incomingMessage) + didReceive(response: .responseRejected(.invalidAuthentication), date: responseDate, message: responseMessage) } func notAuthenticated() -> HistoryItem { - didReceive(response: .responseRejected(.missingKey), date: incomingDate, message: incomingMessage) + didReceive(response: .responseRejected(.missingKey), date: responseDate, message: responseMessage) } - private init(sent: HistoryItem, response: ClientState, date: Date?, message: Message?) { - self.outgoingDate = sent.outgoingDate - self.outgoingMessage = sent.outgoingMessage - self.incomingDate = date - self.incomingMessage = message + private init(sent: HistoryItem, response: ClientState, date: Date?, message: Message.Content?) { + self.requestDate = sent.requestDate + self.request = sent.request + self.responseDate = date + self.responseMessage = message self.response = response + self.usedLocalConnection = sent.usedLocalConnection } // MARK: Statistics var roundTripTime: TimeInterval? { - incomingDate?.timeIntervalSince(outgoingDate) + responseDate?.timeIntervalSince(requestDate) } var deviceTime: Date? { - guard let timestamp = incomingMessage?.content.time else { + guard let timestamp = responseMessage?.time else { return nil } return Date(timestamp: timestamp) } var requestLatency: TimeInterval? { - deviceTime?.timeIntervalSince(outgoingDate) + deviceTime?.timeIntervalSince(requestDate) } var responseLatency: TimeInterval? { guard let deviceTime = deviceTime else { return nil } - return incomingDate?.timeIntervalSince(deviceTime) + return responseDate?.timeIntervalSince(deviceTime) } var clockOffset: Int? { guard let interval = roundTripTime, let deviceTime = deviceTime else { return nil } - let estimatedArrival = outgoingDate.advanced(by: interval / 2) + let estimatedArrival = requestDate.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 +extension HistoryItem: Codable { + + enum CodingKeys: Int, CodingKey { + case requestDate = 1 + case request = 2 + case usedLocalConnection = 3 + case response = 4 + case responseMessage = 5 + case responseDate = 6 } } -private extension Date { - - static var encodedSize: Int { - MemoryLayout.size +extension ClientState: Codable { + + init(from decoder: Decoder) throws { + let code = try decoder.singleValueContainer().decode(UInt8.self) + self.init(code: code) } - - 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 + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(code) } } extension HistoryItem: Identifiable { var id: UInt32 { - outgoingDate.timestamp + requestDate.timestamp } } extension HistoryItem: Comparable { static func < (lhs: HistoryItem, rhs: HistoryItem) -> Bool { - lhs.outgoingDate < rhs.outgoingDate + lhs.requestDate < rhs.requestDate + } +} + +extension HistoryItem { + + static var mock: HistoryItem { + let content = Message.Content(time: Date.now.timestamp, id: 123) + let content2 = Message.Content(time: (Date.now + 1).timestamp, id: 124) + return .init(sent: content, date: .now, local: false) + .didReceive(response: .openSesame, date: .now + 2, message: content2) } } diff --git a/Sesame/HistoryListItem.swift b/Sesame/HistoryListItem.swift index 483d892..c40bd65 100644 --- a/Sesame/HistoryListItem.swift +++ b/Sesame/HistoryListItem.swift @@ -1,4 +1,5 @@ import SwiftUI +import SFSafeSymbols private let df: DateFormatter = { let df = DateFormatter() @@ -12,20 +13,20 @@ struct HistoryListItem: View { let entry: HistoryItem var entryTime: String { - df.string(from: entry.outgoingDate) + df.string(from: entry.requestDate) } - var roundTripText: String { + var roundTripText: String? { guard let time = entry.roundTripTime else { - return "" + return nil } - return "⇆ \(Int(time * 1000)) ms" + 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 { + let sentCounter = entry.request.id + let startText = "\(sentCounter)" + guard let rCounter = entry.responseMessage?.id else { return startText } let diff = Int(rCounter) - Int(sentCounter) @@ -35,15 +36,15 @@ struct HistoryListItem: View { return startText + " (\(diff))" } - var timeOffsetText: String { - guard let offset = entry.clockOffset, offset != 0 else { - return "" + var timeOffsetText: String? { + guard let offset = entry.clockOffset else { + return nil } - return "🕓 \(offset) s" + return "\(offset) s" } var body: some View { - VStack { + VStack(alignment: .leading) { HStack { Text(entry.response?.description ?? "") .font(.headline) @@ -51,18 +52,25 @@ struct HistoryListItem: View { Text(entryTime) }.padding(.bottom, 1) HStack { - Text(roundTripText) - .font(.subheadline) - .foregroundColor(.secondary) + if let roundTripText { + Image(systemSymbol: entry.usedLocalConnection ? .wifi : .network) + //Image(systemSymbol: .arrowUpArrowDownCircle) + Text(roundTripText) + .font(.subheadline) + } + //Spacer() + Image(systemSymbol: .personalhotspot) Text(counterText) .font(.subheadline) - .foregroundColor(.secondary) - Text(timeOffsetText) - .font(.subheadline) - .foregroundColor(.secondary) - Spacer() - } - }.padding() + if let timeOffsetText { + //Spacer() + Image(systemSymbol: .stopwatch) + Text(timeOffsetText) + .font(.subheadline) + } + }.foregroundColor(.secondary) + } + //.padding() } } @@ -71,19 +79,3 @@ struct HistoryListItem_Previews: PreviewProvider { 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 index 86cade8..fb9b5a6 100644 --- a/Sesame/HistoryManager.swift +++ b/Sesame/HistoryManager.swift @@ -1,59 +1,92 @@ import Foundation +import CBORCoding -final class HistoryManager { +protocol HistoryManagerProtocol { + + func loadEntries() -> [HistoryItem] + + func save(item: HistoryItem) throws +} + +final class HistoryManager: HistoryManagerProtocol { + + private let encoder = CBOREncoder(dateEncodingStrategy: .secondsSince1970) private var fm: FileManager { .default } - var documentDirectory: URL { - try! fm.url( + static var documentDirectory: URL { + try! FileManager.default.url( for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) } - private var fileUrl: URL { - documentDirectory.appendingPathComponent("history.bin") + private let fileUrl: URL + + init() { + self.fileUrl = HistoryManager.documentDirectory.appendingPathComponent("history2.bin") } - + func loadEntries() -> [HistoryItem] { - let url = fileUrl - guard fm.fileExists(atPath: url.path) else { + guard fm.fileExists(atPath: fileUrl.path) else { print("No history data found") return [] } let content: Data do { - content = try Data(contentsOf: url) + content = try Data(contentsOf: fileUrl) } catch { print("Failed to read history data: \(error)") return [] } + let decoder = CBORDecoder() 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)") + let length = Int(content[index]) + index += 1 + if index + length > content.count { + print("Missing bytes in history file: needed \(length), has only \(content.count - index)") + return entries + } + let entryData = content[index.. [HistoryItem] { + [.mock] + } + + func save(item: HistoryItem) throws { + } } diff --git a/Sesame/HistoryView.swift b/Sesame/HistoryView.swift index 5c7573e..5785bb3 100644 --- a/Sesame/HistoryView.swift +++ b/Sesame/HistoryView.swift @@ -2,17 +2,20 @@ import SwiftUI struct HistoryView: View { - let manager: HistoryManager + let manager: HistoryManagerProtocol var body: some View { - List(manager.loadEntries()) { entry in - HistoryListItem(entry: entry) + NavigationView { + List(manager.loadEntries()) { entry in + HistoryListItem(entry: entry) + } + .navigationTitle("History") } } } struct HistoryView_Previews: PreviewProvider { static var previews: some View { - HistoryView(manager: .init()) + HistoryView(manager: HistoryManagerMock()) } } diff --git a/Sesame/KeyView.swift b/Sesame/KeyView.swift deleted file mode 100644 index fd0f6f1..0000000 --- a/Sesame/KeyView.swift +++ /dev/null @@ -1,36 +0,0 @@ -import SwiftUI - -struct KeyView: View { - - @Binding - var keyManager: KeyManagement - - @Binding - var isCompensatingDaylightTime: Bool - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 8) { - ForEach(KeyManagement.KeyType.allCases) { keyType in - SingleKeyView( - keyManager: $keyManager, - type: keyType) - } - Toggle(isOn: $isCompensatingDaylightTime) { - Text("Compensate daylight savings time") - } - Text("If the remote has daylight savings time wrongly set, then the time validation will fail. Use this option to send messages with adjusted timestamps. Warning: Incorrect use of this option will allow replay attacks.") - .font(.caption) - .foregroundColor(.secondary) - }.padding() - } - } -} - -struct KeyView_Previews: PreviewProvider { - static var previews: some View { - KeyView( - keyManager: .constant(KeyManagement()), - isCompensatingDaylightTime: .constant(true)) - } -} diff --git a/Sesame/SettingsView.swift b/Sesame/SettingsView.swift new file mode 100644 index 0000000..c7d9820 --- /dev/null +++ b/Sesame/SettingsView.swift @@ -0,0 +1,73 @@ +import SwiftUI + +struct SettingsView: View { + + @Binding + var keyManager: KeyManagement + + @Binding + var serverAddress: String + + @Binding + var localAddress: String + + @Binding + var isCompensatingDaylightTime: Bool + + @Binding + var useLocalConnection: Bool + + var body: some View { + NavigationView { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading) { + Text("Server address") + .bold() + TextField("Server address", text: $serverAddress) + .padding(.leading, 8) + }.padding(.vertical, 8) + VStack(alignment: .leading) { + Text("Local address") + .bold() + TextField("Local address", text: $localAddress) + .padding(.leading, 8) + }.padding(.vertical, 8) + Toggle(isOn: $useLocalConnection) { + Text("Use direct connection to device") + } + Text("Attempt to communicate directly with the device. This is useful if the server is unavailable. Requires a WiFi connection on the same network as the device.") + .font(.caption) + .foregroundColor(.secondary) + ForEach(KeyManagement.KeyType.allCases) { keyType in + SingleKeyView( + keyManager: $keyManager, + type: keyType) + } + Toggle(isOn: $isCompensatingDaylightTime) { + Text("Compensate daylight savings time") + } + Text("If the remote has daylight savings time wrongly set, then the time validation will fail. Use this option to send messages with adjusted timestamps. Warning: Incorrect use of this option will allow replay attacks.") + .font(.caption) + .foregroundColor(.secondary) + }.padding() + }.onDisappear { + if !localAddress.hasSuffix("/") { + localAddress += "/" + } + } + .navigationTitle("Settings") + } + } +} + +struct SettingsView_Previews: PreviewProvider { + static var previews: some View { + SettingsView( + keyManager: .constant(KeyManagement()), + serverAddress: .constant("https://example.com"), + localAddress: .constant("192.168.178.42"), + isCompensatingDaylightTime: .constant(true), + useLocalConnection: .constant(false)) + } +}