Challenge-response, SwiftData, new UI
This commit is contained in:
@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import SFSafeSymbols
|
||||
import CryptoKit
|
||||
|
||||
@ -7,73 +8,20 @@ struct ContentView: View {
|
||||
@Binding
|
||||
var didLaunchFromComplication: Bool
|
||||
|
||||
@AppStorage("connectionType")
|
||||
var connectionType: ConnectionStrategy = .remoteFirst
|
||||
@ObservedObject
|
||||
var coordinator: RequestCoordinator
|
||||
|
||||
@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("deviceId")
|
||||
private var deviceId: Int = 0
|
||||
|
||||
@EnvironmentObject
|
||||
var keyManager: KeyManagement
|
||||
|
||||
@EnvironmentObject
|
||||
var history: HistoryManager
|
||||
|
||||
@State
|
||||
var state: ClientState = .noKeyAvailable
|
||||
|
||||
@State
|
||||
var stateResetTimer: Timer?
|
||||
|
||||
let server = Client()
|
||||
|
||||
private var firstTryIsLocalConnection: Bool {
|
||||
switch connectionType {
|
||||
case .local, .localFirst:
|
||||
return true
|
||||
case .remote, .remoteFirst:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private var hasSecondTry: Bool {
|
||||
switch connectionType {
|
||||
case .localFirst, .remoteFirst:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private var secondTryIsLocalConnection: Bool {
|
||||
switch connectionType {
|
||||
case .local, .localFirst:
|
||||
return false
|
||||
case .remote, .remoteFirst:
|
||||
return true
|
||||
}
|
||||
init(coordinator: RequestCoordinator, didLaunchFromComplication: Binding<Bool>) {
|
||||
self._didLaunchFromComplication = didLaunchFromComplication
|
||||
self.coordinator = coordinator
|
||||
}
|
||||
|
||||
var buttonBackground: Color {
|
||||
state.allowsAction ?
|
||||
.white.opacity(0.2) :
|
||||
.black.opacity(0.2)
|
||||
.white.opacity(0.2)
|
||||
}
|
||||
|
||||
var buttonColor: Color {
|
||||
state.allowsAction ? .white : .gray
|
||||
.white
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@ -85,145 +33,43 @@ struct ContentView: View {
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.fontWeight(.ultraLight)
|
||||
.padding()
|
||||
.onTapGesture(perform: mainButtonPressed)
|
||||
.disabled(!state.allowsAction)
|
||||
if state == .waitingForResponse {
|
||||
.onTapGesture(perform: coordinator.startUnlock)
|
||||
if coordinator.isPerformingRequest {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle())
|
||||
.frame(width: 20, height: 20)
|
||||
} else {
|
||||
Text(state.actionText)
|
||||
Text("Unlock")
|
||||
.font(.subheadline)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.background(state.color)
|
||||
.animation(.easeInOut, value: state.color)
|
||||
.onAppear {
|
||||
if state == .noKeyAvailable, keyManager.hasAllKeys {
|
||||
state = .ready
|
||||
}
|
||||
}
|
||||
.onChange(of: didLaunchFromComplication) { launched in
|
||||
.background(coordinator.state.color)
|
||||
.animation(.easeInOut, value: coordinator.state.color)
|
||||
.onChange(of: didLaunchFromComplication) { _, launched in
|
||||
guard launched else {
|
||||
return
|
||||
}
|
||||
didLaunchFromComplication = false
|
||||
mainButtonPressed()
|
||||
}
|
||||
}
|
||||
|
||||
func mainButtonPressed() {
|
||||
guard let keys = keyManager.getAllKeys(),
|
||||
let deviceId = UInt8(exactly: deviceId) else {
|
||||
return
|
||||
}
|
||||
sendMessage(from: deviceId, using: keys, isFirstTry: true)
|
||||
}
|
||||
|
||||
private func sendMessage(from deviceId: UInt8, using keys: KeySet, isFirstTry: Bool) {
|
||||
preventStateReset()
|
||||
state = .waitingForResponse
|
||||
let localConnection = isFirstTry ? firstTryIsLocalConnection : secondTryIsLocalConnection
|
||||
Task {
|
||||
let response = await send(
|
||||
count: UInt32(nextMessageCounter),
|
||||
from: deviceId,
|
||||
using: keys,
|
||||
to: server,
|
||||
over: localConnection,
|
||||
while: isCompensatingDaylightTime,
|
||||
localAddress: localAddress,
|
||||
remoteAddress: serverPath)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
state = response.response
|
||||
scheduleStateReset()
|
||||
if let counter = response.responseMessage?.id {
|
||||
nextMessageCounter = Int(counter)
|
||||
}
|
||||
}
|
||||
save(historyItem: response)
|
||||
guard isFirstTry, hasSecondTry else {
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
sendMessage(from: deviceId, using: keys, isFirstTry: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func preventStateReset() {
|
||||
stateResetTimer?.invalidate()
|
||||
stateResetTimer = nil
|
||||
}
|
||||
|
||||
private func scheduleStateReset() {
|
||||
stateResetTimer?.invalidate()
|
||||
stateResetTimer = Timer.scheduledTimer(withTimeInterval: 8.0, repeats: false) { _ in
|
||||
DispatchQueue.main.async {
|
||||
resetState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func resetState() {
|
||||
state = keyManager.hasAllKeys ? .ready : .noKeyAvailable
|
||||
preventStateReset()
|
||||
}
|
||||
|
||||
private func save(historyItem: HistoryItem) {
|
||||
do {
|
||||
try history.save(item: historyItem)
|
||||
} catch {
|
||||
print("Failed to save item: \(error)")
|
||||
coordinator.startUnlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func send(count: UInt32, from deviceId: UInt8, using keys: KeySet, to server: Client, over localConnection: Bool, while compensatingTime: Bool, localAddress: String, remoteAddress: String) async -> HistoryItem {
|
||||
let sentTime = Date()
|
||||
// Add time to compensate that the device is using daylight savings time
|
||||
let timeCompensation: UInt32 = compensatingTime ? 3600 : 0
|
||||
let content = Message.Content(
|
||||
time: sentTime.timestamp + timeCompensation,
|
||||
id: count,
|
||||
device: deviceId)
|
||||
let message = content.authenticate(using: keys.remote)
|
||||
print("Sending message \(count)")
|
||||
let address = localConnection ? localAddress : remoteAddress
|
||||
let (newState, responseMessage) = await send(message, to: server, using: keys.server, local: localConnection, address: address)
|
||||
var historyItem = HistoryItem(
|
||||
sent: message.content,
|
||||
sentDate: sentTime,
|
||||
local: localConnection,
|
||||
response: newState,
|
||||
responseDate: .now,
|
||||
responseMessage: responseMessage?.content)
|
||||
|
||||
guard let responseMessage else {
|
||||
return historyItem
|
||||
}
|
||||
guard responseMessage.isValid(using: keys.device) else {
|
||||
historyItem.response = .responseRejected(.invalidAuthentication)
|
||||
return historyItem
|
||||
}
|
||||
return historyItem
|
||||
}
|
||||
|
||||
private func send(_ message: Message, to server: Client, using authToken: Data, local: Bool, address: String) async -> (state: ClientState, response: Message?) {
|
||||
if local {
|
||||
return await server.sendMessageOverLocalNetwork(message, server: address)
|
||||
} else {
|
||||
return await server.send(message, server: address, authToken: authToken)
|
||||
#Preview {
|
||||
do {
|
||||
let config = ModelConfiguration(isStoredInMemoryOnly: true)
|
||||
let container = try ModelContainer(for: HistoryItem.self, configurations: config)
|
||||
|
||||
let item = HistoryItem.mock
|
||||
container.mainContext.insert(item)
|
||||
try container.mainContext.save()
|
||||
let coordinator = RequestCoordinator(modelContext: container.mainContext)
|
||||
return ContentView(coordinator: coordinator, didLaunchFromComplication: .constant(false))
|
||||
.modelContainer(container)
|
||||
} catch {
|
||||
fatalError("Failed to create model container.")
|
||||
}
|
||||
}
|
||||
|
||||
struct ContentView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ContentView(didLaunchFromComplication: .constant(false))
|
||||
.environmentObject(KeyManagement())
|
||||
.environmentObject(HistoryManager())
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import SFSafeSymbols
|
||||
|
||||
private let df: DateFormatter = {
|
||||
@ -10,27 +11,16 @@ private let df: DateFormatter = {
|
||||
}()
|
||||
|
||||
struct HistoryItemDetail: View {
|
||||
|
||||
@Environment(\.modelContext)
|
||||
private var modelContext
|
||||
|
||||
let item: HistoryItem
|
||||
|
||||
let history: HistoryManagerProtocol
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private var entryTime: String {
|
||||
df.string(from: item.requestDate)
|
||||
}
|
||||
|
||||
var counterText: String {
|
||||
let sentCounter = item.request.id
|
||||
let startText = "\(sentCounter)"
|
||||
guard let rCounter = item.responseMessage?.id else {
|
||||
return startText
|
||||
}
|
||||
guard sentCounter + 1 != rCounter && sentCounter != rCounter else {
|
||||
return startText
|
||||
}
|
||||
return "\(sentCounter) -> \(rCounter)"
|
||||
df.string(from: item.startDate)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@ -43,21 +33,16 @@ struct HistoryItemDetail: View {
|
||||
value: entryTime)
|
||||
SettingsListTextItem(
|
||||
title: "Connection",
|
||||
value: item.usedLocalConnection ? "Local" : "Remote")
|
||||
SettingsListTextItem(
|
||||
title: "Device ID",
|
||||
value: "\(item.request.deviceId!)")
|
||||
SettingsListTextItem(
|
||||
title: "Message Counter",
|
||||
value: counterText)
|
||||
value: item.route.displayName)
|
||||
SettingsListTextItem(
|
||||
title: "Round Trip Time",
|
||||
value: "\(Int(item.roundTripTime * 1000)) ms")
|
||||
if let offset = item.clockOffset {
|
||||
SettingsListTextItem(
|
||||
title: "Clock offset",
|
||||
value: "\(offset) seconds")
|
||||
}
|
||||
SettingsListTextItem(
|
||||
title: "Client challenge",
|
||||
value: "\(item.message.clientChallenge)")
|
||||
SettingsListTextItem(
|
||||
title: "Server challenge",
|
||||
value: "\(item.message.serverChallenge)")
|
||||
Button {
|
||||
delete(item: item)
|
||||
} label: {
|
||||
@ -76,15 +61,22 @@ struct HistoryItemDetail: View {
|
||||
}
|
||||
|
||||
private func delete(item: HistoryItem) {
|
||||
guard history.delete(item: item) else {
|
||||
return
|
||||
}
|
||||
modelContext.delete(item)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
struct HistoryItemDetail_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
HistoryItemDetail(item: .mock, history: HistoryManagerMock())
|
||||
#Preview {
|
||||
do {
|
||||
let config = ModelConfiguration(isStoredInMemoryOnly: true)
|
||||
let container = try ModelContainer(for: HistoryItem.self, configurations: config)
|
||||
|
||||
let item = HistoryItem.mock
|
||||
container.mainContext.insert(item)
|
||||
try container.mainContext.save()
|
||||
return HistoryItemDetail(item: .mock)
|
||||
.modelContainer(container)
|
||||
} catch {
|
||||
fatalError("Failed to create model container.")
|
||||
}
|
||||
}
|
||||
|
@ -14,7 +14,7 @@ struct HistoryListRow: View {
|
||||
let item: HistoryItem
|
||||
|
||||
private var entryTime: String {
|
||||
df.string(from: item.requestDate)
|
||||
df.string(from: item.startDate)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
@ -1,21 +1,23 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct HistoryView: View {
|
||||
|
||||
@Environment(\.modelContext)
|
||||
private var modelContext
|
||||
|
||||
@ObservedObject
|
||||
var history: HistoryManager
|
||||
@Query(sort: \HistoryItem.startDate, order: .reverse)
|
||||
var history: [HistoryItem] = []
|
||||
|
||||
private var unlockCount: Int {
|
||||
history.entries.count {
|
||||
$0.response == .openSesame
|
||||
}
|
||||
history.count { $0.response == .unlocked }
|
||||
}
|
||||
|
||||
private var percentage: Double {
|
||||
guard history.entries.count > 0 else {
|
||||
guard history.count > 0 else {
|
||||
return 0
|
||||
}
|
||||
return Double(unlockCount * 100) / Double(history.entries.count)
|
||||
return Double(unlockCount * 100) / Double(history.count)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@ -23,7 +25,7 @@ struct HistoryView: View {
|
||||
List {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text("\(history.entries.count) requests")
|
||||
Text("\(history.count) requests")
|
||||
.foregroundColor(.primary)
|
||||
.font(.body)
|
||||
Text(String(format: "%.1f %% success", percentage))
|
||||
@ -34,9 +36,9 @@ struct HistoryView: View {
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
|
||||
ForEach(history.entries) { item in
|
||||
ForEach(history) { item in
|
||||
NavigationLink {
|
||||
HistoryItemDetail(item: item, history: history)
|
||||
HistoryItemDetail(item: item)
|
||||
} label: {
|
||||
HistoryListRow(item: item)
|
||||
}
|
||||
@ -54,14 +56,21 @@ struct HistoryView: View {
|
||||
}
|
||||
|
||||
private func delete(item: HistoryItem) {
|
||||
guard history.delete(item: item) else {
|
||||
return
|
||||
}
|
||||
modelContext.delete(item)
|
||||
}
|
||||
}
|
||||
|
||||
struct HistoryView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
HistoryView(history: HistoryManager())
|
||||
#Preview {
|
||||
do {
|
||||
let config = ModelConfiguration(isStoredInMemoryOnly: true)
|
||||
let container = try ModelContainer(for: HistoryItem.self, configurations: config)
|
||||
|
||||
let item = HistoryItem.mock
|
||||
container.mainContext.insert(item)
|
||||
try container.mainContext.save()
|
||||
return HistoryView()
|
||||
.modelContainer(container)
|
||||
} catch {
|
||||
fatalError("Failed to create model container.")
|
||||
}
|
||||
}
|
||||
|
@ -1,29 +1,42 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
@main
|
||||
struct Sesame_Watch_Watch_AppApp: App {
|
||||
|
||||
@State
|
||||
var modelContainer: ModelContainer
|
||||
|
||||
@ObservedObject
|
||||
var coordinator: RequestCoordinator
|
||||
|
||||
let keyManagement = KeyManagement()
|
||||
|
||||
let history = HistoryManager()
|
||||
|
||||
@State
|
||||
var selected: Int = 0
|
||||
|
||||
@State
|
||||
var didLaunchFromComplication = false
|
||||
|
||||
init() {
|
||||
do {
|
||||
let modelContainer = try ModelContainer(for: HistoryItem.self)
|
||||
self.modelContainer = modelContainer
|
||||
self.coordinator = .init(modelContext: modelContainer.mainContext)
|
||||
} catch {
|
||||
fatalError("Failed to create model container: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
TabView(selection: $selected) {
|
||||
ContentView(didLaunchFromComplication: $didLaunchFromComplication)
|
||||
.environmentObject(keyManagement)
|
||||
.environmentObject(history)
|
||||
ContentView(coordinator: coordinator, didLaunchFromComplication: $didLaunchFromComplication)
|
||||
.tag(1)
|
||||
SettingsView()
|
||||
.environmentObject(keyManagement)
|
||||
.tag(2)
|
||||
HistoryView(history: history)
|
||||
HistoryView()
|
||||
.tag(3)
|
||||
}
|
||||
.tabViewStyle(PageTabViewStyle())
|
||||
@ -32,5 +45,6 @@ struct Sesame_Watch_Watch_AppApp: App {
|
||||
didLaunchFromComplication = true
|
||||
}
|
||||
}
|
||||
.modelContainer(modelContainer)
|
||||
}
|
||||
}
|
||||
|
@ -1,28 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsListToggleItem: View {
|
||||
|
||||
let title: String
|
||||
|
||||
@Binding
|
||||
var value: Bool
|
||||
|
||||
let subtitle: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Toggle(title, isOn: $value)
|
||||
Text(subtitle)
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding()
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsListToggleItem_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
SettingsListToggleItem(title: "Toggle", value: .constant(true), subtitle: "Some longer text explaining what the toggle does")
|
||||
}
|
||||
}
|
@ -11,29 +11,22 @@ struct SettingsView: View {
|
||||
@AppStorage("localIP")
|
||||
var localAddress: String = "192.168.178.104/"
|
||||
|
||||
@AppStorage("counter")
|
||||
var nextMessageCounter: Int = 0
|
||||
|
||||
@AppStorage("compensate")
|
||||
var isCompensatingDaylightTime: Bool = false
|
||||
|
||||
@AppStorage("deviceId")
|
||||
private var deviceId: Int = 0
|
||||
|
||||
@EnvironmentObject
|
||||
var keys: KeyManagement
|
||||
|
||||
var some: String { "some" }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Picker("Connection", selection: $connectionType) {
|
||||
Text(ConnectionStrategy.local.rawValue)
|
||||
Text(display: ConnectionStrategy.local)
|
||||
.tag(ConnectionStrategy.local)
|
||||
Text(ConnectionStrategy.localFirst.rawValue)
|
||||
Text(display: ConnectionStrategy.localFirst)
|
||||
.tag(ConnectionStrategy.localFirst)
|
||||
Text(ConnectionStrategy.remote.rawValue)
|
||||
Text(display: ConnectionStrategy.remote)
|
||||
.tag(ConnectionStrategy.remote)
|
||||
Text(ConnectionStrategy.remoteFirst.rawValue)
|
||||
Text(display: ConnectionStrategy.remoteFirst)
|
||||
.tag(ConnectionStrategy.remoteFirst)
|
||||
}
|
||||
.padding(.leading)
|
||||
@ -45,18 +38,6 @@ struct SettingsView: View {
|
||||
title: "Local url",
|
||||
value: $localAddress,
|
||||
footnote: "The url where the device can be reached directly on the local WiFi network.")
|
||||
SettingsNumberItemLink(
|
||||
title: "Device ID",
|
||||
value: $deviceId,
|
||||
footnote: "The device ID is unique for each remote device, and is assigned by the system administrator.")
|
||||
SettingsNumberItemLink(
|
||||
title: "Message counter",
|
||||
value: $nextMessageCounter,
|
||||
footnote: "The message counter is increased after every message to the device, and used to prevent replay attacks.")
|
||||
SettingsListToggleItem(
|
||||
title: "Daylight savings",
|
||||
value: $isCompensatingDaylightTime,
|
||||
subtitle: "Compensate timestamps if the remote has daylight savings time wrongly set.")
|
||||
SettingsKeyItemLink(
|
||||
type: .deviceKey,
|
||||
footnote: "Some text describing the purpose of the key.")
|
||||
|
Reference in New Issue
Block a user