diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/CypherProtocol.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/CypherProtocol.xcscheme
new file mode 100644
index 0000000..c8448ec
--- /dev/null
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/CypherProtocol.xcscheme
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/CypherTextKit-Package.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/CypherTextKit-Package.xcscheme
index a8ddf26..5483bc7 100644
--- a/.swiftpm/xcode/xcshareddata/xcschemes/CypherTextKit-Package.xcscheme
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/CypherTextKit-Package.xcscheme
@@ -121,7 +121,7 @@
AnyChatMessage? {
- if messages.isEmpty {
+ @MainActor fileprivate func popNext() async throws -> AnyChatMessage? {
+ while messages.isEmpty {
if drained {
return nil
}
-
+
try await getMore(iterationSize)
- return try await popNext()
- } else {
- return messages.removeFirst()
}
+
+ return messages.removeFirst()
}
-
- public func dropNext() {
+
+ @MainActor fileprivate func dropNext() {
if !messages.isEmpty {
messages.removeFirst()
}
}
-
- public func peekNext() async throws -> AnyChatMessage? {
- if messages.isEmpty {
+
+ @MainActor fileprivate func peekNext() async throws -> AnyChatMessage? {
+ while messages.isEmpty {
if drained {
return nil
}
-
+
try await getMore(iterationSize)
- return try await peekNext()
- } else {
- return messages.first
}
+
+ return messages.first
}
-
- public func getMore(_ limit: Int) async throws {
+
+ @MainActor fileprivate func getMore(_ limit: Int) async throws {
if drained { return }
-
+
let messages = try await messenger.cachedStore.listChatMessages(
inConversation: conversationId,
senderId: senderId,
@@ -75,31 +70,30 @@ fileprivate final class DeviceChatCursor {
offsetBy: self.offset,
limit: limit
)
+
self.latestOrder = messages.last?.order ?? self.latestOrder
-
self.drained = messages.count < limit
self.offset += messages.count
- try await self.messages.append(
- contentsOf: messages.asyncMap { message in
- AnyChatMessage(
- target: self.target,
- messenger: self.messenger,
- raw: try await self.messenger.decrypt(message)
- )
- })
+
+ for message in messages {
+ let raw = try messenger.decrypt(message)
+ self.messages.append(
+ AnyChatMessage(
+ target: target,
+ messenger: messenger,
+ raw: raw
+ )
+ )
+ }
}
}
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
public final class AnyChatMessageCursor {
let messenger: CypherMessenger
private let devices: [DeviceChatCursor]
let sortMode: SortMode
- private final class ResultSet {
- var messages = [AnyChatMessage]()
- }
-
private init(
conversationId: UUID,
messenger: CypherMessenger,
@@ -111,89 +105,85 @@ public final class AnyChatMessageCursor {
self.sortMode = sortMode
}
- public func getNext() async throws -> AnyChatMessage? {
- struct CursorResult {
- let device: DeviceChatCursor
- let message: AnyChatMessage
- }
-
- var results = try await devices.asyncCompactMap { device -> (Date, CursorResult)? in
+ @MainActor public func getNext() async throws -> AnyChatMessage? {
+ var results = [(Date, DeviceChatCursor)]()
+
+ for device in devices {
if let message = try await device.peekNext() {
- let result = CursorResult(
- device: device,
- message: message
- )
-
- return (message.sentDate ?? Date(), result)
- } else {
- return nil
+ let sentDate = message.sentDate ?? Date()
+ results.append((sentDate, device))
}
}
-
+
results.sort { lhs, rhs -> Bool in
switch self.sortMode {
case .ascending:
return lhs.0 < rhs.0
- case .descending:
- return lhs.0 > rhs.0
+ case .descending:
+ return lhs.0 > rhs.0
}
}
-
- guard let result = results.first?.1 else {
+
+ guard let deviceCursor = results.first?.1 else {
return nil
}
-
- return try await result.device.popNext()
+
+ return try await deviceCursor.popNext()
}
- private func _getMore(_ max: Int, joinedWith resultSet: ResultSet) async throws {
+ @MainActor private func _getMore(_ max: Int, joinedWith resultSet: inout [AnyChatMessage]) async throws {
if max <= 0 {
return
}
-
- guard let message = try await getNext() else {
- return
+
+ for _ in 0.. [AnyChatMessage] {
- let resultSet = ResultSet()
+ @MainActor public func getMore(_ max: Int) async throws -> [AnyChatMessage] {
+ var resultSet = [AnyChatMessage]()
if max <= 500 {
- resultSet.messages.reserveCapacity(max)
+ resultSet.reserveCapacity(max)
}
- try await _getMore(max, joinedWith: resultSet)
- return resultSet.messages
+ try await _getMore(max, joinedWith: &resultSet)
+ return resultSet
}
- public static func readingConversation(
+ @MainActor public static func readingConversation(
_ conversation: Conversation,
sortMode: SortMode = .descending
) async throws -> AnyChatMessageCursor {
assert(sortMode == .descending, "Unsupported ascending")
-
- var devices = try await conversation.memberDevices().asyncMap { device in
- await DeviceChatCursor(
- target: conversation.getTarget(),
- conversationId: conversation.conversation.encrypted.id,
- messenger: conversation.messenger,
- senderId: device.props.senderId,
- sortMode: sortMode
+
+ var devices = [DeviceChatCursor]()
+
+ for device in try await conversation.historicMemberDevices() {
+ devices.append(
+ DeviceChatCursor(
+ target: await conversation.getTarget(),
+ conversationId: conversation.conversation.encrypted.id,
+ messenger: conversation.messenger,
+ senderId: await device.props.senderId,
+ sortMode: sortMode
+ )
)
}
- await devices.append(
+
+ devices.append(
DeviceChatCursor(
- target: conversation.getTarget(),
+ target: await conversation.getTarget(),
conversationId: conversation.conversation.encrypted.id,
messenger: conversation.messenger,
senderId: conversation.messenger.deviceIdentityId,
sortMode: sortMode
)
)
-
+
return AnyChatMessageCursor(
conversationId: conversation.conversation.encrypted.id,
messenger: conversation.messenger,
diff --git a/Sources/CypherMessaging/Contacts/API+Contacts.swift b/Sources/CypherMessaging/Contacts/API+Contacts.swift
index 1ce049c..f34fc83 100644
--- a/Sources/CypherMessaging/Contacts/API+Contacts.swift
+++ b/Sources/CypherMessaging/Contacts/API+Contacts.swift
@@ -3,17 +3,18 @@ import BSON
import Foundation
import NIO
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
public struct Contact: Identifiable, Hashable {
public let messenger: CypherMessenger
public let model: DecryptedModel
+ @CacheActor public let cache = Cache()
- public func save() async throws {
+ @MainActor public func save() async throws {
try await messenger.cachedStore.updateContact(model.encrypted)
messenger.eventHandler.onUpdateContact(self)
}
- public var username: Username {
+ @MainActor public var username: Username {
model.username
}
@@ -27,28 +28,32 @@ public struct Contact: Identifiable, Hashable {
id.hash(into: &hasher)
}
- public func remove() async throws {
+ @MainActor public func remove() async throws {
try await messenger.cachedStore.removeContact(model.encrypted)
messenger.eventHandler.onRemoveContact(self)
}
-
- public func refreshDevices() async throws {
+
+ @MainActor public func refreshDevices() async throws {
try await messenger._refreshDeviceIdentities(for: username)
}
}
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
extension CypherMessenger {
- public func listContacts() async throws -> [Contact] {
- try await self.cachedStore.fetchContacts().asyncMap { contact in
- Contact(
- messenger: self,
- model: try await self.decrypt(contact)
+ @MainActor public func listContacts() async throws -> [Contact] {
+ var contacts = [Contact]()
+ for contact in try await self.cachedStore.fetchContacts() {
+ contacts.append(
+ Contact(
+ messenger: self,
+ model: try self.decrypt(contact)
+ )
)
}
+ return contacts
}
- public func getContact(byUsername username: Username) async throws -> Contact? {
+ @MainActor public func getContact(byUsername username: Username) async throws -> Contact? {
for contact in try await listContacts() {
if contact.model.username == username {
return contact
@@ -58,7 +63,7 @@ extension CypherMessenger {
return nil
}
- public func createContact(byUsername username: Username) async throws -> Contact {
+ @MainActor public func createContact(byUsername username: Username) async throws -> Contact {
if username == self.username {
throw CypherSDKError.badInput
}
@@ -86,10 +91,10 @@ extension CypherMessenger {
try await self.cachedStore.createContact(contact)
self.eventHandler.onCreateContact(
- Contact(messenger: self, model: try await self.decrypt(contact)),
+ Contact(messenger: self, model: try self.decrypt(contact)),
messenger: self
)
- return try await Contact(messenger: self, model: self.decrypt(contact))
+ return try Contact(messenger: self, model: self.decrypt(contact))
}
}
}
diff --git a/Sources/CypherMessaging/Conversations/API+ChatMessage.swift b/Sources/CypherMessaging/Conversations/API+ChatMessage.swift
index e457532..cb9027e 100644
--- a/Sources/CypherMessaging/Conversations/API+ChatMessage.swift
+++ b/Sources/CypherMessaging/Conversations/API+ChatMessage.swift
@@ -1,48 +1,48 @@
import Foundation
-@available(macOS 12, iOS 15, *)
-public struct AnyChatMessage {
+@available(macOS 10.15, iOS 13, *)
+public struct AnyChatMessage: Sendable {
public let target: TargetConversation
public let messenger: CypherMessenger
public let raw: DecryptedModel
- public func markAsRead() async throws {
+ @MainActor public func markAsRead() async throws {
if raw.deliveryState == .read || sender == messenger.username {
return
}
- _ = try await messenger._markMessage(byId: raw.encrypted.id, as: .read)
+ try await messenger._markMessage(byId: raw.encrypted.id, as: .read)
}
- public var text: String {
+ @MainActor public var text: String {
raw.message.text
}
- public var metadata: Document {
+ @MainActor public var metadata: Document {
raw.message.metadata
}
- public var messageType: CypherMessageType {
+ @MainActor public var messageType: CypherMessageType {
raw.message.messageType
}
- public var messageSubtype: String? {
+ @MainActor public var messageSubtype: String? {
raw.message.messageSubtype
}
- public var sentDate: Date? {
+ @MainActor public var sentDate: Date? {
raw.message.sentDate
}
- public var destructionTimer: TimeInterval? {
+ @MainActor public var destructionTimer: TimeInterval? {
raw.message.destructionTimer
}
- public var sender: Username {
+ @MainActor public var sender: Username {
raw.senderUser
}
- public func remove() async throws {
+ @MainActor public func remove() async throws {
try await messenger.cachedStore.removeChatMessage(raw.encrypted)
messenger.eventHandler.onRemoveChatMessage(self)
}
diff --git a/Sources/CypherMessaging/Conversations/API+Conversations.swift b/Sources/CypherMessaging/Conversations/API+Conversations.swift
index 8d469d4..db9f272 100644
--- a/Sources/CypherMessaging/Conversations/API+Conversations.swift
+++ b/Sources/CypherMessaging/Conversations/API+Conversations.swift
@@ -1,9 +1,10 @@
import CypherProtocol
-import BSON
+@preconcurrency import BSON
import Foundation
+import TaskQueue
import NIO
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
extension CypherMessenger {
public func getConversation(byId id: UUID) async throws -> TargetConversation.Resolved? {
let conversations = try await cachedStore.fetchConversations()
@@ -21,78 +22,83 @@ extension CypherMessenger {
return nil
}
- public func getInternalConversation() async throws -> InternalConversation {
- let conversations = try await cachedStore.fetchConversations()
- for conversation in conversations {
- let conversation = try await self.decrypt(conversation)
-
- if conversation.members == [self.username] {
- return InternalConversation(conversation: conversation, messenger: self)
+ @MainActor public func getInternalConversation() async throws -> InternalConversation {
+ try await taskQueue.runThrowing { @MainActor in
+ let conversations = try await self.cachedStore.fetchConversations()
+ for conversation in conversations {
+ let conversation = try self.decrypt(conversation)
+
+ if conversation.members == [self.username] {
+ return InternalConversation(conversation: conversation, messenger: self)
+ }
}
+
+ let conversation = try await self._createConversation(
+ members: [self.username],
+ metadata: [:]
+ )
+
+ return InternalConversation(
+ conversation: try self.decrypt(conversation),
+ messenger: self
+ )
}
-
- let conversation = try await self._createConversation(
- members: [self.username],
- metadata: [:]
- )
-
- return InternalConversation(
- conversation: try await self.decrypt(conversation),
- messenger: self
- )
}
internal func _openGroupChat(byId id: GroupChatId) async throws -> GroupChat {
- if let groupChat = try await getGroupChat(byId: id) {
- return groupChat
- }
-
- let config = try await self.transport.readPublishedBlob(
- byId: id.raw,
- as: Signed.self
- )
-
- guard let config = config else {
- throw CypherSDKError.unknownGroup
- }
-
- let groupConfig = try config.blob.readWithoutVerifying()
+ try await taskQueue.runThrowing { @MainActor in
+ if let groupChat = try await self.getGroupChat(byId: id) {
+ return groupChat
+ }
+
+ let config = try await self.transport.readPublishedBlob(
+ byId: id.raw,
+ as: Signed.self
+ )
- let devices = try await self._fetchDeviceIdentities(for: groupConfig.admin)
- for device in devices {
- if config.blob.isSigned(by: device.props.identity) {
- let config = ReferencedBlob(id: config.id, blob: groupConfig)
- let groupMetadata = GroupMetadata(
- custom: [:],
- config: config
- )
- let conversation = try ConversationModel(
- props: .init(
- members: groupConfig.members,
- metadata: BSONEncoder().encode(groupMetadata),
- localOrder: 0
- ),
- encryptionKey: self.databaseEncryptionKey
- )
-
- try await self.cachedStore.createConversation(conversation)
- let chat = GroupChat(
- conversation: try await self.decrypt(conversation),
- messenger: self,
- metadata: groupMetadata
- )
- self.eventHandler.onCreateConversation(chat)
- return chat
+ guard let config = config else {
+ throw CypherSDKError.unknownGroup
}
- }
- throw CypherSDKError.invalidGroupConfig
+ let groupConfig = try config.blob.readWithoutVerifying()
+
+ let devices = try await self._fetchDeviceIdentities(for: groupConfig.admin)
+ for device in devices {
+ if await config.blob.isSigned(by: device.props.identity) {
+ let config = ReferencedBlob(id: config.id, blob: groupConfig)
+ let groupMetadata = GroupMetadata(
+ custom: [:],
+ config: config
+ )
+ let conversation = try ConversationModel(
+ props: .init(
+ members: groupConfig.members,
+ kickedMembers: [],
+ metadata: BSONEncoder().encode(groupMetadata),
+ localOrder: 0
+ ),
+ encryptionKey: self.databaseEncryptionKey
+ )
+
+ try await self.cachedStore.createConversation(conversation)
+ let chat = GroupChat(
+ conversation: try self.decrypt(conversation),
+ messenger: self,
+ metadata: groupMetadata
+ )
+ self.eventHandler.onCreateConversation(chat)
+ return chat
+ }
+ }
+
+ throw CypherSDKError.invalidGroupConfig
+ }
}
- public func getGroupChat(byId id: GroupChatId) async throws -> GroupChat? {
+ @MainActor public func getGroupChat(byId id: GroupChatId) async throws -> GroupChat? {
let conversations = try await cachedStore.fetchConversations()
nextConversation: for conversation in conversations {
- let conversation = try await self.decrypt(conversation)
+ let conversation = try self.decrypt(conversation)
guard
conversation.members.count >= 2,
conversation.members.contains(self.username)
@@ -123,10 +129,10 @@ extension CypherMessenger {
return nil
}
- public func getPrivateChat(with otherUser: Username) async throws -> PrivateChat? {
+ @MainActor public func getPrivateChat(with otherUser: Username) async throws -> PrivateChat? {
let conversations = try await cachedStore.fetchConversations()
nextConversation: for conversation in conversations {
- let conversation = try await self.decrypt(conversation)
+ let conversation = try self.decrypt(conversation)
let members = conversation.members
if
@@ -159,6 +165,8 @@ extension CypherMessenger {
moderators: [self.username],
metadata: sharedMetadata
)
+ // TODO: Transparent Group Chats (no uploaded binary blobs)
+ // TODO: Predefined group members?
let referencedBlob = try await self.transport.publishBlob(self.sign(config))
let metadata = GroupMetadata(
@@ -176,7 +184,7 @@ extension CypherMessenger {
metadata: metadataDocument
)
- let chat = GroupChat(
+ let chat = GroupChat(
conversation: try await self.decrypt(conversation),
messenger: self,
metadata: metadata
@@ -192,34 +200,36 @@ extension CypherMessenger {
}
public func createPrivateChat(with otherUser: Username) async throws -> PrivateChat {
- guard otherUser != self.username else {
- throw CypherSDKError.badInput
- }
-
- if let conversation = try await self.getPrivateChat(with: otherUser) {
- return conversation
- } else {
- let metadata = try await self.eventHandler.createPrivateChatMetadata(
- withUser: otherUser,
- messenger: self
- )
-
- let conversation = try await self._createConversation(
- members: [otherUser],
- metadata: metadata
- )
+ try await taskQueue.runThrowing { @MainActor in
+ guard otherUser != self.username else {
+ throw CypherSDKError.badInput
+ }
- return PrivateChat(
- conversation: try await self.decrypt(conversation),
- messenger: self
- )
+ if let conversation = try await self.getPrivateChat(with: otherUser) {
+ return conversation
+ } else {
+ let metadata = try await self.eventHandler.createPrivateChatMetadata(
+ withUser: otherUser,
+ messenger: self
+ )
+
+ let conversation = try await self._createConversation(
+ members: [otherUser],
+ metadata: metadata
+ )
+
+ return PrivateChat(
+ conversation: try self.decrypt(conversation),
+ messenger: self
+ )
+ }
}
}
- public func listPrivateChats(increasingOrder: @escaping (PrivateChat, PrivateChat) throws -> Bool) async throws -> [PrivateChat] {
+ @MainActor public func listPrivateChats(increasingOrder: @escaping @Sendable @MainActor (PrivateChat, PrivateChat) throws -> Bool) async throws -> [PrivateChat] {
let conversations = try await cachedStore.fetchConversations()
return try await conversations.asyncCompactMap { conversation -> PrivateChat? in
- let conversation = try await self.decrypt(conversation)
+ let conversation = try self.decrypt(conversation)
let members = conversation.members
guard
members.contains(self.username),
@@ -230,13 +240,15 @@ extension CypherMessenger {
}
return PrivateChat(conversation: conversation, messenger: self)
- }.sorted(by: increasingOrder)
+ }.sorted { lhs, rhs in
+ return try increasingOrder(lhs, rhs)
+ }
}
- public func listGroupChats(increasingOrder: @escaping (GroupChat, GroupChat) throws -> Bool) async throws -> [GroupChat] {
+ @MainActor public func listGroupChats(increasingOrder: @escaping @Sendable @MainActor (GroupChat, GroupChat) throws -> Bool) async throws -> [GroupChat] {
let conversations = try await cachedStore.fetchConversations()
return try await conversations.asyncCompactMap { conversation -> GroupChat? in
- let conversation = try await self.decrypt(conversation)
+ let conversation = try self.decrypt(conversation)
let members = conversation.members
guard
@@ -257,17 +269,19 @@ extension CypherMessenger {
} catch {
return nil
}
- }.sorted(by: increasingOrder)
+ }.sorted { lhs, rhs in
+ return try increasingOrder(lhs, rhs)
+ }
}
- public func listConversations(
+ @MainActor public func listConversations(
includingInternalConversation: Bool,
- increasingOrder: @escaping (TargetConversation.Resolved, TargetConversation.Resolved) throws -> Bool
+ increasingOrder: @escaping @Sendable @MainActor (TargetConversation.Resolved, TargetConversation.Resolved) throws -> Bool
) async throws -> [TargetConversation.Resolved] {
let conversations = try await cachedStore.fetchConversations()
return try await conversations.asyncCompactMap { conversation -> TargetConversation.Resolved? in
- let conversation = try await self.decrypt(conversation)
+ let conversation = try self.decrypt(conversation)
let resolved = await TargetConversation.Resolved(conversation: conversation, messenger: self)
if !includingInternalConversation, case .internalChat = resolved {
@@ -275,12 +289,14 @@ extension CypherMessenger {
}
return resolved
- }.sorted(by: increasingOrder)
+ }.sorted { lhs, rhs in
+ return try increasingOrder(lhs, rhs)
+ }
}
}
-@available(macOS 12, iOS 15, *)
-public protocol AnyConversation {
+@available(macOS 10.15, iOS 13, *)
+public protocol AnyConversation: Sendable {
var conversation: DecryptedModel { get }
var messenger: CypherMessenger { get }
var cache: Cache { get }
@@ -289,7 +305,7 @@ public protocol AnyConversation {
func resolveTarget() async -> TargetConversation.Resolved
}
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
extension AnyConversation {
public func listOpenP2PConnections() async throws -> [P2PClient] {
try await self.memberDevices().asyncCompactMap { device in
@@ -315,21 +331,26 @@ extension AnyConversation {
}
// TODO: This _could_ be cached
- internal func memberDevices() async throws -> [DecryptedModel] {
+ internal func memberDevices() async throws -> [_DecryptedModel] {
try await messenger._fetchDeviceIdentities(forUsers: conversation.members)
}
+
+ internal func historicMemberDevices() async throws -> [_DecryptedModel] {
+ try await messenger._fetchDeviceIdentities(forUsers: conversation.allHistoricMembers)
+ }
- public func save() async throws {
+ @MainActor public func save() async throws {
try await messenger.cachedStore.updateConversation(conversation.encrypted)
messenger.eventHandler.onUpdateConversation(self)
}
- private func getNextLocalOrder() async throws -> Int {
+ internal func getNextLocalOrder() async throws -> Int {
let order = try await conversation.getNextLocalOrder()
try await messenger.cachedStore.updateConversation(conversation.encrypted)
return order
}
+ @discardableResult
@JobQueueActor public func sendRawMessage(
type: CypherMessageType,
messageSubtype: String? = nil,
@@ -365,7 +386,7 @@ extension AnyConversation {
metadata: Document = [:],
destructionTimer: TimeInterval? = nil,
sentDate: Date = Date()
- ) async throws -> DecryptedModel {
+ ) async throws -> DecryptedModel? {
let order = try await getNextLocalOrder()
let message = await SingleCypherMessage(
messageType: type,
@@ -390,12 +411,22 @@ extension AnyConversation {
)
}
- internal func _saveMessage(
+ @CypherTextKitActor internal func _saveMessage(
senderId: Int,
order: Int,
props: ChatMessageModel.SecureProps,
remoteId: String = UUID().uuidString
- ) async throws -> DecryptedModel {
+ ) async throws -> DecryptedModel? {
+ if let existingMessage = try? await messenger.cachedStore.fetchChatMessage(byRemoteId: remoteId) {
+ let existingMessage = try await messenger.decrypt(existingMessage)
+
+ if await existingMessage.senderUser == props.senderUser, await existingMessage.senderDeviceId == props.senderDeviceId {
+ return nil
+ } else {
+ // TODO: Allow duplicate remote IDs, if they originate from different users
+ }
+ }
+
let chatMessage = try ChatMessageModel(
conversationId: conversation.id,
senderId: senderId,
@@ -419,7 +450,7 @@ extension AnyConversation {
return message
}
- internal func _sendMessage(
+ @MainActor internal func _sendMessage(
_ message: SingleCypherMessage,
to recipients: Set,
pushType: PushType
@@ -441,7 +472,7 @@ extension AnyConversation {
case .send:
()
case .saveAndSend:
- let chatMessage = try await _saveMessage(
+ guard let chatMessage = try await _saveMessage(
senderId: messenger.deviceIdentityId,
order: message.order,
props: .init(
@@ -449,25 +480,52 @@ extension AnyConversation {
senderUser: self.messenger.username,
senderDeviceId: self.messenger.deviceId
)
- )
+ ) else {
+ // Message was not saved, probably an error
+ // Let's abort to avoid confusion, while we can
+ return nil
+ }
+
remoteId = chatMessage.encrypted.remoteId
localId = chatMessage.id
_chatMessage = chatMessage
}
-
- try await messenger._queueTask(
- .sendMultiRecipientMessage(
- SendMultiRecipientMessageTask(
- message: CypherMessage(message: message),
- // We _always_ attach a messageID so the protocol doesn't give away
- // The precense of magic packets
- messageId: remoteId,
- recipients: recipients,
- localId: localId,
- pushType: pushType
+
+ if messenger.transport.supportsMultiRecipientMessages && messenger.isOnline {
+ // If offline, try to leverage a potential peer-to-peer mesh
+ try await messenger._queueTask(
+ .sendMultiRecipientMessage(
+ SendMultiRecipientMessageTask(
+ message: CypherMessage(message: message),
+ // We _always_ attach a messageID so the protocol doesn't give away
+ // The precense of magic packets
+ messageId: remoteId,
+ recipients: recipients,
+ localId: localId,
+ pushType: pushType
+ )
)
)
- )
+ } else {
+ var tasks = [CypherTask]()
+
+ for device in try await memberDevices() {
+ await tasks.append(
+ .sendMessage(
+ SendMessageTask(
+ message: CypherMessage(message: message),
+ recipient: device.username,
+ recipientDeviceId: device.deviceId,
+ localId: localId,
+ pushType: pushType,
+ messageId: remoteId
+ )
+ )
+ )
+ }
+
+ try await messenger._queueTasks(tasks)
+ }
try await messenger.cachedStore.updateConversation(conversation.encrypted)
@@ -482,41 +540,41 @@ extension AnyConversation {
}
}
- public func message(byRemoteId remoteId: String) async throws -> AnyChatMessage {
+ @MainActor public func message(byRemoteId remoteId: String) async throws -> AnyChatMessage {
let message = try await self.messenger.cachedStore.fetchChatMessage(byRemoteId: remoteId)
return await AnyChatMessage(
target: self.getTarget(),
messenger: self.messenger,
- raw: try await self.messenger.decrypt(message)
+ raw: try self.messenger.decrypt(message)
)
}
- public func message(byLocalId id: UUID) async throws -> AnyChatMessage {
+ @MainActor public func message(byLocalId id: UUID) async throws -> AnyChatMessage {
let message = try await self.messenger.cachedStore.fetchChatMessage(byId: id)
return await AnyChatMessage(
target: self.getTarget(),
messenger: self.messenger,
- raw: try await self.messenger.decrypt(message)
+ raw: try self.messenger.decrypt(message)
)
}
- public func allMessages(sortedBy sortMode: SortMode) async throws -> [AnyChatMessage] {
+ @MainActor public func allMessages(sortedBy sortMode: SortMode) async throws -> [AnyChatMessage] {
let cursor = try await cursor(sortedBy: sortMode)
return try await cursor.getMore(.max)
}
-
- public func cursor(sortedBy sortMode: SortMode) async throws -> AnyChatMessageCursor {
+
+ @MainActor public func cursor(sortedBy sortMode: SortMode) async throws -> AnyChatMessageCursor {
try await AnyChatMessageCursor.readingConversation(self)
}
}
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
public struct InternalConversation: AnyConversation {
public let conversation: DecryptedModel
public let messenger: CypherMessenger
- public let cache = Cache()
+ @CacheActor public let cache = Cache()
public func getTarget() async -> TargetConversation {
return .currentUser
@@ -528,18 +586,74 @@ public struct InternalConversation: AnyConversation {
public func sendInternalMessage(_ message: SingleCypherMessage) async throws {
// Refresh device identities
- // TODO: Rate limit
- _ = try await self.messenger._fetchDeviceIdentities(for: messenger.username)
+ if messenger.isOnline {
+ try await self.messenger._refreshDeviceIdentities(for: messenger.username)
+ }
+
try await messenger._writeMessage(message, to: messenger.username)
}
+
+ @JobQueueActor public func sendMagicPacket(
+ messageSubtype: String,
+ text: String,
+ metadata: Document = [:],
+ toDeviceId recipientDeviceId: DeviceId
+ ) async throws {
+ let order = try await getNextLocalOrder()
+ try await messenger._queueTask(
+ .sendMessage(
+ SendMessageTask(
+ message: CypherMessage(
+ message: SingleCypherMessage(
+ messageType: .magic,
+ messageSubtype: messageSubtype,
+ text: text,
+ metadata: metadata,
+ sentDate: Date(),
+ preferredPushType: PushType.none,
+ order: order,
+ target: .currentUser
+ )
+ ),
+ recipient: messenger.username,
+ recipientDeviceId: recipientDeviceId,
+ localId: nil,
+ pushType: .none,
+ messageId: UUID().uuidString
+ )
+ )
+ )
+ }
+
+ @JobQueueActor public func sendMagicPacket(
+ messageSubtype: String,
+ text: String,
+ metadata: Document = [:]
+ ) async throws {
+ let order = try await getNextLocalOrder()
+ _ = try await _sendMessage(
+ SingleCypherMessage(
+ messageType: .magic,
+ messageSubtype: messageSubtype,
+ text: text,
+ metadata: metadata,
+ sentDate: Date(),
+ preferredPushType: PushType.none,
+ order: order,
+ target: .currentUser
+ ),
+ to: conversation.members,
+ pushType: .none
+ )
+ }
}
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
public struct GroupChat: AnyConversation {
public let conversation: DecryptedModel
public let messenger: CypherMessenger
internal var metadata: GroupMetadata
- public let cache = Cache()
+ @CacheActor public let cache = Cache()
public func getGroupConfig() async -> ReferencedBlob {
metadata.config
}
@@ -554,32 +668,207 @@ public struct GroupChat: AnyConversation {
public func resolveTarget() async -> TargetConversation.Resolved {
.groupChat(self)
}
+
+ @MainActor public func kickMember(_ member: Username) async throws {
+ if !conversation.members.contains(member) {
+ throw CypherSDKError.notGroupMember
+ }
+
+ try conversation.modifyProps { props in
+ props.members.remove(member)
+ props.kickedMembers.insert(member)
+ }
+
+ try await self.save()
+
+ // Send message to user explicitly, so that they know they're kicked
+ let order = try await getNextLocalOrder()
+ try await messenger._writeMessage(
+ SingleCypherMessage(
+ messageType: .magic,
+ messageSubtype: "_/group/kick",
+ text: member.raw,
+ metadata: [:],
+ destructionTimer: nil,
+ sentDate: Date(),
+ preferredPushType: PushType.none,
+ order: order,
+ target: await self.getTarget()
+ ),
+ to: member
+ )
+
+ // TODO: Notify all members, update uploaded config
+ }
+
+ @MainActor public func inviteMember(_ member: Username) async throws {
+ if conversation.members.contains(member) {
+ return
+ }
+
+ try conversation.modifyProps { props in
+ props.members.insert(member)
+ props.kickedMembers.remove(member)
+ }
+
+ try await self.save()
+ try await sendRawMessage(
+ type: .magic,
+ messageSubtype: "_/group/invite",
+ text: member.raw,
+ preferredPushType: .none
+ )
+
+ // TODO: Notify all members, update uploaded config
+ }
}
-public struct GroupMetadata: Codable {
+public struct GroupMetadata: Codable, Sendable {
public private(set) var _type = "group"
public var custom: Document
public internal(set) var config: ReferencedBlob
}
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
public struct PrivateChat: AnyConversation {
public let conversation: DecryptedModel
public let messenger: CypherMessenger
- public let cache = Cache()
+ @CacheActor public let cache = Cache()
- public func getTarget() -> TargetConversation {
- .otherUser(conversationPartner)
+ public func getTarget() async -> TargetConversation {
+ .otherUser(await conversationPartner)
}
public func resolveTarget() -> TargetConversation.Resolved {
.privateChat(self)
}
- public var conversationPartner: Username {
+ @MainActor public var conversationPartner: Username {
// PrivateChats always have exactly 2 members
var members = conversation.members
members.remove(messenger.username)
return members.first!
}
+
+ @JobQueueActor public func sendMagicPacket(
+ messageSubtype: String,
+ text: String,
+ metadata: Document = [:],
+ toDeviceId recipientDeviceId: DeviceId
+ ) async throws {
+ let order = try await getNextLocalOrder()
+ try await messenger._queueTask(
+ .sendMessage(
+ SendMessageTask(
+ message: CypherMessage(
+ message: SingleCypherMessage(
+ messageType: .magic,
+ messageSubtype: messageSubtype,
+ text: text,
+ metadata: metadata,
+ sentDate: Date(),
+ preferredPushType: PushType.none,
+ order: order,
+ target: .currentUser
+ )
+ ),
+ recipient: conversationPartner,
+ recipientDeviceId: recipientDeviceId,
+ localId: nil,
+ pushType: .none,
+ messageId: UUID().uuidString
+ )
+ )
+ )
+ }
+}
+
+extension AnyConversation {
+ @discardableResult
+ @JobQueueActor public func sendMagicPacketWithinCurrentUser(
+ messageSubtype: String? = nil,
+ text: String,
+ metadata: Document = [:],
+ preferredPushType: PushType
+ ) async throws -> AnyChatMessage? {
+ let order = try await getNextLocalOrder()
+ return try await self._sendMessage(
+ SingleCypherMessage(
+ messageType: .magic,
+ messageSubtype: messageSubtype,
+ text: text,
+ metadata: metadata,
+ destructionTimer: nil,
+ sentDate: Date(),
+ preferredPushType: PushType.none,
+ order: order,
+ target: getTarget()
+ ),
+ to: [messenger.username],
+ pushType: .none
+ )
+ }
+
+ @discardableResult
+ @JobQueueActor public func sendMagicPacketMessage(
+ messageSubtype: String? = nil,
+ text: String,
+ metadata: Document = [:],
+ preferredPushType: PushType
+ ) async throws -> AnyChatMessage? {
+ let order = try await getNextLocalOrder()
+ return try await self._sendMessage(
+ SingleCypherMessage(
+ messageType: .magic,
+ messageSubtype: messageSubtype,
+ text: text,
+ metadata: metadata,
+ destructionTimer: nil,
+ sentDate: Date(),
+ preferredPushType: PushType.none,
+ order: order,
+ target: getTarget()
+ ),
+ to: conversation.members,
+ pushType: .none
+ )
+ }
+
+ @discardableResult
+ @JobQueueActor public func createDeviceLocalMessage(
+ messageType: CypherMessageType,
+ messageSubtype: String? = nil,
+ text: String,
+ metadata: Document = [:]
+ ) async throws -> AnyChatMessage? {
+ let order = try await getNextLocalOrder()
+ let target = await getTarget()
+ guard let message = try await _saveMessage(
+ senderId: messenger.deviceIdentityId,
+ order: order,
+ props: .init(
+ sending: SingleCypherMessage(
+ messageType: messageType,
+ messageSubtype: messageSubtype,
+ text: text,
+ metadata: metadata,
+ destructionTimer: nil,
+ sentDate: Date(),
+ preferredPushType: PushType.none,
+ order: order,
+ target: target
+ ),
+ senderUser: messenger.username,
+ senderDeviceId: messenger.deviceId
+ )
+ ) else {
+ return nil
+ }
+
+ return AnyChatMessage(
+ target: target,
+ messenger: messenger,
+ raw: message
+ )
+ }
}
diff --git a/Sources/CypherMessaging/Conversations/SingleCypherMessage.swift b/Sources/CypherMessaging/Conversations/SingleCypherMessage.swift
index 4785d80..ecc9ed5 100644
--- a/Sources/CypherMessaging/Conversations/SingleCypherMessage.swift
+++ b/Sources/CypherMessaging/Conversations/SingleCypherMessage.swift
@@ -2,20 +2,72 @@ import BSON
import CypherProtocol
import Foundation
-public enum PushType: String, Codable {
- case none, call, message, contactRequest = "contactrequest", cancelCall = "cancelcall"
+public enum PushType: RawRepresentable, Codable, Sendable {
+ case none
+ case call
+ case message
+ case contactRequest
+ case cancelCall
+ case custom(String)
+
+ public init(rawValue: String) {
+ switch rawValue {
+ case "none":
+ self = .none
+ case "call":
+ self = .call
+ case "message":
+ self = .message
+ case "contact-request":
+ self = .contactRequest
+ case "cancel-call":
+ self = .cancelCall
+ default:
+ self = .custom(rawValue)
+ }
+ }
+
+ public var rawValue: String {
+ switch self {
+ case .none:
+ return "none"
+ case .call:
+ return "call"
+ case .message:
+ return "message"
+ case .contactRequest:
+ return "contact-request"
+ case .cancelCall:
+ return "cancel-call"
+ case .custom(let string):
+ return string
+ }
+ }
}
public enum CypherMessageType: String, Codable {
case text, media, magic
}
-@available(macOS 12, iOS 15, *)
-public enum TargetConversation {
+@available(macOS 10.15, iOS 13, *)
+public enum TargetConversation: Sendable, Equatable {
case currentUser
case otherUser(Username)
case groupChat(GroupChatId)
+ public static func ==(lhs: TargetConversation, rhs: TargetConversation) -> Bool {
+ switch (lhs, rhs) {
+ case (.currentUser, .currentUser):
+ return true
+ case (.groupChat(let lhs), .groupChat(let rhs)):
+ return lhs == rhs
+ case (.otherUser(let lhs), .otherUser(let rhs)):
+ return lhs == rhs
+ default:
+ return false
+ }
+ }
+
public func resolve(
in messenger: CypherMessenger
) async throws -> TargetConversation.Resolved {
@@ -37,19 +89,19 @@ public enum TargetConversation {
}
}
- public enum Resolved: AnyConversation, Identifiable {
+ public enum Resolved: AnyConversation, Identifiable, Sendable {
case privateChat(PrivateChat)
case groupChat(GroupChat)
case internalChat(InternalConversation)
init?(conversation: DecryptedModel, messenger: CypherMessenger) async {
- let members = conversation.members
+ let members = await conversation.members
let username = messenger.username
guard members.contains(username) else {
return nil
}
- let metadata = conversation.metadata
+ let metadata = await conversation.metadata
switch members.count {
case ..<0:
return nil
@@ -100,7 +152,7 @@ public enum TargetConversation {
public func getTarget() async -> TargetConversation {
switch self {
case .privateChat(let chat):
- return chat.getTarget()
+ return await chat.getTarget()
case .groupChat(let chat):
return await chat.getTarget()
case .internalChat(let chat):
@@ -129,7 +181,7 @@ public enum TargetConversation {
}
}
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
public struct ConversationTarget: Codable {
// Only the fields specified here are encoded
private enum CodingKeys: String, CodingKey {
@@ -159,8 +211,8 @@ public struct ConversationTarget: Codable {
}
}
-@available(macOS 12, iOS 15, *)
-public struct SingleCypherMessage: Codable {
+@available(macOS 10.15, iOS 13, *)
+public struct SingleCypherMessage: Codable, @unchecked Sendable {
// Only the fields specified here are encoded
private enum CodingKeys: String, CodingKey {
case messageType = "a"
diff --git a/Sources/CypherMessaging/EventHandler.swift b/Sources/CypherMessaging/EventHandler.swift
index e54324d..dcb0e62 100644
--- a/Sources/CypherMessaging/EventHandler.swift
+++ b/Sources/CypherMessaging/EventHandler.swift
@@ -6,7 +6,7 @@ public struct DeviceReference {
public let deviceId: DeviceId
}
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
public struct ReceivedMessageContext {
public let sender: DeviceReference
public let messenger: CypherMessenger
@@ -14,7 +14,7 @@ public struct ReceivedMessageContext {
public let conversation: TargetConversation.Resolved
}
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
public struct SentMessageContext {
public let recipients: Set
public let messenger: CypherMessenger
@@ -45,24 +45,26 @@ public struct ProcessMessageAction {
}
// TODO: Make this into a concrete type, so more events can be supported
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
public protocol CypherMessengerEventHandler {
- func onRekey(withUser: Username, deviceId: DeviceId, messenger: CypherMessenger) async throws
- func onDeviceRegisteryRequest(_ config: UserDeviceConfig, messenger: CypherMessenger) async throws
- func onReceiveMessage(_ message: ReceivedMessageContext) async throws -> ProcessMessageAction
- func onSendMessage(_ message: SentMessageContext) async throws -> SendMessageAction
- func createPrivateChatMetadata(withUser otherUser: Username, messenger: CypherMessenger) async throws -> Document
- func createContactMetadata(for username: Username, messenger: CypherMessenger) async throws -> Document
- func onMessageChange(_ message: AnyChatMessage)
- func onCreateContact(_ contact: Contact, messenger: CypherMessenger)
- func onUpdateContact(_ contact: Contact)
- func onCreateConversation(_ conversation: AnyConversation)
- func onUpdateConversation(_ conversation: AnyConversation)
- func onCreateChatMessage(_ conversation: AnyChatMessage)
- func onContactIdentityChange(username: Username, messenger: CypherMessenger)
- func onP2PClientOpen(_ client: P2PClient, messenger: CypherMessenger)
- func onP2PClientClose(messenger: CypherMessenger)
- func onRemoveContact(_ contact: Contact)
- func onRemoveChatMessage(_ message: AnyChatMessage)
- func onDeviceRegistery(_ deviceId: DeviceId, messenger: CypherMessenger) async throws
+ @MainActor func onRekey(withUser: Username, deviceId: DeviceId, messenger: CypherMessenger) async throws
+ @MainActor func onDeviceRegisteryRequest(_ config: UserDeviceConfig, messenger: CypherMessenger) async throws
+ @MainActor func onReceiveMessage(_ message: ReceivedMessageContext) async throws -> ProcessMessageAction
+ @MainActor func onSendMessage(_ message: SentMessageContext) async throws -> SendMessageAction
+ @MainActor func createPrivateChatMetadata(withUser otherUser: Username, messenger: CypherMessenger) async throws -> Document
+ @MainActor func createContactMetadata(for username: Username, messenger: CypherMessenger) async throws -> Document
+ @MainActor func onMessageChange(_ message: AnyChatMessage)
+ @MainActor func onCreateContact(_ contact: Contact, messenger: CypherMessenger)
+ @MainActor func onUpdateContact(_ contact: Contact)
+ @MainActor func onCreateConversation(_ conversation: AnyConversation)
+ @MainActor func onUpdateConversation(_ conversation: AnyConversation)
+ @MainActor func onCreateChatMessage(_ conversation: AnyChatMessage)
+ @MainActor func onContactIdentityChange(username: Username, messenger: CypherMessenger)
+ @MainActor func onP2PClientOpen(_ client: P2PClient, messenger: CypherMessenger)
+ @MainActor func onP2PClientClose(messenger: CypherMessenger)
+ @MainActor func onRemoveContact(_ contact: Contact)
+ @MainActor func onRemoveChatMessage(_ message: AnyChatMessage)
+ @MainActor func onDeviceRegistery(_ deviceId: DeviceId, messenger: CypherMessenger) async
+ @MainActor func onOtherUserDeviceRegistery(username: Username, deviceId: DeviceId, messenger: CypherMessenger)
+ @MainActor func onCustomConfigChange()
}
diff --git a/Sources/CypherMessaging/Export.swift b/Sources/CypherMessaging/Export.swift
index fb26d0f..eb5200f 100644
--- a/Sources/CypherMessaging/Export.swift
+++ b/Sources/CypherMessaging/Export.swift
@@ -2,4 +2,3 @@
@_exported import CypherProtocol
@_exported import Foundation
@_exported import BSON
-@_exported import _NIOConcurrency
diff --git a/Sources/CypherMessaging/Helpers.swift b/Sources/CypherMessaging/Helpers.swift
index 613cd3a..3df9193 100644
--- a/Sources/CypherMessaging/Helpers.swift
+++ b/Sources/CypherMessaging/Helpers.swift
@@ -1,7 +1,6 @@
import NIO
-import _NIOConcurrency
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
extension EventLoop {
public func executeAsync(_ block: @escaping @Sendable () async throws -> T) -> EventLoopFuture {
let promise = self.makePromise(of: T.self)
diff --git a/Sources/CypherMessaging/Jobs/CypherTask.swift b/Sources/CypherMessaging/Jobs/CypherTask.swift
index 2cf8ad3..5e1e2d9 100644
--- a/Sources/CypherMessaging/Jobs/CypherTask.swift
+++ b/Sources/CypherMessaging/Jobs/CypherTask.swift
@@ -1,6 +1,6 @@
import CypherProtocol
import Foundation
-import CryptoKit
+import Crypto
import BSON
import NIO
@@ -29,7 +29,7 @@ struct HandshakeMessageTask: Codable {
let messageId: String
}
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
struct CreateChatTask: Codable {
private enum CodingKeys: String, CodingKey {
case message = "a"
@@ -50,7 +50,7 @@ struct CreateChatTask: Codable {
let acceptedByOtherUser: Bool
}
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
struct AddContactTask: Codable {
private enum CodingKeys: String, CodingKey {
case message = "a"
@@ -65,7 +65,7 @@ struct AddContactTask: Codable {
let nickname: String
}
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
struct SendMessageTask: Codable {
private enum CodingKeys: String, CodingKey {
case message = "a"
@@ -90,15 +90,17 @@ struct ReceiveMessageTask: Codable {
case messageId = "b"
case sender = "c"
case deviceId = "d"
+ case createdAt = "e"
}
let message: RatchetedCypherMessage
let messageId: String
let sender: Username
let deviceId: DeviceId
+ let createdAt: Date?
}
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
struct SendMessageDeliveryStateChangeTask: Codable {
private enum CodingKeys: String, CodingKey {
case localId = "a"
@@ -115,37 +117,41 @@ struct SendMessageDeliveryStateChangeTask: Codable {
let newState: ChatMessageModel.DeliveryState
}
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
struct ReceiveMessageDeliveryStateChangeTask: Codable {
private enum CodingKeys: String, CodingKey {
case messageId = "a"
case sender = "b"
case deviceId = "c"
case newState = "d"
+ case receivedAt = "e"
}
let messageId: String
let sender: Username
let deviceId: DeviceId?
let newState: ChatMessageModel.DeliveryState
+ let receivedAt: Date
}
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
struct ReceiveMultiRecipientMessageTask: Codable {
private enum CodingKeys: String, CodingKey {
case message = "a"
case messageId = "b"
case sender = "c"
case deviceId = "d"
+ case createdAt = "e"
}
let message: MultiRecipientCypherMessage
let messageId: String
let sender: Username
let deviceId: DeviceId
+ let createdAt: Date?
}
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
struct SendMultiRecipientMessageTask: Codable {
private enum CodingKeys: String, CodingKey {
case message = "a"
@@ -207,7 +213,7 @@ public enum _CypherTaskConfig {
public static var sendMessageRetryMode: TaskRetryMode? = nil
}
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
enum CypherTask: Codable, StoredTask {
private enum CodingKeys: String, CodingKey {
case key = "a"
@@ -228,17 +234,21 @@ enum CypherTask: Codable, StoredTask {
return mode
}
- return .retryAfter(30, maxAttempts: 3)
+ return .retryAfter(30, maxAttempts: nil)
case .processMultiRecipientMessage, .processMessage:
return .always
case .receiveMessageDeliveryStateChangeTask:
- return .retryAfter(60, maxAttempts: 5)
+ // Message sent by other device, but same (our) user
+ // We don't know what message this is, yet
+ return .retryAfter(60, maxAttempts: 30)
}
}
- var requiresConnectivity: Bool {
+ func requiresConnectivity(on messenger: CypherMessenger) -> Bool {
switch self {
- case .sendMessage, .sendMultiRecipientMessage, .sendMessageDeliveryStateChangeTask:
+ case .sendMessage:
+ return !messenger.canBroadcastInMesh
+ case .sendMultiRecipientMessage, .sendMessageDeliveryStateChangeTask:
return true
case .processMessage, .processMultiRecipientMessage, .receiveMessageDeliveryStateChangeTask:
return false
@@ -342,9 +352,9 @@ enum CypherTask: Codable, StoredTask {
func onDelayed(on messenger: CypherMessenger) async throws {
switch self {
case .sendMessage(let task):
- _ = try await messenger._markMessage(byId: task.localId, as: .undelivered)
+ try await messenger._markMessage(byId: task.localId, as: .undelivered)
case .sendMultiRecipientMessage(let task):
- _ = try await messenger._markMessage(byId: task.localId, as: .undelivered)
+ try await messenger._markMessage(byId: task.localId, as: .undelivered)
case .processMessage, .processMultiRecipientMessage, .sendMessageDeliveryStateChangeTask, .receiveMessageDeliveryStateChangeTask:
()
}
@@ -362,7 +372,8 @@ enum CypherTask: Codable, StoredTask {
multiRecipientContainer: nil,
messageId: message.messageId,
sender: message.sender,
- senderDevice: message.deviceId
+ senderDevice: message.deviceId,
+ createdAt: message.createdAt
)
case .sendMultiRecipientMessage(let task):
debugLog("Sending message to multiple recipients", task.recipients)
@@ -372,7 +383,8 @@ enum CypherTask: Codable, StoredTask {
task.message,
messageId: task.messageId,
sender: task.sender,
- senderDevice: task.deviceId
+ senderDevice: task.deviceId,
+ createdAt: task.createdAt
)
case .sendMessageDeliveryStateChangeTask(let task):
let result = try await messenger._markMessage(byId: task.localId, as: task.newState)
@@ -402,20 +414,22 @@ enum CypherTask: Codable, StoredTask {
fatalError("TODO")
}
case .receiveMessageDeliveryStateChangeTask(let task):
- _ = try await messenger._markMessage(byRemoteId: task.messageId, updatedBy: task.sender, as: task.newState)
+ try await messenger._markMessage(byRemoteId: task.messageId, updatedBy: task.sender, as: task.newState)
}
}
}
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
enum TaskHelpers {
fileprivate static func writeMultiRecipientMessageTask(
task: SendMultiRecipientMessageTask,
messenger: CypherMessenger
) async throws {
+ assert(messenger.transport.supportsMultiRecipientMessages)
+
guard messenger.authenticated == .authenticated else {
debugLog("Not connected with the server")
- _ = try await messenger._markMessage(byId: task.localId, as: .undelivered)
+ try await messenger._markMessage(byId: task.localId, as: .undelivered)
throw CypherSDKError.offline
}
@@ -430,16 +444,16 @@ enum TaskHelpers {
let index = devices.count - i - 1
let device = devices[index]
- do {
- if let p2pTransport = try? await messenger.getEstablishedP2PConnection(
- with: device.username,
- deviceId: device.deviceId
- ) {
+ if let p2pTransport = try? await messenger.getEstablishedP2PConnection(
+ with: device.username,
+ deviceId: device.deviceId
+ ) {
+ do {
try await p2pTransport.sendMessage(task.message, messageId: task.messageId)
devices.remove(at: index)
+ } catch {
+ debugLog("Failed to send message over P2P connection", error)
}
- } catch {
- debugLog("Failed to send message over P2P connection", error)
}
}
@@ -457,12 +471,6 @@ enum TaskHelpers {
task: SendMessageTask,
messenger: CypherMessenger
) async throws {
- guard messenger.authenticated == .authenticated else {
- debugLog("Not connected with the server")
- _ = try await messenger._markMessage(byId: task.localId, as: .undelivered)
- throw CypherSDKError.offline
- }
-
// Fetch the identity
debugLog("Executing task: Send message")
@@ -482,21 +490,44 @@ enum TaskHelpers {
}
}
- try await messenger._writeWithRatchetEngine(ofUser: task.recipient, deviceId: task.recipientDeviceId) { ratchetEngine, rekeyState in
+ guard let device = try await messenger._fetchKnownDeviceIdentity(for: task.recipient, deviceId: task.recipientDeviceId) else {
+ // Nothing to do, device is not a member (anymore?)
+ return
+ }
+
+ try await device._writeWithRatchetEngine(messenger: messenger) { ratchetEngine, rekeyState in
let encodedMessage = try BSONEncoder().encode(task.message).makeData()
let ratchetMessage = try ratchetEngine.ratchetEncrypt(encodedMessage)
- let encryptedMessage = try await messenger._signRatchetMessage(ratchetMessage, rekey: rekeyState)
- try await messenger.transport.sendMessage(
- encryptedMessage,
- toUser: task.recipient,
- otherUserDeviceId: task.recipientDeviceId,
- pushType: task.pushType,
- messageId: task.messageId
- )
+ let encryptedMessage = try messenger._signRatchetMessage(ratchetMessage, rekey: rekeyState)
+ if messenger.isOnline {
+ try await messenger.transport.sendMessage(
+ encryptedMessage,
+ toUser: task.recipient,
+ otherUserDeviceId: task.recipientDeviceId,
+ pushType: task.pushType,
+ messageId: task.messageId
+ )
+ } else if messenger.canBroadcastInMesh, encodedMessage.count <= P2PClient.maximumMeshPacketSize {
+ try await messenger._writeMessageOverMesh(
+ encryptedMessage,
+ messageId: task.messageId,
+ to: Peer(
+ username: device.username,
+ deviceConfig: UserDeviceConfig(
+ deviceId: device.deviceId,
+ identity: device.identity,
+ publicKey: device.publicKey,
+ isMasterDevice: device.isMasterDevice
+ )
+ )
+ )
+
+ // Throw an error anyways, this packet may not arrive
+ throw CypherSDKError.offline
+ } else {
+ throw CypherSDKError.offline
+ }
}
-
- // Message may be a magic packet
- _ = try? await messenger._markMessage(byId: task.localId, as: .none)
}
}
diff --git a/Sources/CypherMessaging/Jobs/JobQueue.swift b/Sources/CypherMessaging/Jobs/JobQueue.swift
index e1987e5..69b768c 100644
--- a/Sources/CypherMessaging/Jobs/JobQueue.swift
+++ b/Sources/CypherMessaging/Jobs/JobQueue.swift
@@ -1,24 +1,17 @@
import BSON
import Crypto
import Foundation
-import SwiftUI
import NIO
-@globalActor final actor JobQueueActor {
- public static let shared = JobQueueActor()
-
- private init() {}
-}
-
-@available(macOS 12, iOS 15, *)
-final class JobQueue: ObservableObject {
+@available(macOS 10.15, iOS 13, *)
+final class JobQueue {
weak private(set) var messenger: CypherMessenger?
private let database: CypherMessengerStore
private let databaseEncryptionKey: SymmetricKey
- public private(set) var runningJobs = false
- public private(set) var hasOutstandingTasks = true
- private var pausing: EventLoopPromise?
- private var jobs: [DecryptedModel] {
+ @JobQueueActor public private(set) var runningJobs = false
+ @JobQueueActor public private(set) var hasOutstandingTasks = true
+ @JobQueueActor private var pausing: EventLoopPromise?
+ @JobQueueActor private var jobs: [_DecryptedModel] {
didSet {
markAsDone()
}
@@ -26,13 +19,18 @@ final class JobQueue: ObservableObject {
private let eventLoop: EventLoop
private static var taskDecoders = [TaskKey: TaskDecoder]()
- init(messenger: CypherMessenger, database: CypherMessengerStore, databaseEncryptionKey: SymmetricKey) async throws {
+ init(messenger: CypherMessenger, database: CypherMessengerStore, databaseEncryptionKey: SymmetricKey) {
self.messenger = messenger
self.eventLoop = messenger.eventLoop
self.database = database
self.databaseEncryptionKey = databaseEncryptionKey
- self.jobs = try await database.readJobs().asyncMap { job -> (Date, DecryptedModel) in
- let job = try await messenger.decrypt(job)
+ self.jobs = []
+ }
+
+ @JobQueueActor
+ func loadJobs() async throws {
+ self.jobs = try await database.readJobs().map { job -> (Date, _DecryptedModel) in
+ let job = try messenger!._cachelessDecrypt(job)
return (job.scheduledAt, job)
}.sorted { lhs, rhs in
lhs.0 < rhs.0
@@ -46,13 +44,13 @@ final class JobQueue: ObservableObject {
}
@JobQueueActor
- func cancelJob(_ job: DecryptedModel) async throws {
+ func cancelJob(_ job: _DecryptedModel) async throws {
// TODO: What if the job is cancelled while executing and succeeding?
try await dequeueJob(job)
}
@JobQueueActor
- func dequeueJob(_ job: DecryptedModel) async throws {
+ func dequeueJob(_ job: _DecryptedModel) async throws {
try await database.removeJob(job.encrypted)
for i in 0..(_ task: T) async throws {
+ @JobQueueActor
+ public func queueTask(_ task: T) async throws {
guard let messenger = self.messenger else {
throw CypherSDKError.appLocked
}
@@ -72,7 +71,7 @@ final class JobQueue: ObservableObject {
encryptionKey: databaseEncryptionKey
)
- let queuedJob = try await messenger.decrypt(job)
+ let queuedJob = try messenger._cachelessDecrypt(job)
self.jobs.append(queuedJob)
self.hasOutstandingTasks = true
try await database.createJob(job)
@@ -81,14 +80,60 @@ final class JobQueue: ObservableObject {
}
}
- fileprivate var isDoneNotifications = [EventLoopPromise]()
+ @JobQueueActor
+ public func queueTasks(_ tasks: [T]) async throws {
+ if tasks.isEmpty {
+ return
+ }
+
+ guard let messenger = self.messenger else {
+ throw CypherSDKError.appLocked
+ }
+
+ let jobs = try tasks.map { task in
+ try JobModel(
+ props: .init(task: task),
+ encryptionKey: databaseEncryptionKey
+ )
+ }
+
+ var queuedJobs = [_DecryptedModel]()
+
+ for job in jobs {
+ queuedJobs.append(try messenger._cachelessDecrypt(job))
+ }
+
+ do {
+ for job in jobs {
+ try await database.createJob(job)
+ }
+ } catch {
+ debugLog("Failed to queue all jobs of type \(T.self)")
+
+ for job in jobs {
+ try? await database.removeJob(job)
+ }
+
+ throw error
+ }
+
+ self.jobs.append(contentsOf: queuedJobs)
+ self.hasOutstandingTasks = true
+
+ if !self.runningJobs {
+ self.startRunningTasks()
+ }
+ }
+
+ @JobQueueActor fileprivate var isDoneNotifications = [EventLoopPromise]()
@JobQueueActor
- func awaitDoneProcessing() async throws -> SynchronisationResult {
-// if runningJobs {
-// return .busy
-// } else
- if hasOutstandingTasks, let messenger = messenger {
+ func awaitDoneProcessing(untilEmpty: Bool) async throws -> SynchronisationResult {
+ if hasOutstandingTasks, !jobs.isEmpty, let messenger = messenger {
+ if !untilEmpty, nextJob() == nil {
+ return .skipped
+ }
+
let promise = messenger.eventLoop.makePromise(of: Void.self)
self.isDoneNotifications.append(promise)
startRunningTasks()
@@ -99,19 +144,23 @@ final class JobQueue: ObservableObject {
}
}
+ @JobQueueActor
func markAsDone() {
- if !hasOutstandingTasks && !isDoneNotifications.isEmpty {
- for notification in isDoneNotifications {
- notification.succeed(())
- }
-
- isDoneNotifications = []
+ for notification in isDoneNotifications {
+ notification.succeed(())
}
+
+ isDoneNotifications = []
}
@JobQueueActor
func startRunningTasks() {
debugLog("Starting job queue")
+
+ if let messenger = messenger, !messenger.isOnline, !messenger.canBroadcastInMesh {
+ debugLog("App is offline, aborting")
+ return
+ }
if runningJobs {
debugLog("Job queue already running")
@@ -159,6 +208,9 @@ final class JobQueue: ObservableObject {
switch result {
case .success, .delayed, .failed(haltExecution: false):
return try await next()
+ case .waitingForDelays:
+ self.runningJobs = false
+ self.markAsDone()
case .failed(haltExecution: true):
for job in self.jobs {
let task: StoredTask
@@ -232,6 +284,7 @@ final class JobQueue: ObservableObject {
resume()
}
+ @JobQueueActor
public func pause() async throws {
let promise = eventLoop.makePromise(of: Void.self)
pausing = promise
@@ -244,25 +297,47 @@ final class JobQueue: ObservableObject {
public enum TaskResult {
case success, delayed, failed(haltExecution: Bool)
+ case waitingForDelays
}
-
+
@JobQueueActor
- private func runNextJob() async throws -> TaskResult {
+ private func nextJob() -> _DecryptedModel? {
debugLog("Available jobs", jobs.count)
var index = 0
let initialJob = jobs[0]
-
- if initialJob.props.isBackgroundTask, jobs.count > 1 {
+
+ findOtherJob: if (initialJob.props.isBackgroundTask && jobs.count > 1) || initialJob.delayedUntil != nil {
+ if let delayedUntil = initialJob.delayedUntil, delayedUntil <= Date() {
+ break findOtherJob
+ }
+
findBetterTask: for newIndex in 1.. Date() {
+ continue findBetterTask
+ }
+
index = newIndex
break findBetterTask
}
}
}
-
+
let job = jobs[index]
+
+ if let delayedUntil = job.delayedUntil, delayedUntil > Date() {
+ return nil
+ } else {
+ return job
+ }
+ }
+
+ @JobQueueActor
+ private func runNextJob() async throws -> TaskResult {
+ guard let job = nextJob() else {
+ return .waitingForDelays
+ }
debugLog("Running job", job.props)
@@ -290,7 +365,7 @@ final class JobQueue: ObservableObject {
throw CypherSDKError.appLocked
}
- if task.requiresConnectivity, messenger.transport.authenticated != .authenticated {
+ if task.requiresConnectivity(on: messenger), messenger.isOnline, messenger.authenticated != .authenticated {
debugLog("Job required connectivity, but app is offline")
throw CypherSDKError.offline
}
@@ -304,8 +379,8 @@ final class JobQueue: ObservableObject {
switch task.retryMode.raw {
case .retryAfter(let retryDelay, let maxAttempts):
- debugLog("Delaying task for an hour")
- try await job.delayExecution(retryDelay: retryDelay)
+ debugLog("Delaying task for \(retryDelay) seconds")
+ try job.delayExecution(retryDelay: retryDelay)
if let maxAttempts = maxAttempts, job.attempts >= maxAttempts {
try await self.cancelJob(job)
diff --git a/Sources/CypherMessaging/Jobs/StoredTask.swift b/Sources/CypherMessaging/Jobs/StoredTask.swift
index 2db2c19..b97bb61 100644
--- a/Sources/CypherMessaging/Jobs/StoredTask.swift
+++ b/Sources/CypherMessaging/Jobs/StoredTask.swift
@@ -2,19 +2,19 @@ import NIO
import BSON
import Foundation
-@available(macOS 12, iOS 15, *)
-public protocol StoredTask: Codable {
+@available(macOS 10.15, iOS 13, *)
+public protocol StoredTask: Codable, Sendable {
var key: TaskKey { get }
var isBackgroundTask: Bool { get }
var retryMode: TaskRetryMode { get }
var priority: TaskPriority { get }
- var requiresConnectivity: Bool { get }
+ func requiresConnectivity(on messenger: CypherMessenger) -> Bool
func execute(on messenger: CypherMessenger) async throws
func onDelayed(on messenger: CypherMessenger) async throws
}
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
typealias TaskDecoder = (Document) throws -> StoredTask
public struct TaskPriority {
diff --git a/Sources/CypherMessaging/Messenger.swift b/Sources/CypherMessaging/Messenger.swift
index 47125d0..83151da 100644
--- a/Sources/CypherMessaging/Messenger.swift
+++ b/Sources/CypherMessaging/Messenger.swift
@@ -1,20 +1,24 @@
-import BSON
-import Foundation
+@preconcurrency import BSON
+@preconcurrency import Foundation
import Crypto
+import TaskQueue
import NIO
import CypherProtocol
-public enum DeviceRegisteryMode: Int, Codable {
+public enum DeviceRegisteryMode: Int, Codable, Sendable {
case masterDevice, childDevice, unregistered
}
-@globalActor final actor CryptoActor {
- public static let shared = CryptoActor()
+@globalActor public final actor CypherTextKitActor {
+ public static let shared = CypherTextKitActor()
private init() {}
}
-internal struct _CypherMessengerConfig: Codable {
+public typealias CryptoActor = CypherTextKitActor
+typealias JobQueueActor = CypherTextKitActor
+
+internal struct _CypherMessengerConfig: Codable, Sendable {
private enum CodingKeys: String, CodingKey {
case databaseEncryptionKey = "a"
case deviceKeys = "b"
@@ -22,6 +26,7 @@ internal struct _CypherMessengerConfig: Codable {
case registeryMode = "d"
case custom = "e"
case deviceIdentityId = "f"
+ case lastKnownUserConfig = "g"
}
let databaseEncryptionKey: Data
@@ -30,14 +35,15 @@ internal struct _CypherMessengerConfig: Codable {
var registeryMode: DeviceRegisteryMode
var custom: Document
let deviceIdentityId: Int
+ var lastKnownUserConfig: UserConfig?
}
-enum RekeyState {
+enum RekeyState: Sendable {
case rekey, next
}
/// Provided by CypherMessenger to a factory (function) so that it can create a Transport Client to the app's servers
-public struct TransportCreationRequest {
+public struct TransportCreationRequest: Sendable {
public let username: Username
public let deviceId: DeviceId
public let userConfig: UserConfig
@@ -51,11 +57,16 @@ public struct TransportCreationRequest {
}
}
+public struct ContactCard: Codable, Sendable {
+ public let username: Username
+ public let config: UserConfig
+}
+
/// The representation of a P2PSession with another device
///
/// Peer-to-peer sessions are used to communicate directly with another device
/// They can rely on a custom otransport implementation, leveraging network or platform features
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
internal struct P2PSession {
/// The connected device's known username
/// Multiple devics may belong to the same username
@@ -76,8 +87,21 @@ internal struct P2PSession {
/// The transport client used by P2PClient
let transport: P2PTransportClient
- init(
- deviceIdentity: DecryptedModel,
+ @CryptoActor init(
+ peer: Peer,
+ transport: P2PTransportClient,
+ client: P2PClient
+ ) {
+ self.username = peer.username
+ self.deviceId = peer.deviceId
+ self.publicKey = peer.publicKey
+ self.identity = peer.identity
+ self.transport = transport
+ self.client = client
+ }
+
+ @CryptoActor init(
+ deviceIdentity: _DecryptedModel,
transport: P2PTransportClient,
client: P2PClient
) {
@@ -91,10 +115,10 @@ internal struct P2PSession {
}
/// This actor stores all mutable shared state for a CypherMessenger instance
-fileprivate final actor CypherMessengerActor {
- var config: _CypherMessengerConfig
- var p2pSessions = [P2PSession]()
- var appPassword: String
+fileprivate final class CypherMessengerActor {
+ @CypherTextKitActor var config: _CypherMessengerConfig
+ @CypherTextKitActor var p2pSessions = [P2PSession]()
+ @CypherTextKitActor var appPassword: String
let cachedStore: _CypherMessengerStoreCache
internal init(config: _CypherMessengerConfig, cachedStore: _CypherMessengerStoreCache, appPassword: String) {
@@ -104,7 +128,7 @@ fileprivate final actor CypherMessengerActor {
self.cachedStore = cachedStore
}
- func updateConfig(_ run: (inout _CypherMessengerConfig) -> ()) async throws {
+ @CypherTextKitActor func updateConfig(_ run: @Sendable (inout _CypherMessengerConfig) -> ()) async throws {
let salt = try await self.cachedStore.readLocalDeviceSalt()
let appEncryptionKey = CypherMessenger.formAppEncryptionKey(appPassword: appPassword, salt: salt)
run(&config)
@@ -112,17 +136,17 @@ fileprivate final actor CypherMessengerActor {
try await self.cachedStore.writeLocalDeviceConfig(encryptedConfig.makeData())
}
- func changeAppPassword(to appPassword: String) async throws {
+ @CypherTextKitActor func changeAppPassword(to appPassword: String) async throws {
let salt = try await self.cachedStore.readLocalDeviceSalt()
let appEncryptionKey = CypherMessenger.formAppEncryptionKey(appPassword: appPassword, salt: salt)
let encryptedConfig = try Encrypted(self.config, encryptionKey: appEncryptionKey)
- let data = try BSONEncoder().encode(encryptedConfig).makeData()
+ let data = encryptedConfig.makeData()
try await self.cachedStore.writeLocalDeviceConfig(data)
self.appPassword = appPassword
}
- var isSetupCompleted: Bool {
+ @CypherTextKitActor var isSetupCompleted: Bool {
switch config.registeryMode {
case .unregistered:
return false
@@ -131,11 +155,11 @@ fileprivate final actor CypherMessengerActor {
}
}
- public func sign(_ value: T) throws -> Signed {
+ @CypherTextKitActor public func sign(_ value: T) throws -> Signed {
try Signed(value, signedBy: config.deviceKeys.identity)
}
- public func writeCustomConfig(_ custom: Document) async throws {
+ @CypherTextKitActor public func writeCustomConfig(_ custom: Document) async throws {
let salt = try await self.cachedStore.readLocalDeviceSalt()
let appEncryptionKey = CypherMessenger.formAppEncryptionKey(appPassword: self.appPassword, salt: salt)
var newConfig = self.config
@@ -145,7 +169,7 @@ fileprivate final actor CypherMessengerActor {
self.config = newConfig
}
- func closeP2PConnection(_ connection: P2PTransportClient) async {
+ @CypherTextKitActor func closeP2PConnection(_ connection: P2PTransportClient) async {
debugLog("Removing P2P session from active pool")
guard let index = p2pSessions.firstIndex(where: {
$0.transport === connection
@@ -157,7 +181,7 @@ fileprivate final actor CypherMessengerActor {
return await session.client.disconnect()
}
- func registerSession(_ session: P2PSession) {
+ @CypherTextKitActor func registerSession(_ session: P2PSession) {
p2pSessions.append(session)
}
}
@@ -166,10 +190,32 @@ fileprivate final actor CypherMessengerActor {
/// CypherMessenger is responsible for orchestrating end-to-end encrypted communication of any kind.
///
/// CypherMessenger can be created as a singleton, but multiple clients in the same process is supported.
-@available(macOS 12, iOS 15, *)
-public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportClientDelegate {
+@available(macOS 10.15, iOS 13, *)
+public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportClientDelegate, P2PTransportFactoryDelegate, @unchecked Sendable {
+ @CypherTextKitActor public func createLocalDeviceAdvertisement() async throws -> P2PAdvertisement {
+ let advertisement = P2PAdvertisement.Advertisement(
+ origin: Peer(
+ username: username,
+ deviceConfig: UserDeviceConfig(
+ deviceId: deviceId,
+ identity: state.config.deviceKeys.identity.publicKey,
+ publicKey: state.config.deviceKeys.privateKey.publicKey,
+ isMasterDevice: state.config.registeryMode == .masterDevice
+ )
+ )
+ )
+
+ return try await P2PAdvertisement(advertisement: sign(advertisement))
+ }
+
internal let eventLoop: EventLoop
+
+ /// A queue of stored jobs, executing signals in sequence. Mainly used for messaging traffic which requires cryptographic sequentiality.
private(set) var jobQueue: JobQueue!
+
+ /// A thread which executes higher-level operations in sequence. Does not load/store unfinished tasks, but might inject tasks into `jobQueue`
+ let taskQueue = TaskQueue()
+
private var inactiveP2PSessionsTimeout: Int? = 30
internal let deviceIdentityId: Int
fileprivate let state: CypherMessengerActor
@@ -178,10 +224,24 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC
internal let cachedStore: _CypherMessengerStoreCache
internal let databaseEncryptionKey: SymmetricKey
+ /// All rediscovered usernames during this session
+ /// Will reset next boot
+ @CryptoActor internal var rediscoveredUsernames = Set()
+
/// The TransportClient implementation provided to CypherTextKit for this CypherMessenger to communicate through
public let transport: CypherServerTransportClient
+ public var isOnline: Bool { transport.isConnected }
public var authenticated: AuthenticationState { transport.authenticated }
+ public var canBroadcastInMesh: Bool {
+ for factory in p2pFactories {
+ if factory.isMeshEnabled {
+ return true
+ }
+ }
+
+ return false
+ }
/// The username that this device is registered to
public let username: Username
@@ -211,8 +271,9 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC
cachedStore: cachedStore,
appPassword: appPassword
)
- self.jobQueue = try await JobQueue(messenger: self, database: self.cachedStore, databaseEncryptionKey: self.databaseEncryptionKey)
+ self.jobQueue = JobQueue(messenger: self, database: self.cachedStore, databaseEncryptionKey: self.databaseEncryptionKey)
+ try await jobQueue.loadJobs()
try await self.transport.setDelegate(to: self)
if transport.authenticated == .unauthenticated {
@@ -223,6 +284,31 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC
}
await jobQueue.resume()
+
+ for factory in p2pFactories {
+ factory.delegate = self
+ }
+
+ // Ensure this device knows if it's registered or not
+ if config.registeryMode == .unregistered {
+ Task { @CypherTextKitActor in
+ let bundle = try await transport.readKeyBundle(forUsername: self.username)
+ try await self.updateConfig { appConfig in
+ appConfig.lastKnownUserConfig = bundle
+ }
+ for device in try bundle.readAndValidateDevices() {
+ if device.deviceId == self.deviceId {
+ try await self.updateConfig { $0.registeryMode = device.isMasterDevice ? .masterDevice : .childDevice }
+ return
+ }
+ }
+ }
+ } else if transport.isConnected, config.lastKnownUserConfig == nil {
+ let bundle = try await transport.readKeyBundle(forUsername: config.username)
+ try await self.updateConfig { appConfig in
+ appConfig.lastKnownUserConfig = bundle
+ }
+ }
}
/// Initializes and registers a new messenger. This generates a new private key.
@@ -252,13 +338,19 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC
Data(bytes: buffer.baseAddress!, count: buffer.count)
}
- let config = _CypherMessengerConfig(
+ let deviceKeys = DevicePrivateKeys(deviceId: deviceId)
+ let userConfig = try UserConfig(
+ mainDevice: deviceKeys,
+ otherDevices: []
+ )
+ var config = _CypherMessengerConfig(
databaseEncryptionKey: databaseEncryptionKeyData,
- deviceKeys: DevicePrivateKeys(deviceId: deviceId),
+ deviceKeys: deviceKeys,
username: username,
registeryMode: .unregistered,
custom: [:],
- deviceIdentityId: .random(in: 1 ..< .max)
+ deviceIdentityId: .random(in: 1 ..< .max),
+ lastKnownUserConfig: userConfig
)
let salt = try await database.readLocalDeviceSalt()
@@ -267,11 +359,6 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC
salt: salt
)
- let userConfig = try UserConfig(
- mainDevice: config.deviceKeys,
- otherDevices: []
- )
-
var encryptedConfig = try Encrypted(config, encryptionKey: appEncryptionKey)
let transportRequest = TransportCreationRequest(
@@ -311,12 +398,18 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC
try await transport.requestDeviceRegistery(metadata)
return messenger
} catch {
- var config = config
config.registeryMode = .masterDevice
- encryptedConfig = try Encrypted(config, encryptionKey: appEncryptionKey)
+ encryptedConfig = try Encrypted(config, encryptionKey: appEncryptionKey)
try await database.writeLocalDeviceConfig(encryptedConfig.makeData())
- try await transport.publishKeyBundle(userConfig)
+
+ guard transport.isConnected || transport.supportsDelayedRegistration else {
+ throw CypherSDKError.cannotRegisterDeviceConfig
+ }
+
+ if transport.isConnected {
+ try await transport.publishKeyBundle(userConfig)
+ }
return try await CypherMessenger(
appPassword: appPassword,
@@ -419,14 +512,19 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC
let box = try AES.GCM.SealedBox(combined: data)
let encryptedConfig = Encrypted<_CypherMessengerConfig>(representing: box)
let config = try encryptedConfig.decrypt(using: encryptionKey)
+
let transportRequest = try TransportCreationRequest(
username: config.username,
deviceId: config.deviceKeys.deviceId,
- userConfig: UserConfig(mainDevice: config.deviceKeys, otherDevices: []),
+ userConfig: config.lastKnownUserConfig ?? UserConfig(
+ mainDevice: config.deviceKeys,
+ otherDevices: []
+ ),
signingIdentity: config.deviceKeys.identity
)
let transport = try await createTransport(transportRequest)
+
return try await CypherMessenger(
appPassword: appPassword,
eventHandler: eventHandler,
@@ -452,14 +550,65 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC
let data = try await self.cachedStore.readLocalDeviceConfig()
let box = try AES.GCM.SealedBox(combined: data)
let config = Encrypted<_CypherMessengerConfig>(representing: box)
- _ = try config.decrypt(using: appEncryptionKey)
+ try config.canDecrypt(using: appEncryptionKey)
return true
} catch {
return false
}
}
- internal func updateConfig(_ run: (inout _CypherMessengerConfig) -> ()) async throws {
+ @CryptoActor public func importContactCard(
+ _ card: ContactCard
+ ) async throws {
+ let knownDevices = try await _fetchKnownDeviceIdentities(for: card.username)
+ guard knownDevices.isEmpty else {
+ throw CypherSDKError.cannotImportExistingContact
+ }
+
+ try await _processDeviceConfig(
+ card.config,
+ forUername: card.username,
+ knownDevices: []
+ )
+ }
+
+ @CypherTextKitActor public func createContactCard() async throws -> ContactCard {
+ if state.config.registeryMode == .masterDevice {
+ let otherDevices = try await _fetchKnownDeviceIdentities(for: self.username)
+
+ return ContactCard(
+ username: self.username,
+ config: try UserConfig(
+ mainDevice: state.config.deviceKeys,
+ otherDevices: otherDevices.map { device in
+ return UserDeviceConfig(
+ deviceId: device.deviceId,
+ identity: device.identity,
+ publicKey: device.publicKey,
+ isMasterDevice: false
+ )
+ }
+ )
+ )
+ } else if let lastKnownUserConfig = state.config.lastKnownUserConfig {
+ return ContactCard(
+ username: self.username,
+ config: lastKnownUserConfig
+ )
+ } else {
+ let bundle = try await transport.readKeyBundle(forUsername: self.username)
+ try await self.updateConfig { appConfig in
+ appConfig.lastKnownUserConfig = bundle
+ }
+
+ return ContactCard(
+ username: self.username,
+ config: bundle
+ )
+ }
+ }
+
+ internal func updateConfig(_ run: @Sendable (inout _CypherMessengerConfig) -> ()) async throws {
try await state.updateConfig(run)
}
@@ -476,9 +625,14 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC
/// The client is responsible for transporting the UserDeviceConfig to another device, for example through a QR code.
/// The master device must then call `addDevice` with this request.
public func createDeviceRegisteryRequest(isMasterDevice: Bool = false) async throws -> UserDeviceConfig? {
+ guard await registeryMode == .unregistered else {
+ // Cannot register, already registered
+ return nil
+ }
+
if try await isRegisteredOnline() {
- try await updateConfig { config in
- config.registeryMode = .childDevice
+ try await updateConfig { appConfig in
+ appConfig.registeryMode = .childDevice
}
return nil
}
@@ -491,6 +645,10 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC
)
}
+ @CypherTextKitActor public var isPasswordProtected: Bool {
+ !state.appPassword.isEmpty
+ }
+
public func checkSetupCompleted() async -> Bool {
await state.isSetupCompleted
}
@@ -503,61 +661,64 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC
/// Mainly used by test suties, to ensure all outstanding work is finished.
/// This is handy when you're simulating communication, but want to delay assertions until this CypherMessenger has processed all outstanding work.
- public func processJobQueue() async throws -> SynchronisationResult {
- try await jobQueue.awaitDoneProcessing()
+ public func processJobQueue(untilEmpty: Bool = false) async throws -> SynchronisationResult {
+ try await jobQueue.awaitDoneProcessing(untilEmpty: untilEmpty)
+ }
+
+ public func resumeJobQueue() async {
+ await jobQueue.resume()
}
// TODO: Make internal
/// Internal API that CypherMessenger uses to read information from Transport Clients
public func receiveServerEvent(_ event: CypherServerEvent) async throws {
switch event.raw {
- case let .multiRecipientMessageSent(message, id: messageId, byUser: sender, deviceId: deviceId):
- // guard let key = message.keys.first(where: {
- // $0.user == self.config.username && $0.deviceId == self.config.deviceKeys.deviceId
- // }) else {
- // return
- // }
-
+ case let .multiRecipientMessageSent(message, id: messageId, byUser: sender, deviceId: deviceId, createdAt: createdAt):
return try await self.jobQueue.queueTask(
CypherTask.processMultiRecipientMessage(
ReceiveMultiRecipientMessageTask(
message: message,
messageId: messageId,
sender: sender,
- deviceId: deviceId
+ deviceId: deviceId,
+ createdAt: createdAt
)
)
)
- case let .messageSent(message, id: messageId, byUser: sender, deviceId: deviceId):
+ case let .messageSent(message, id: messageId, byUser: sender, deviceId: deviceId, createdAt: createdAt):
+ // TODO: Server- or even origin-defined creation date
return try await self.jobQueue.queueTask(
CypherTask.processMessage(
ReceiveMessageTask(
message: message,
messageId: messageId,
sender: sender,
- deviceId: deviceId
+ deviceId: deviceId,
+ createdAt: createdAt
)
)
)
- case let .messageDisplayed(by: recipient, deviceId: deviceId, id: messageId):
+ case let .messageDisplayed(by: recipient, deviceId: deviceId, id: messageId, receivedAt: receivedAt):
return try await self.jobQueue.queueTask(
CypherTask.receiveMessageDeliveryStateChangeTask(
ReceiveMessageDeliveryStateChangeTask(
messageId: messageId,
sender: recipient,
deviceId: deviceId,
- newState: .read
+ newState: .read,
+ receivedAt: receivedAt
)
)
)
- case let .messageReceived(by: recipient, deviceId: deviceId, id: messageId):
+ case let .messageReceived(by: recipient, deviceId: deviceId, id: messageId, receivedAt: receivedAt):
return try await self.jobQueue.queueTask(
CypherTask.receiveMessageDeliveryStateChangeTask(
ReceiveMessageDeliveryStateChangeTask(
messageId: messageId,
sender: recipient,
deviceId: deviceId,
- newState: .received
+ newState: .received,
+ receivedAt: receivedAt
)
)
)
@@ -577,7 +738,7 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC
}
/// Adds a new device to this user's devices. This device can from now on receive all messages, and communicate in name of this user.
- public func addDevice(_ deviceConfig: UserDeviceConfig) async throws {
+ @MainActor public func addDevice(_ deviceConfig: UserDeviceConfig) async throws {
var config = try await transport.readKeyBundle(forUsername: self.username)
guard await config.identity.data == state.config.deviceKeys.identity.publicKey.data else {
throw CypherSDKError.corruptUserConfig
@@ -585,9 +746,13 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC
try config.addDeviceConfig(deviceConfig, signedWith: await state.config.deviceKeys.identity)
try await self.transport.publishKeyBundle(config)
+ let uploadedConfig = config
+ try await self.updateConfig { appConfig in
+ appConfig.lastKnownUserConfig = uploadedConfig
+ }
let internalConversation = try await self.getInternalConversation()
let metadata = try BSONEncoder().encode(deviceConfig)
- _ = try await self._createDeviceIdentity(
+ try await self._createDeviceIdentity(
from: deviceConfig,
forUsername: self.username
)
@@ -603,11 +768,42 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC
try await internalConversation.sendInternalMessage(message)
- for contact in try await self.listContacts() {
+ for contact in try await listContacts() {
try await _writeMessage(message, to: contact.username)
}
+ }
+
+ @CypherTextKitActor func _writeMessageOverMesh(
+ _ message: RatchetedCypherMessage,
+ messageId: String,
+ to recipient: Peer
+ ) async throws {
+ let origin = Peer(
+ username: username,
+ deviceConfig: UserDeviceConfig(
+ deviceId: deviceId,
+ identity: state.config.deviceKeys.identity.publicKey,
+ publicKey: state.config.deviceKeys.privateKey.publicKey,
+ isMasterDevice: state.config.registeryMode == .masterDevice
+ )
+ )
- try await eventHandler.onDeviceRegistery(deviceConfig.deviceId, messenger: self)
+ let broadcastMessage = P2PBroadcast.Message(
+ origin: origin,
+ target: recipient,
+ messageId: messageId,
+ createdAt: Date(),
+ payload: message
+ )
+ let signedBroadcastMessage = try await sign(broadcastMessage)
+ let broadcast = P2PBroadcast(hops: 16, value: signedBroadcastMessage)
+
+ for client in listOpenP2PConnections() where client.isMeshEnabled {
+ Task {
+ // Ignore errors
+ try await client.sendMessage(.broadcast(broadcast))
+ }
+ }
}
func _writeMessage(
@@ -630,10 +826,10 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC
}
}
- func _withCreatedMultiRecipientMessage(
+ @CryptoActor func _withCreatedMultiRecipientMessage(
encrypting message: CypherMessage,
- forDevices devices: [DecryptedModel],
- run: (MultiRecipientCypherMessage) async throws -> T
+ forDevices devices: [_DecryptedModel],
+ run: @Sendable (MultiRecipientCypherMessage) async throws -> T
) async throws -> T {
let key = SymmetricKey(size: .bits256)
let keyData = key.withUnsafeBytes { buffer in
@@ -644,13 +840,13 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC
let encryptedMessage = try AES.GCM.seal(messageData, using: key).combined!
var keyMessages = [MultiRecipientCypherMessage.ContainerKey]()
- var rekeyDevices = [DecryptedModel]()
+ var rekeyDevices = [_DecryptedModel]()
for device in devices {
let keyMessage = try await device._writeWithRatchetEngine(messenger: self) { ratchetEngine, rekeyState -> MultiRecipientCypherMessage.ContainerKey in
let ratchetMessage = try ratchetEngine.ratchetEncrypt(keyData)
- return try await MultiRecipientCypherMessage.ContainerKey(
+ return try MultiRecipientCypherMessage.ContainerKey(
user: device.props.username,
deviceId: device.props.deviceId,
message: self._signRatchetMessage(
@@ -670,7 +866,7 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC
do {
let message = try MultiRecipientCypherMessage(
encryptedMessage: encryptedMessage,
- signWith: await state.config.deviceKeys.identity,
+ signWith: state.config.deviceKeys.identity,
keys: keyMessages
)
@@ -679,38 +875,148 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC
for device in rekeyDevices {
// Device was instantiated with a rekey, but the message wasn't sent
// So to prevent any further confusion & issues, just reset this
- try await device.updateDoubleRatchetState(to: nil)
+ try device.updateDoubleRatchetState(to: nil)
}
throw error
}
}
- actor ModelCache {
+ @MainActor final class ModelCache {
private var cache = [UUID: Weak]()
- func getModel(ofType: M.Type, forId id: UUID) -> DecryptedModel? {
+ @MainActor func getModel(ofType: M.Type, forId id: UUID) -> DecryptedModel? {
cache[id]?.object as? DecryptedModel
}
- func addModel(_ model: DecryptedModel, forId id: UUID) {
+ @MainActor func addModel(_ model: DecryptedModel, forId id: UUID) {
cache[id] = Weak(object: model)
}
}
- private let cache = ModelCache()
+ @CryptoActor final class InternalModelCache {
+ private var cache = [UUID: Weak]()
+ @CryptoActor func getModel(ofType: M.Type, forId id: UUID) -> _DecryptedModel? {
+ cache[id]?.object as? _DecryptedModel
+ }
+
+ @CryptoActor func addModel(_ model: _DecryptedModel, forId id: UUID) {
+ cache[id] = Weak(object: model)
+ }
+ }
+
+ @MainActor private let cache = ModelCache()
+ @CryptoActor private let _cache = InternalModelCache()
+
+ /// Decrypts a model as provided by the database
+ /// It is critical to call this method for decryption for stability reasons, as CypherMessenger prevents duplicate representations of a Model from existing at the same time.
+ @CryptoActor internal func _cachelessDecrypt(_ model: M) throws -> _DecryptedModel {
+ try _DecryptedModel(model: model, encryptionKey: databaseEncryptionKey)
+ }
+
+ /// Decrypts a model as provided by the database
+ /// It is critical to call this method for decryption for stability reasons, as CypherMessenger prevents duplicate representations of a Model from existing at the same time.
+ @CryptoActor func _decrypt(_ model: M) throws -> _DecryptedModel {
+ if let decrypted = _cache.getModel(ofType: M.self, forId: model.id) {
+ return decrypted
+ }
+
+ let decrypted = try _DecryptedModel(model: model, encryptionKey: databaseEncryptionKey)
+ _cache.addModel(decrypted, forId: model.id)
+ return decrypted
+ }
/// Decrypts a model as provided by the database
/// It is critical to call this method for decryption for stability reasons, as CypherMessenger prevents duplicate representations of a Model from existing at the same time.
- @CryptoActor public func decrypt(_ model: M) async throws -> DecryptedModel {
- if let decrypted = await cache.getModel(ofType: M.self, forId: model.id) {
+ @MainActor public func decrypt(_ model: M) throws -> DecryptedModel {
+ if let decrypted = cache.getModel(ofType: M.self, forId: model.id) {
return decrypted
}
- let decrypted = try await DecryptedModel(model: model, encryptionKey: databaseEncryptionKey)
- await cache.addModel(decrypted, forId: model.id)
+ let decrypted = try DecryptedModel(model: model, encryptionKey: databaseEncryptionKey)
+ cache.addModel(decrypted, forId: model.id)
return decrypted
}
+ @CryptoActor public func decryptDirectMessageNotification(
+ _ message: RatchetedCypherMessage,
+ senderUser username: Username,
+ senderDeviceId deviceId: DeviceId
+ ) async throws -> String? {
+ guard
+ !message.rekey,
+ let deviceIdentity = try await self._fetchKnownDeviceIdentity(for: username, deviceId: deviceId),
+ let ratchetState = deviceIdentity.doubleRatchet
+ else {
+ return nil
+ }
+
+ let message = try message.readAndValidate(usingIdentity: deviceIdentity.identity)
+ var ratchet = DoubleRatchetHKDF(state: ratchetState, configuration: doubleRatchetConfig)
+ guard case .success(let data) = try ratchet.ratchetDecrypt(message) else {
+ return nil
+ }
+
+ // Don't save the ratchet state
+ let decryptedMessage = try BSONDecoder().decode(CypherMessage.self, from: Document(data: data))
+
+ switch decryptedMessage.box {
+ case .array(let list):
+ return list.first { $0.messageType == .text }?.text
+ case .single(let message):
+ if message.messageType != .text || message.text.isEmpty {
+ return nil
+ }
+
+ return message.text
+ }
+ }
+
+ @CryptoActor public func decryptMultiRecipientMessageNotification(
+ _ message: MultiRecipientCypherMessage,
+ senderUser username: Username,
+ senderDeviceId deviceId: DeviceId
+ ) async throws -> String? {
+ guard
+ let foundKey = message.keys.first(where: { key in
+ key.deviceId == self.deviceId && key.user == self.username
+ }),
+ let deviceIdentity = try await self._fetchKnownDeviceIdentity(for: username, deviceId: deviceId),
+ let ratchetState = deviceIdentity.doubleRatchet
+ else {
+ return nil
+ }
+
+ let keyMessage = try foundKey.message.readAndValidate(usingIdentity: deviceIdentity.identity)
+ var ratchet = DoubleRatchetHKDF(state: ratchetState, configuration: doubleRatchetConfig)
+ guard case .success(let keyData) = try ratchet.ratchetDecrypt(keyMessage) else {
+ return nil
+ }
+
+ guard keyData.count == 32 else {
+ throw CypherSDKError.invalidMultiRecipientKey
+ }
+
+ let key = SymmetricKey(data: keyData)
+
+ // Don't save the ratchet state
+ let decryptedMessage = try message.container.readAndValidate(
+ type: CypherMessage.self,
+ usingIdentity: deviceIdentity.props.identity,
+ decryptingWith: key
+ )
+
+ switch decryptedMessage.box {
+ case .array(let list):
+ return list.first { $0.messageType == .text }?.text
+ case .single(let message):
+ if message.messageType != .text || message.text.isEmpty {
+ return nil
+ }
+
+ return message.text
+ }
+ }
+
/// Encrypts a file for storage on the disk. Can be used for any personal information, or attachments received.
public func encryptLocalFile(_ data: Data) throws -> AES.GCM.SealedBox {
try AES.GCM.seal(data, using: databaseEncryptionKey)
@@ -723,19 +1029,19 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC
/// Signs a message using this device's Private Key, allowing another client to verify the message's origin.
@CryptoActor public func sign(_ value: T) async throws -> Signed {
- try await state.sign(value)
+ try state.sign(value)
}
- @CryptoActor func _signRatchetMessage(_ message: RatchetMessage, rekey: RekeyState) async throws -> RatchetedCypherMessage {
+ @CryptoActor func _signRatchetMessage(_ message: RatchetMessage, rekey: RekeyState) throws -> RatchetedCypherMessage {
return try RatchetedCypherMessage(
message: message,
- signWith: await state.config.deviceKeys.identity,
+ signWith: state.config.deviceKeys.identity,
rekey: rekey == .rekey
)
}
- @CryptoActor fileprivate func _formSharedSecret(with publicKey: PublicKey) async throws -> SharedSecret {
- try await state.config.deviceKeys.privateKey.sharedSecretFromKeyAgreement(
+ @CryptoActor internal func _formSharedSecret(with publicKey: PublicKey) throws -> SharedSecret {
+ try state.config.deviceKeys.privateKey.sharedSecretFromKeyAgreement(
with: publicKey
)
}
@@ -763,42 +1069,51 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC
/// Writes a new custom configuration file to this device
public func writeCustomConfig(_ custom: Document) async throws {
try await state.writeCustomConfig(custom)
+ await eventHandler.onCustomConfigChange()
}
func _writeWithRatchetEngine(
ofUser username: Username,
deviceId: DeviceId,
- run: @escaping (inout DoubleRatchetHKDF, RekeyState) async throws -> T
+ run: @Sendable (inout DoubleRatchetHKDF, RekeyState) async throws -> T
) async throws -> T {
let device = try await self._fetchDeviceIdentity(for: username, deviceId: deviceId)
return try await device._writeWithRatchetEngine(messenger: self, run: run)
}
+ @CypherTextKitActor public func p2pTransportDiscovered(_ connection: P2PTransportClient, remotePeer: Peer) async throws {
+ connection.delegate = self
+ try await state.registerSession(
+ P2PSession(
+ peer: remotePeer,
+ transport: connection,
+ client: P2PClient(
+ client: connection,
+ messenger: self,
+ closeInactiveAfter: self.inactiveP2PSessionsTimeout
+ )
+ )
+ )
+ }
+
// TODO: Make internal
/// An internal implementation that allows CypherMessenger to respond to information received by a P2PConnection
- public func p2pConnection(
- _ connection: P2PTransportClient,
- closedWithOptions: Set
- ) async throws {
+ public func p2pConnectionClosed(_ connection: P2PTransportClient) async throws {
debugLog("P2P session disconnecting")
- if closedWithOptions.contains(.reconnnectPossible) {
- do {
- return try await connection.reconnect()
- } catch {
- debugLog("Reconnecting P2P connection failed")
- return await state.closeP2PConnection(connection)
- }
- }
-
return await state.closeP2PConnection(connection)
}
- internal func _processP2PMessage(
+ @CryptoActor internal func _processP2PMessage(
_ message: SingleCypherMessage,
remoteMessageId: String,
- sender device: DecryptedModel
+ sender device: _DecryptedModel
) async throws {
+ guard let sentDate = message.sentDate, abs(sentDate.timeIntervalSinceNow) <= 60 else {
+ // Ignore older P2P messages
+ return
+ }
+
var subType = message.messageSubtype ?? ""
assert(subType.hasPrefix("_/p2p/0/"))
@@ -824,9 +1139,16 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC
messenger: self,
targetConversation: message.target,
state: P2PFrameworkState(
- username: device.username,
- deviceId: device.deviceId,
- identity: device.identity
+ remote: Peer(
+ username: device.username,
+ deviceConfig: UserDeviceConfig(
+ deviceId: device.deviceId,
+ identity: device.identity,
+ publicKey: device.publicKey,
+ isMasterDevice: device.isMasterDevice
+ )
+ ),
+ isMeshEnabled: factory.isMeshEnabled
)
)
@@ -834,7 +1156,7 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC
// TODO: What is a P2P session already exists?
if let client = client {
client.delegate = self
- await state.registerSession(
+ try await state.registerSession(
P2PSession(
deviceIdentity: device,
transport: client,
@@ -854,6 +1176,11 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC
_ connection: P2PTransportClient,
receivedMessage buffer: ByteBuffer
) async throws {
+ guard buffer.readableBytes <= 16_000_000 else {
+ // Package too large for P2P Transport
+ return
+ }
+
guard let session = await state.p2pSessions.first(where: {
$0.transport === connection
}) else {
@@ -865,8 +1192,12 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC
/// Lists all active P2P connections
/// Especially useful for when a client wants to purge unneeded sessions
- public func listOpenP2PConnections() async -> [P2PClient] {
- await state.p2pSessions.map(\.client)
+ @CypherTextKitActor public func listOpenP2PConnections() -> [P2PClient] {
+ state.p2pSessions.map(\.client)
+ }
+
+ @CypherTextKitActor public func hasP2PConnection(with username: Username) -> Bool {
+ state.p2pSessions.contains { $0.username == username }
}
internal func getEstablishedP2PConnection(
@@ -878,20 +1209,20 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC
})?.client
}
- internal func getEstablishedP2PConnection(
- with device: DecryptedModel
+ @CryptoActor internal func getEstablishedP2PConnection(
+ with device: _DecryptedModel
) async throws -> P2PClient? {
- await state.p2pSessions.first(where: { user in
+ state.p2pSessions.first(where: { user in
user.username == device.username && user.deviceId == device.deviceId
})?.client
}
- internal func createP2PConnection(
- with device: DecryptedModel,
+ @CryptoActor internal func createP2PConnection(
+ with device: _DecryptedModel,
targetConversation: TargetConversation,
preferredTransportIdentifier: String? = nil
) async throws {
- if await state.p2pSessions.contains(where: { user in
+ if state.p2pSessions.contains(where: { user in
user.username == device.username && user.deviceId == device.deviceId
}) {
return
@@ -916,9 +1247,16 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC
}
let state = P2PFrameworkState(
- username: device.username,
- deviceId: device.deviceId,
- identity: device.identity
+ remote: Peer(
+ username: device.username,
+ deviceConfig: UserDeviceConfig(
+ deviceId: device.deviceId,
+ identity: device.identity,
+ publicKey: device.publicKey,
+ isMasterDevice: device.isMasterDevice
+ )
+ ),
+ isMeshEnabled: transportFactory.isMeshEnabled
)
let client = try await transportFactory.createConnection(
@@ -933,7 +1271,7 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC
// TODO: What if a P2P session already exists?
if let client = client {
client.delegate = self
- await self.state.registerSession(
+ try await self.state.registerSession(
P2PSession(
deviceIdentity: device,
transport: client,
@@ -946,130 +1284,158 @@ public final class CypherMessenger: CypherTransportClientDelegate, P2PTransportC
)
}
}
+
+ @CypherTextKitActor public var registeryMode: DeviceRegisteryMode {
+ self.state.config.registeryMode
+ }
+
+ @CypherTextKitActor public func listDevices() async throws -> [UserDevice] {
+ return try await self._fetchDeviceIdentities(for: self.username).map { device in
+ UserDevice(device: device)
+ }
+ }
+
+ public func renameCurrentDevice(to name: String) async throws {
+ let chat = try await getInternalConversation()
+ try await chat.sendMagicPacket(
+ messageSubtype: "_/devices/rename",
+ text: "",
+ metadata: BSONEncoder().encode(MagicPackets.RenameDevice(deviceId: self.deviceId, name: name))
+ )
+ }
}
-extension DecryptedModel where M == DeviceIdentityModel {
+extension Contact {
+ @CypherTextKitActor public func listDevices() async throws -> [UserDevice] {
+ return try await messenger._fetchDeviceIdentities(for: username).map { device in
+ UserDevice(device: device)
+ }
+ }
+}
+
+extension _DecryptedModel where M == DeviceIdentityModel {
@CryptoActor func _readWithRatchetEngine(
- ofUser username: Username,
- deviceId: DeviceId,
message: RatchetedCypherMessage,
messenger: CypherMessenger
- ) async throws -> Data {
- try await withLock {
- func rekey() async throws {
- debugLog("Rekeying - removing ratchet state")
- try await self.updateDoubleRatchetState(to: nil)
-
- try await messenger.eventHandler.onRekey(
- withUser: username,
- deviceId: deviceId,
- messenger: messenger
- )
- try await messenger.cachedStore.updateDeviceIdentity(encrypted)
- try await messenger._queueTask(
- .sendMessage(
- SendMessageTask(
- message: CypherMessage(
- message: SingleCypherMessage(
- messageType: .magic,
- messageSubtype: "_/ignore",
- text: "",
- metadata: [:],
- order: 0,
- target: .otherUser(username)
- )
- ),
- recipient: username,
- recipientDeviceId: deviceId,
- localId: UUID(),
- pushType: .none,
- messageId: UUID().uuidString
- )
+ ) async throws -> Data? {
+ @CryptoActor func rekey() async throws {
+ debugLog("Rekeying - removing ratchet state")
+ try self.updateDoubleRatchetState(to: nil)
+ try self.setProp(at: \.lastRekey, to: Date())
+
+ try await messenger.eventHandler.onRekey(
+ withUser: username,
+ deviceId: deviceId,
+ messenger: messenger
+ )
+ try await messenger.cachedStore.updateDeviceIdentity(encrypted)
+ try await messenger._queueTask(
+ .sendMessage(
+ SendMessageTask(
+ message: CypherMessage(
+ message: SingleCypherMessage(
+ messageType: .magic,
+ messageSubtype: "_/ignore",
+ text: "",
+ metadata: [:],
+ order: 0,
+ target: .otherUser(username)
+ )
+ ),
+ recipient: username,
+ recipientDeviceId: deviceId,
+ localId: UUID(),
+ pushType: .none,
+ messageId: UUID().uuidString
)
)
- }
+ )
+ }
+
+ let data: Data
+ var ratchet: DoubleRatchetHKDF
+ if let existingState = self.doubleRatchet, !message.rekey {
+ ratchet = DoubleRatchetHKDF(
+ state: existingState,
+ configuration: doubleRatchetConfig
+ )
- let data: Data
- var ratchet: DoubleRatchetHKDF
- if let existingState = self.doubleRatchet, !message.rekey {
- ratchet = DoubleRatchetHKDF(
- state: existingState,
- configuration: doubleRatchetConfig
- )
-
- do {
- let ratchetMessage = try message.readAndValidate(usingIdentity: self.identity)
- data = try ratchet.ratchetDecrypt(ratchetMessage)
- } catch {
- try await rekey()
- debugLog("Failed to read message", error)
- throw error
- }
- } else {
- guard message.rekey else {
- debugLog("Couldn't read message not marked as rekey")
- throw CypherSDKError.invalidHandshake
- }
-
- do {
- let secret = try await messenger._formSharedSecret(with: self.publicKey)
- let symmetricKey = messenger._deriveSymmetricKey(
- from: secret,
- initiator: messenger.username
- )
- let ratchetMessage = try message.readAndValidate(usingIdentity: self.identity)
- (ratchet, data) = try DoubleRatchetHKDF.initializeRecipient(
- secretKey: symmetricKey,
- localPrivateKey: await messenger.state.config.deviceKeys.privateKey,
- configuration: doubleRatchetConfig,
- initialMessage: ratchetMessage
- )
- } catch {
- // TODO: Ignore incoming follow-up messages
- debugLog("Failed to initialise recipient", error)
- try await rekey()
- throw error
+ do {
+ let ratchetMessage = try message.readAndValidate(usingIdentity: self.identity)
+ switch try ratchet.ratchetDecrypt(ratchetMessage) {
+ case .success(let d):
+ data = d
+ case .keyExpiry:
+ return nil
}
+ } catch {
+ try await rekey()
+ debugLog("Failed to read message", error)
+ throw error
+ }
+ } else {
+ guard message.rekey else {
+ debugLog("Couldn't read message not marked as rekey")
+ throw CypherSDKError.invalidHandshake
}
- try await self.updateDoubleRatchetState(to: ratchet.state)
-
- try await messenger.cachedStore.updateDeviceIdentity(encrypted)
- return data
+ do {
+ let secret = try messenger._formSharedSecret(with: self.publicKey)
+ let symmetricKey = messenger._deriveSymmetricKey(
+ from: secret,
+ initiator: messenger.username
+ )
+ let ratchetMessage = try message.readAndValidate(usingIdentity: self.identity)
+ (ratchet, data) = try DoubleRatchetHKDF.initializeRecipient(
+ secretKey: symmetricKey,
+ localPrivateKey: messenger.state.config.deviceKeys.privateKey,
+ configuration: doubleRatchetConfig,
+ initialMessage: ratchetMessage
+ )
+ } catch {
+ // TODO: Ignore incoming follow-up messages
+ debugLog("Failed to initialise recipient", error)
+ try await rekey()
+ throw error
+ }
}
+
+ try self.updateDoubleRatchetState(to: ratchet.state)
+
+ try await messenger.cachedStore.updateDeviceIdentity(encrypted)
+ return data
}
@CryptoActor func _writeWithRatchetEngine(
messenger: CypherMessenger,
- run: @escaping (inout DoubleRatchetHKDF, RekeyState) async throws -> T
+ run: @Sendable @CryptoActor (inout DoubleRatchetHKDF, RekeyState) async throws -> T
) async throws -> T {
- try await withLock {
- var ratchet: DoubleRatchetHKDF
- let rekey: Bool
-
- if let existingState = self.doubleRatchet {
- ratchet = DoubleRatchetHKDF(
- state: existingState,
- configuration: doubleRatchetConfig
- )
- rekey = false
- } else {
- let secret = try await messenger._formSharedSecret(with: publicKey)
- let symmetricKey = messenger._deriveSymmetricKey(from: secret, initiator: self.username)
- ratchet = try DoubleRatchetHKDF.initializeSender(
- secretKey: symmetricKey,
- contactingRemote: publicKey,
- configuration: doubleRatchetConfig
- )
- rekey = true
- }
-
- let result = try await run(&ratchet, rekey ? .rekey : .next)
- try await updateDoubleRatchetState(to: ratchet.state)
-
- try await messenger.cachedStore.updateDeviceIdentity(encrypted)
- return result
+ var ratchet: DoubleRatchetHKDF
+ let rekey: Bool
+
+ if let existingState = self.doubleRatchet {
+ ratchet = DoubleRatchetHKDF(
+ state: existingState,
+ configuration: doubleRatchetConfig
+ )
+ rekey = false
+ } else {
+ let secret = try messenger._formSharedSecret(with: publicKey)
+ let symmetricKey = messenger._deriveSymmetricKey(from: secret, initiator: self.username)
+ try self.setProp(at: \.lastRekey, to: Date())
+ ratchet = try DoubleRatchetHKDF.initializeSender(
+ secretKey: symmetricKey,
+ contactingRemote: publicKey,
+ configuration: doubleRatchetConfig
+ )
+ rekey = true
}
+
+ let result = try await run(&ratchet, rekey ? .rekey : .next)
+ try updateDoubleRatchetState(to: ratchet.state)
+
+ try await messenger.cachedStore.updateDeviceIdentity(encrypted)
+ return result
}
}
@@ -1105,3 +1471,28 @@ fileprivate let doubleRatchetConfig = DoubleRatchetConfiguration(
headerAssociatedDataGenerator: .constant("Cypher ChatMessage".data(using: .ascii)!),
maxSkippedMessageKeys: 100
)
+
+public struct UserDevice: Identifiable {
+ public var id: DeviceId { config.deviceId }
+ public let username: Username
+ public let config: UserDeviceConfig
+ public let name: String?
+
+ @CypherTextKitActor internal init(device: _DecryptedModel) {
+ self.username = device.username
+ self.name = device.deviceName
+ self.config = UserDeviceConfig(
+ deviceId: device.deviceId,
+ identity: device.identity,
+ publicKey: device.publicKey,
+ isMasterDevice: device.isMasterDevice
+ )
+ }
+}
+
+enum MagicPackets {
+ internal struct RenameDevice: Codable {
+ let deviceId: DeviceId
+ let name: String
+ }
+}
diff --git a/Sources/CypherMessaging/P2PClient.swift b/Sources/CypherMessaging/P2PClient.swift
index 931530c..8959354 100644
--- a/Sources/CypherMessaging/P2PClient.swift
+++ b/Sources/CypherMessaging/P2PClient.swift
@@ -1,9 +1,10 @@
+import Crypto
import NIO
import BSON
import CypherProtocol
struct Acknowledgement {
- public let id: ObjectId
+ public let id: String
fileprivate let done: EventLoopFuture
public func completion() async throws {
@@ -12,10 +13,10 @@ struct Acknowledgement {
}
fileprivate final actor AcknowledgementManager {
- var acks = [ObjectId: EventLoopPromise]()
+ var acks = [String: EventLoopPromise]()
func next(on eventLoop: EventLoop, deadline: TimeAmount = .seconds(10)) -> Acknowledgement {
- let id = ObjectId()
+ let id = UUID().uuidString
let promise = eventLoop.makePromise(of: Void.self)
acks[id] = promise
@@ -26,7 +27,7 @@ fileprivate final actor AcknowledgementManager {
return Acknowledgement(id: id, done: promise.futureResult)
}
- func acknowledge(_ id: ObjectId) {
+ func acknowledge(_ id: String) {
acks[id]?.succeed(())
}
@@ -39,22 +40,39 @@ fileprivate final actor AcknowledgementManager {
}
}
+fileprivate struct ForwardedBroadcast: Hashable {
+ let username: Username
+ let deviceId: DeviceId
+ let messageId: String
+}
+
/// A peer-to-peer connection with a remote device. Used for low-latency communication with a third-party device.
/// P2PClient is also used for static-length packets that are easily identified, such as status changes.
///
/// You can interact with P2PClient as if you're sending and receiving cleartext messages, while the client itself applies the end-to-end encryption.
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
public final class P2PClient {
+ public static let maximumMeshPacketSize = 16_000
private weak var messenger: CypherMessenger?
private let client: P2PTransportClient
let eventLoop: EventLoop
private let ack = AcknowledgementManager()
internal private(set) var lastActivity = Date()
+ public var isMeshEnabled: Bool { client.state.isMeshEnabled }
+ @CypherTextKitActor private var forwardedBroadcasts = [ForwardedBroadcast]()
public private(set) var remoteStatus: P2PStatusMessage? {
didSet {
_onStatusChange?(remoteStatus)
}
}
+ private var handshakeSent = false
+ private let encryptionKey: SymmetricKey
+ private var inboundPacketId = 0
+ private var outboundPacketId = 0
+ private let encryptionNonce = SymmetricKey(size: .bits256).withUnsafeBytes { buffer in
+ Array(buffer.bindMemory(to: UInt8.self))
+ }
+ private var decryptionKey: SymmetricKey?
private var task: RepeatedTask?
/// The username of the remote device to which this P2PClient is connected
@@ -66,29 +84,37 @@ public final class P2PClient {
public var isConnected: Bool {
client.connected == .connected
}
- private var _onStatusChange: ((P2PStatusMessage?) -> ())?
- private var _onDisconnect: (() -> ())?
+ private var _onStatusChange: (@Sendable (P2PStatusMessage?) -> ())?
+ private var _onDisconnect: (@Sendable () -> ())?
/// The provided closure is called when the client disconnects
- public func onDisconnect(perform: @escaping () -> ()) {
+ public func onDisconnect(perform: @escaping @Sendable () -> ()) {
_onDisconnect = perform
}
/// The provided closure is called when the remote device indicates it's status has changed
- public func onStatusChange(perform: @escaping (P2PStatusMessage?) -> ()) {
+ public func onStatusChange(perform: @escaping @Sendable (P2PStatusMessage?) -> ()) {
_onStatusChange = perform
}
- internal init(
+ @CypherTextKitActor internal init(
client: P2PTransportClient,
messenger: CypherMessenger,
closeInactiveAfter seconds: Int?
- ) {
+ ) async throws {
self.messenger = messenger
self.client = client
self.eventLoop = messenger.eventLoop
- messenger.eventHandler.onP2PClientOpen(self, messenger: messenger)
+ let sharedSecret = try messenger._formSharedSecret(with: client.state.remote.publicKey)
+ encryptionKey = sharedSecret.hkdfDerivedSymmetricKey(
+ using: SHA512.self,
+ salt: encryptionNonce,
+ sharedInfo: "p2p".data(using: .utf8)!,
+ outputByteCount: 32
+ )
+
+ await messenger.eventHandler.onP2PClientOpen(self, messenger: messenger)
if let seconds = seconds {
assert(seconds > 0 && seconds <= 3600, "Invalid inactivity timer")
@@ -114,32 +140,65 @@ public final class P2PClient {
}
/// This function is called whenever a P2PTransportClient receives information from a remote device
- internal func receiveBuffer(
+ @CypherTextKitActor internal func receiveBuffer(
_ buffer: ByteBuffer
) async throws {
guard let messenger = messenger else {
return
}
+ guard buffer.readableBytes <= 100_000 else {
+ // Ignore packets over 100KB
+ // While BSON is a fast parser that does no unnecessary copies
+ // We should still protect memory
+ return
+ }
+
+ // TODO: DOS prevention against repeating malicious peers
+
self.lastActivity = Date()
let document = Document(buffer: buffer)
- let ratchetMessage = try BSONDecoder().decode(RatchetedCypherMessage.self, from: document)
+ let packet = try BSONDecoder().decode(P2PMessage.self, from: document)
- let device = try await messenger._fetchDeviceIdentity(for: username, deviceId: deviceId)
- let data = try await device._readWithRatchetEngine(
- ofUser: client.state.username,
- deviceId: client.state.deviceId,
- message: ratchetMessage,
- messenger: messenger
- )
- let messageBson = Document(data: data)
- let message = try BSONDecoder().decode(P2PMessage.self, from: messageBson)
+ switch packet {
+ case .encrypted(let encryptedMessage):
+ if let decryptionKey = decryptionKey {
+ let message = try encryptedMessage.decrypt(using: decryptionKey)
+
+ guard message.id == inboundPacketId else {
+ // Replay attack?
+ return await disconnect()
+ }
+
+ inboundPacketId += 1
+ return try await receiveDecryptedPayload(message)
+ } else {
+ debugLog("Cannot decrypt message without key")
+ }
+ case .handshake(let handshake):
+ let sharedSecret = try messenger._formSharedSecret(with: client.state.remote.publicKey)
+ decryptionKey = sharedSecret.hkdfDerivedSymmetricKey(
+ using: SHA512.self,
+ salt: handshake.nonce,
+ sharedInfo: "p2p".data(using: .utf8)!,
+ outputByteCount: 32
+ )
+ inboundPacketId = 0
+ }
+ }
+
+ @CypherTextKitActor private func receiveDecryptedPayload(
+ _ message: P2PPayload
+ ) async throws {
+ guard let messenger = messenger else {
+ return
+ }
switch message.box {
case .status(let status):
self.remoteStatus = status
- case .sendMessage(let message):
+ case .message(let message):
let device = try await messenger._fetchDeviceIdentity(for: username, deviceId: deviceId)
let messageId = message.id
switch message.message.box {
@@ -160,13 +219,115 @@ public final class P2PClient {
}
case .ack:
await ack.acknowledge(message.ack)
+ case .broadcast(var broadcast):
+ guard
+ // Ignore broadcasts on non-mesh clients
+ client.state.isMeshEnabled,
+ // 2KB packaging overhead over payload
+ broadcast.value.value.makeByteBuffer().readableBytes <= P2PClient.maximumMeshPacketSize + 2_000,
+ // Prevent infinite hopping
+ broadcast.hops <= 64
+ else {
+ // Ignore broadcast
+ return
+ }
+
+ broadcast.hops -= 1
+ let signedBroadcast = broadcast.value
+ let unverifiedBroadcast = try signedBroadcast.readWithoutVerifying()
+ let claimedOrigin = unverifiedBroadcast.origin
+ let destination = unverifiedBroadcast.target
+
+ // TODO: Prevent Denial-of-Service spammers from spamming us through the mesh
+
+ let forwardedBroadcast = ForwardedBroadcast(
+ username: claimedOrigin.username,
+ deviceId: claimedOrigin.deviceId,
+ messageId: unverifiedBroadcast.messageId
+ )
+
+ guard !forwardedBroadcasts.contains(forwardedBroadcast) else {
+ // Don't re-process the same message
+ return
+ }
+
+ forwardedBroadcasts.append(forwardedBroadcast)
+
+ if forwardedBroadcasts.count > 200 {
+ // Clean up historic broadcasts list in bulk
+ // To clean up memory, and to allow re-broadcasts in case things changed
+ forwardedBroadcasts.removeFirst(100)
+ }
+
+ let broadcastMessage: P2PBroadcast.Message
+ let knownDevices = try await messenger._fetchKnownDeviceIdentities(for: claimedOrigin.username)
+ let verified: Bool
+
+ if let knownPeer = knownDevices.first(where: { $0.deviceId == claimedOrigin.deviceId }) {
+ // Device is known, accept!
+ broadcastMessage = try signedBroadcast.readAndVerifySignature(signedBy: knownPeer.identity)
+ verified = true
+ } else if knownDevices.isEmpty {
+ // User is not known, so assume the device is plausible although unverified
+ broadcastMessage = try signedBroadcast.readAndVerifySignature(signedBy: claimedOrigin.identity)
+ verified = false
+ } else {
+ // User is known, but device is not known. Abort, might be malicious
+ return
+ }
+
+ if destination.username == messenger.username && destination.deviceId == messenger.deviceId {
+ // It's for us!
+ try await messenger._queueTask(
+ .processMessage(
+ ReceiveMessageTask(
+ message: broadcastMessage.payload,
+ messageId: broadcastMessage.messageId,
+ sender: claimedOrigin.username,
+ deviceId: claimedOrigin.deviceId,
+ createdAt: broadcastMessage.createdAt
+ )
+ )
+ )
+
+ // TODO: Broadcast ack back? How does the client know it's arrived?
+ }
+
+ let p2pConnections = messenger.listOpenP2PConnections()
+
+ if let p2pConnection = p2pConnections.first(where: {
+ $0.client.state.username == destination.username && $0.client.state.deviceId == destination.deviceId
+ }) {
+ // We know who to send it to!
+ return try await p2pConnection.sendMessage(.broadcast(broadcast))
+ }
+
+ if broadcast.hops <= 0 {
+ // End of reach, let's stop here
+ return
+ }
+
+ // Try to pass it on to other peers, maybe they can help
+ for p2pConnection in p2pConnections {
+ if p2pConnection.username == client.state.username && p2pConnection.deviceId == client.state.deviceId {
+ // Ignore, since we're just cascading it back to the origin of this broadcast
+ } else {
+ let broadcast = broadcast
+ Task {
+ // Ignore (connection) errors, we've tried our best
+ try await p2pConnection.sendMessage(.broadcast(broadcast))
+ }
+ }
+ }
+
+ // TODO: Forward to/broadcast to the internet services?
}
}
func sendMessage(_ message: CypherMessage, messageId: String) async throws {
debugLog("Routing message over P2P", message)
try await sendMessage(
- .sendMessage(
+ .message(
P2PSendMessage(
message: message,
id: messageId
@@ -192,38 +353,48 @@ public final class P2PClient {
/// Sends a message (cleartext) to a remote peer
/// This function then applies end-to-end encryption before transmitting the information over the internet.
- private func sendMessage(_ box: P2PMessage.Box) async throws {
- guard let messenger = self.messenger else {
- throw CypherSDKError.offline
+ internal func sendMessage(_ box: P2PPayload.Box) async throws {
+ if !handshakeSent {
+ handshakeSent = true
+ try await sendHandshake()
}
self.lastActivity = Date()
let ack = await ack.next(on: eventLoop)
- let message = P2PMessage(
+ let payload = P2PPayload(
box: box,
- ack: ack.id
+ ack: ack.id,
+ packetId: outboundPacketId
)
+ outboundPacketId += 1
- try await messenger._writeWithRatchetEngine(
- ofUser: client.state.username,
- deviceId: client.state.deviceId
- ) { ratchetEngine, rekey in
- let messageBson = try BSONEncoder().encode(message)
- let encryptedMessage = try ratchetEngine.ratchetEncrypt(messageBson.makeData())
- let signedMessage = try await messenger._signRatchetMessage(encryptedMessage, rekey: rekey)
- let signedMessageBson = try BSONEncoder().encode(signedMessage)
-
- try await self.client.sendMessage(signedMessageBson.makeByteBuffer())
- }
+ let message = try P2PMessage.encrypted(
+ Encrypted(
+ payload,
+ encryptionKey: encryptionKey
+ )
+ )
+
+ let signedMessageBson = try BSONEncoder().encode(message)
+ try await self.client.sendMessage(signedMessageBson.makeByteBuffer())
try await ack.completion()
}
+ /// Sends a message (cleartext) to a remote peer
+ /// This function then applies end-to-end encryption before transmitting the information over the internet.
+ private func sendHandshake() async throws {
+ let message = P2PMessage.handshake(P2PHandshake(nonce: encryptionNonce))
+ let signedMessageBson = try BSONEncoder().encode(message)
+ outboundPacketId = 0
+ try await self.client.sendMessage(signedMessageBson.makeByteBuffer())
+ }
+
/// Disconnects the transport layer
public func disconnect() async {
if let messenger = self.messenger {
- messenger.eventHandler.onP2PClientClose(messenger: messenger)
+ await messenger.eventHandler.onP2PClientClose(messenger: messenger)
}
await client.disconnect()
diff --git a/Sources/CypherMessaging/P2PTransport/IPv6+TCP/IPv6TCPP2PTransport.swift b/Sources/CypherMessaging/P2PTransport/IPv6+TCP/IPv6TCPP2PTransport.swift
index 95ff3cb..3046cb4 100644
--- a/Sources/CypherMessaging/P2PTransport/IPv6+TCP/IPv6TCPP2PTransport.swift
+++ b/Sources/CypherMessaging/P2PTransport/IPv6+TCP/IPv6TCPP2PTransport.swift
@@ -6,7 +6,7 @@ public enum IPv6TCPP2PError: Error {
case reconnectFailed, timeout, socketCreationFailed
}
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
private final class BufferHandler: ChannelInboundHandler {
typealias InboundIn = ByteBuffer
private weak var client: IPv6TCPP2PTransportClient?
@@ -24,7 +24,7 @@ private final class BufferHandler: ChannelInboundHandler {
if let delegate = client.delegate {
context.eventLoop.executeAsync {
- _ = try await delegate.p2pConnection(client, receivedMessage: buffer)
+ try await delegate.p2pConnection(client, receivedMessage: buffer)
}.whenFailure { error in
context.fireErrorCaught(error)
}
@@ -32,7 +32,7 @@ private final class BufferHandler: ChannelInboundHandler {
}
}
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
final class IPv6TCPP2PTransportClient: P2PTransportClient {
public weak var delegate: P2PTransportClientDelegate?
public private(set) var connected = ConnectionState.connected
@@ -44,10 +44,6 @@ final class IPv6TCPP2PTransportClient: P2PTransportClient {
self.channel = channel
}
- public func reconnect() async throws {
- throw IPv6TCPP2PError.reconnectFailed
- }
-
public func disconnect() async {
do {
try await channel.close()
@@ -101,9 +97,11 @@ public struct StunConfig {
}
}
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
public final class IPv6TCPP2PTransportClientFactory: P2PTransportClientFactory {
public let transportLayerIdentifier = "_ipv6-tcp"
+ public let isMeshEnabled = false
+ public weak var delegate: P2PTransportFactoryDelegate?
let eventLoop = MultiThreadedEventLoopGroup(numberOfThreads: 1).next()
let stun: StunConfig?
diff --git a/Sources/CypherMessaging/Primitives/Cache.swift b/Sources/CypherMessaging/Primitives/Cache.swift
index 964a842..4fe902c 100644
--- a/Sources/CypherMessaging/Primitives/Cache.swift
+++ b/Sources/CypherMessaging/Primitives/Cache.swift
@@ -5,26 +5,29 @@
// Created by Joannis Orlandos on 19/04/2021.
//
-import SwiftUI
+typealias CacheActor = MainActor
public protocol CacheKey {
associatedtype Value
}
-public final class Cache {
+@CacheActor public final class Cache: Sendable {
internal init() {}
private var values = [ObjectIdentifier: Any]()
+ @CacheActor
public func read(_ key: Key.Type) -> Key.Value? {
values[ObjectIdentifier(key)] as? Key.Value
}
+ @CacheActor
public func setValue(_ value: Key.Value, forKey key: Key.Type) {
values[ObjectIdentifier(key)] = value
}
- public func readOrCreateValue(forKey key: Key.Type, resolve: () -> Key.Value) -> Key.Value {
+ @CacheActor
+ public func readOrCreateValue(forKey key: Key.Type, resolve: @Sendable () -> Key.Value) -> Key.Value {
if let value = read(key) {
return value
} else {
diff --git a/Sources/CypherMessaging/Primitives/GroupChat.swift b/Sources/CypherMessaging/Primitives/GroupChat.swift
index 79db926..f07ea8a 100644
--- a/Sources/CypherMessaging/Primitives/GroupChat.swift
+++ b/Sources/CypherMessaging/Primitives/GroupChat.swift
@@ -1,9 +1,9 @@
-import BSON
+@preconcurrency import BSON
import CypherProtocol
-import Foundation
+@preconcurrency import Foundation
/// A string wrapper so that Strings are handled in a case-insensitive manner and to prevent mistakes like provding the wring String in a function
-public struct GroupChatId: CustomStringConvertible, Identifiable, Codable, Hashable, Equatable, Comparable, ExpressibleByStringLiteral {
+public struct GroupChatId: CustomStringConvertible, Identifiable, Codable, Hashable, Equatable, Comparable, ExpressibleByStringLiteral, Sendable {
public let raw: String
public static func ==(lhs: GroupChatId, rhs: GroupChatId) -> Bool {
@@ -38,7 +38,7 @@ public struct GroupChatId: CustomStringConvertible, Identifiable, Codable, Hasha
public var id: String { raw }
}
-public struct ReferencedBlob: Codable {
+public struct ReferencedBlob: Codable, Sendable {
public let id: String
public var blob: T
@@ -48,13 +48,14 @@ public struct ReferencedBlob: Codable {
}
}
-public struct GroupChatConfig: Codable {
+public struct GroupChatConfig: Codable, Sendable {
private enum CodingKeys: String, CodingKey {
case members = "a"
case createdAt = "b"
case moderators = "c"
case metadata = "d"
case admin = "e"
+ case kickedMembers = "f"
}
public private(set) var members: Set
@@ -62,6 +63,7 @@ public struct GroupChatConfig: Codable {
public private(set) var moderators: Set
public var metadata: Document
public let admin: Username
+ public private(set) var kickedMembers: Set
public init(
admin: Username,
@@ -77,14 +79,18 @@ public struct GroupChatConfig: Codable {
self.moderators = moderators
self.createdAt = Date()
self.metadata = metadata
+ self.kickedMembers = []
}
public mutating func addMember(_ username: Username) {
members.insert(username)
+ kickedMembers.remove(username)
}
public mutating func removeMember(_ username: Username) {
- members.remove(username)
+ if let member = members.remove(username) {
+ kickedMembers.insert(member)
+ }
moderators.remove(username)
}
diff --git a/Sources/CypherMessaging/Primitives/UserKeys.swift b/Sources/CypherMessaging/Primitives/UserKeys.swift
index 3d706ba..3449f0c 100644
--- a/Sources/CypherMessaging/Primitives/UserKeys.swift
+++ b/Sources/CypherMessaging/Primitives/UserKeys.swift
@@ -1,8 +1,9 @@
import Foundation
import CypherProtocol
+import Crypto
/// The user's private keys are only stored on the user's main device
-public struct DevicePrivateKeys: Codable {
+public struct DevicePrivateKeys: Codable, Sendable {
private enum CodingKeys: String, CodingKey {
case deviceId = "a"
case identity = "b"
@@ -21,13 +22,13 @@ public struct DevicePrivateKeys: Codable {
}
}
-public struct UserConfig: Codable {
+public struct UserConfig: Codable, @unchecked Sendable {
private enum CodingKeys: String, CodingKey {
case identity = "a"
case devices = "b"
}
- /// Identity is a public key used to validate messages sidned by `identity`
+ /// Identity is a public key used to validate messages signed by `identity`
/// This is the main device's identity, which when trusted verified all other devices' validity
public let identity: PublicSigningKey
@@ -57,6 +58,10 @@ public struct UserConfig: Codable {
_ config: UserDeviceConfig,
signedWith identity: PrivateSigningKey
) throws {
+ guard self.identity.data == identity.publicKey.data else {
+ throw CypherSDKError.invalidSignature
+ }
+
var devices = try readAndValidateDevices()
if devices.contains(where: { $0.deviceId == config.deviceId }) {
diff --git a/Sources/CypherMessaging/Protocol/CypherMessage.swift b/Sources/CypherMessaging/Protocol/CypherMessage.swift
index b0721fe..6663474 100644
--- a/Sources/CypherMessaging/Protocol/CypherMessage.swift
+++ b/Sources/CypherMessaging/Protocol/CypherMessage.swift
@@ -1,16 +1,16 @@
-@available(macOS 12, iOS 15, *)
-struct CypherMessage: Codable {
+@available(macOS 10.15, iOS 13, *)
+struct CypherMessage: Codable, Sendable {
private enum CodingKeys: String, CodingKey {
case type = "a"
case box = "b"
}
- private enum WrappedType: Int, Codable {
+ private enum WrappedType: Int, Codable, Sendable {
case single = 0
case array = 1
}
- internal enum Wrapped {
+ internal enum Wrapped: Sendable {
case single(SingleCypherMessage)
case array([SingleCypherMessage])
}
diff --git a/Sources/CypherMessaging/Protocol/P2PMessage.swift b/Sources/CypherMessaging/Protocol/P2PMessage.swift
index 85656b7..a65eb49 100644
--- a/Sources/CypherMessaging/Protocol/P2PMessage.swift
+++ b/Sources/CypherMessaging/Protocol/P2PMessage.swift
@@ -6,62 +6,96 @@ public struct P2PSendMessage: Codable {
let id: String
}
-public struct P2PMessage: Codable {
+public struct P2PBroadcast: Codable {
+ // TODO: Shorter CodingKeys
+ public struct Message: Codable {
+ // TODO: Shorter CodingKeys
+ let origin: Peer
+ let target: Peer
+ let messageId: String
+ let createdAt: Date
+ let payload: RatchetedCypherMessage
+ }
+
+ var hops: Int
+ let value: Signed
+}
+
+public struct P2PHandshake: Codable {
+ let nonce: [UInt8]
+}
+
+public enum P2PMessage: Codable {
+ case encrypted(Encrypted)
+ case handshake(P2PHandshake)
+}
+
+public struct P2PPayload: Codable {
private enum CodingKeys: String, CodingKey {
case type = "a"
case box = "b"
case ack = "c"
+ case id = "d"
}
private enum MessageType: Int, Codable {
case status = 0
case sendMessage = 1
case ack = 2
+ case broadcast = 3
}
internal enum Box {
case status(P2PStatusMessage)
- case sendMessage(P2PSendMessage)
+ case message(P2PSendMessage)
case ack
+ case broadcast(P2PBroadcast)
}
let box: Box
- let ack: ObjectId
+ let ack: String
+ let id: Int
- init(box: Box, ack: ObjectId) {
+ init(box: Box, ack: String, packetId: Int) {
self.box = box
self.ack = ack
+ self.id = packetId
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
+ try container.encode(ack, forKey: .ack)
+ try container.encode(id, forKey: .id)
switch box {
case .status(let status):
try container.encode(MessageType.status, forKey: .type)
try container.encode(status, forKey: .box)
- try container.encode(ack, forKey: .ack)
- case .sendMessage(let message):
+ case .message(let message):
try container.encode(MessageType.sendMessage, forKey: .type)
try container.encode(message, forKey: .box)
- try container.encode(ack, forKey: .ack)
case .ack:
try container.encode(MessageType.ack, forKey: .type)
- try container.encode(ack, forKey: .ack)
+ case .broadcast(let message):
+ try container.encode(MessageType.broadcast, forKey: .type)
+ try container.encode(message, forKey: .box)
}
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
- self.ack = try container.decode(ObjectId.self, forKey: .ack)
+ self.ack = try container.decode(String.self, forKey: .ack)
+ self.id = try container.decode(Int.self, forKey: .id)
switch try container.decode(MessageType.self, forKey: .type) {
case .status:
self.box = try .status(container.decode(P2PStatusMessage.self, forKey: .box))
case .sendMessage:
- self.box = try .sendMessage(container.decode(P2PSendMessage.self, forKey: .box))
+ self.box = try .message(container.decode(P2PSendMessage.self, forKey: .box))
case .ack:
self.box = .ack
+ case .broadcast:
+ self.box = try .broadcast(container.decode(P2PBroadcast.self, forKey: .box))
}
}
}
diff --git a/Sources/CypherMessaging/Store/CypherMessengerStore.swift b/Sources/CypherMessaging/Store/CypherMessengerStore.swift
index ec96eba..8db9cf4 100644
--- a/Sources/CypherMessaging/Store/CypherMessengerStore.swift
+++ b/Sources/CypherMessaging/Store/CypherMessengerStore.swift
@@ -1,11 +1,11 @@
import Foundation
import NIO
-public enum SortMode {
+public enum SortMode: Sendable {
case ascending, descending
}
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
public protocol CypherMessengerStore {
func fetchContacts() async throws -> [ContactModel]
func createContact(_ contact: ContactModel) async throws
diff --git a/Sources/CypherMessaging/Store/_CypherMessengerStoreCache.swift b/Sources/CypherMessaging/Store/_CypherMessengerStoreCache.swift
index 4af2b4b..925784b 100644
--- a/Sources/CypherMessaging/Store/_CypherMessengerStoreCache.swift
+++ b/Sources/CypherMessaging/Store/_CypherMessengerStoreCache.swift
@@ -12,7 +12,7 @@ struct Weak {
private init() {}
}
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
internal final class _CypherMessengerStoreCache: CypherMessengerStore {
internal let base: CypherMessengerStore
@@ -41,6 +41,12 @@ internal final class _CypherMessengerStoreCache: CypherMessengerStore {
return contacts
} else {
let contacts = try await self.base.fetchContacts()
+
+ // If two fetches ran in parallel, the first one wins
+ if let contacts = self.contacts {
+ return contacts
+ }
+
self.contacts = contacts
return contacts
}
@@ -63,6 +69,7 @@ internal final class _CypherMessengerStoreCache: CypherMessengerStore {
return try await base.updateContact(contact)
}
+ // TODO: Rename to `byLocalId: UUID`
@CypherCacheActor func fetchChatMessage(byId messageId: UUID) async throws -> ChatMessageModel {
if let message = self.messages[messageId] {
return message
@@ -73,6 +80,7 @@ internal final class _CypherMessengerStoreCache: CypherMessengerStore {
}
}
+ // TODO: Deprecate, remoteID should be unique per senderID, not globally
@CypherCacheActor func fetchChatMessage(byRemoteId remoteId: String) async throws -> ChatMessageModel {
let message = try await self.base.fetchChatMessage(byRemoteId: remoteId)
if let cachedMessage = self.messages[message.id] {
@@ -87,6 +95,12 @@ internal final class _CypherMessengerStoreCache: CypherMessengerStore {
return conversations
} else {
let conversations = try await self.base.fetchConversations()
+
+ // If two `fetchConversations` ran in parallel, the first one wins
+ if let conversations = self.conversations {
+ return conversations
+ }
+
self.conversations = conversations
return conversations
}
@@ -114,6 +128,12 @@ internal final class _CypherMessengerStoreCache: CypherMessengerStore {
return deviceIdentities
} else {
let deviceIdentities = try await self.base.fetchDeviceIdentities()
+
+ // If two fetches ran in parallel, the first one wins
+ if let deviceIdentities = self.deviceIdentities {
+ return deviceIdentities
+ }
+
self.deviceIdentities = deviceIdentities
return deviceIdentities
}
diff --git a/Sources/CypherMessaging/TestSupport/MemorySpokeMessengerStore.swift b/Sources/CypherMessaging/TestSupport/MemorySpokeMessengerStore.swift
index d4f11dd..85d2620 100644
--- a/Sources/CypherMessaging/TestSupport/MemorySpokeMessengerStore.swift
+++ b/Sources/CypherMessaging/TestSupport/MemorySpokeMessengerStore.swift
@@ -5,7 +5,7 @@ public enum MemoryCypherMessengerStoreError: Error {
case notFound
}
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
public final actor MemoryCypherMessengerStore: CypherMessengerStore {
private let salt = UUID().uuidString
private var localConfig: Data?
diff --git a/Sources/CypherMessaging/TestSupport/SpoofP2PTransport.swift b/Sources/CypherMessaging/TestSupport/SpoofP2PTransport.swift
index 9e2c90e..4bf6cd1 100644
--- a/Sources/CypherMessaging/TestSupport/SpoofP2PTransport.swift
+++ b/Sources/CypherMessaging/TestSupport/SpoofP2PTransport.swift
@@ -4,7 +4,7 @@ enum SpoofP2PTransportError: Error {
case disconnected
}
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
public final class SpoofP2PTransportClient: P2PTransportClient {
public weak var delegate: P2PTransportClientDelegate?
public fileprivate(set) var connected: ConnectionState = .connecting
@@ -19,16 +19,11 @@ public final class SpoofP2PTransportClient: P2PTransportClient {
self.otherClient = otherClient
}
- public func reconnect() async throws {
- if otherClient == nil {
- self.connected = .disconnected
- throw SpoofP2PTransportError.disconnected
- } else {
- self.connected = .connected
- }
- }
-
public func disconnect() async {
+ if connected == .disconnected || connected == .disconnecting {
+ return
+ }
+
self.connected = .disconnecting
if let otherClient = otherClient {
@@ -55,21 +50,75 @@ public final class SpoofP2PTransportClient: P2PTransportClient {
}
}
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
fileprivate final class SpoofTransportFactoryMedium {
- var clients = [String: SpoofP2PTransportClient]()
+ @CypherTextKitActor var clients = [String: SpoofP2PTransportClient]()
+ @CypherTextKitActor var devices = [String: SpoofP2PTransportFactory]()
private init() {}
static let `default` = SpoofTransportFactoryMedium()
}
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
public final class SpoofP2PTransportFactory: P2PTransportClientFactory {
- public init() {}
+ @CypherTextKitActor public static func clearMesh() {
+ SpoofTransportFactoryMedium.default.devices = [:]
+ }
+
+ @discardableResult
+ @CypherTextKitActor public static func connectMesh(from: String, to: String) async throws -> Bool {
+ guard
+ let from = SpoofTransportFactoryMedium.default.devices[from],
+ let to = SpoofTransportFactoryMedium.default.devices[to],
+ let fromAdvertisement = try await from.delegate?.createLocalDeviceAdvertisement(),
+ let toAdvertisement = try await to.delegate?.createLocalDeviceAdvertisement()
+ else {
+ return false
+ }
+
+ let fromState = try await from.createLocalTransportState(advertisement: toAdvertisement)
+ let fromClient = SpoofP2PTransportClient(
+ state: fromState,
+ otherClient: nil
+ )
+
+ let toState = try await to.createLocalTransportState(advertisement: fromAdvertisement)
+ let toClient = SpoofP2PTransportClient(
+ state: toState,
+ otherClient: fromClient
+ )
+
+ fromClient.otherClient = toClient
+
+ try await from.delegate?.p2pTransportDiscovered(
+ fromClient,
+ remotePeer: fromState.remote
+ )
+ try await to.delegate?.p2pTransportDiscovered(
+ toClient,
+ remotePeer: toState.remote
+ )
+
+ fromClient.connected = .connected
+ toClient.connected = .connected
+ return true
+ }
public let transportLayerIdentifier = "_spoof"
+ public let isMeshEnabled: Bool
+ public weak var delegate: P2PTransportFactoryDelegate?
+
+ public init() {
+ self.isMeshEnabled = false
+ }
+
+ @CypherTextKitActor public init(meshId: String) {
+ self.isMeshEnabled = true
+
+ SpoofTransportFactoryMedium.default.devices[meshId] = self
+ }
- public func createConnection(handle: P2PTransportFactoryHandle) async throws -> P2PTransportClient? {
+ @CypherTextKitActor public func createConnection(handle: P2PTransportFactoryHandle) async throws -> P2PTransportClient? {
let localClient = SpoofP2PTransportClient(
state: handle.state,
otherClient: nil
@@ -86,7 +135,7 @@ public final class SpoofP2PTransportFactory: P2PTransportClientFactory {
return localClient
}
- public func receiveMessage(_ text: String, metadata: Document, handle: P2PTransportFactoryHandle) async throws -> P2PTransportClient? {
+ @CypherTextKitActor public func receiveMessage(_ text: String, metadata: Document, handle: P2PTransportFactoryHandle) async throws -> P2PTransportClient? {
guard let client = SpoofTransportFactoryMedium.default.clients[text] else {
return nil
}
diff --git a/Sources/CypherMessaging/TestSupport/SpoofSpokeEventHandler.swift b/Sources/CypherMessaging/TestSupport/SpoofSpokeEventHandler.swift
index c158fce..af442c6 100644
--- a/Sources/CypherMessaging/TestSupport/SpoofSpokeEventHandler.swift
+++ b/Sources/CypherMessaging/TestSupport/SpoofSpokeEventHandler.swift
@@ -1,6 +1,6 @@
import NIO
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
public struct SpoofCypherEventHandler: CypherMessengerEventHandler {
public init() {}
@@ -48,4 +48,6 @@ public struct SpoofCypherEventHandler: CypherMessengerEventHandler {
public func onRemoveChatMessage(_ message: AnyChatMessage) {}
public func onDeviceRegistery(_ deviceId: DeviceId, messenger: CypherMessenger) {}
+ public func onOtherUserDeviceRegistery(username: Username, deviceId: DeviceId, messenger: CypherMessenger) { }
+ public func onCustomConfigChange() {}
}
diff --git a/Sources/CypherMessaging/TestSupport/SpoofTransport.swift b/Sources/CypherMessaging/TestSupport/SpoofTransport.swift
index 4bfd036..61abcb6 100644
--- a/Sources/CypherMessaging/TestSupport/SpoofTransport.swift
+++ b/Sources/CypherMessaging/TestSupport/SpoofTransport.swift
@@ -3,7 +3,7 @@ import Foundation
import NIO
public enum SpoofTransportClientSettings {
- public enum PacketType {
+ public enum PacketType: Sendable {
case readReceipt(remoteId: String, otherUser: Username)
case receiveReceipt(remoteId: String, otherUser: Username)
case deviceRegistery
@@ -14,7 +14,14 @@ public enum SpoofTransportClientSettings {
case sendMessage(messageId: String)
}
- public static var shouldDropPacket: (Username, PacketType) async throws -> () = { _, _ in }
+ public static var isOffline = false
+ public static var shouldDropPacket: @Sendable @CryptoActor (Username, PacketType) async throws -> () = { _, _ in }
+ public static func removeBacklog() -> [DeviceId: [CypherServerEvent]] {
+ SpoofServer.local.removeBacklog()
+ }
+ public static func addBacklog(_ backlog: [DeviceId: [CypherServerEvent]]) {
+ SpoofServer.local.addBacklog(backlog)
+ }
}
fileprivate final class SpoofServer {
@@ -46,6 +53,22 @@ fileprivate final class SpoofServer {
publishedBlobs = [:]
}
+ func removeBacklog() -> [DeviceId: [CypherServerEvent]] {
+ defer { backlog = [:] }
+ return backlog
+ }
+
+ func addBacklog(_ backlog: [DeviceId: [CypherServerEvent]]) {
+ for (device, log) in backlog {
+ if var existing = self.backlog[device] {
+ existing.append(contentsOf: log)
+ self.backlog[device] = existing
+ } else {
+ self.backlog[device] = log
+ }
+ }
+ }
+
fileprivate func login(username: Username, deviceId: DeviceId) async throws -> SpoofTransportClient {
SpoofTransportClient(username: username, deviceId: deviceId, server: self)
}
@@ -131,6 +154,8 @@ public final class SpoofTransportClient: ConnectableCypherTransportClient {
private let server: SpoofServer
public private(set) var authenticated = AuthenticationState.unauthenticated
public let supportsMultiRecipientMessages = true
+ public let supportsDelayedRegistration = true
+ public var isConnected: Bool { !SpoofTransportClientSettings.isOffline }
public weak var delegate: CypherTransportClientDelegate?
public func setDelegate(to delegate: CypherTransportClientDelegate) async throws {
@@ -153,16 +178,21 @@ public final class SpoofTransportClient: ConnectableCypherTransportClient {
}
public static func login(_ request: TransportCreationRequest) async throws -> SpoofTransportClient {
- try await SpoofServer.local.login(username: request.username, deviceId: request.deviceId)
+ return try await SpoofServer.local.login(username: request.username, deviceId: request.deviceId)
}
public func receiveServerEvent(_ event: CypherServerEvent) async throws {
- _ = try await delegate?.receiveServerEvent(event)
+ try await delegate?.receiveServerEvent(event)
}
public func reconnect() async throws {
+ if SpoofTransportClientSettings.isOffline {
+ throw SpoofP2PTransportError.disconnected
+ }
+
server.connectUser(self)
authenticated = .authenticated
+ try await server.requestBacklog(username: username, deviceId: deviceId, into: self)
}
public func disconnect() async throws {
@@ -171,6 +201,10 @@ public final class SpoofTransportClient: ConnectableCypherTransportClient {
}
public func sendMessageReadReceipt(byRemoteId remoteId: String, to otherUser: Username) async throws {
+ if !isConnected {
+ throw CypherSDKError.offline
+ }
+
try await SpoofTransportClientSettings.shouldDropPacket(
self.username,
.readReceipt(remoteId: remoteId, otherUser: otherUser)
@@ -179,6 +213,10 @@ public final class SpoofTransportClient: ConnectableCypherTransportClient {
}
public func sendMessageReceivedReceipt(byRemoteId remoteId: String, to otherUser: Username) async throws {
+ if !isConnected {
+ throw CypherSDKError.offline
+ }
+
try await SpoofTransportClientSettings.shouldDropPacket(
self.username,
.receiveReceipt(remoteId: remoteId, otherUser: otherUser)
@@ -187,6 +225,10 @@ public final class SpoofTransportClient: ConnectableCypherTransportClient {
}
public func requestDeviceRegistery(_ userDeviceConfig: UserDeviceConfig) async throws {
+ if !isConnected {
+ throw CypherSDKError.offline
+ }
+
try await SpoofTransportClientSettings.shouldDropPacket(
self.username,
.deviceRegistery
@@ -207,6 +249,10 @@ public final class SpoofTransportClient: ConnectableCypherTransportClient {
}
public func readKeyBundle(forUsername username: Username) async throws -> UserConfig {
+ if !isConnected {
+ throw CypherSDKError.offline
+ }
+
try await SpoofTransportClientSettings.shouldDropPacket(
self.username,
.readKeyBundle(username: username)
@@ -220,6 +266,10 @@ public final class SpoofTransportClient: ConnectableCypherTransportClient {
}
public func publishKeyBundle(_ keys: UserConfig) async throws {
+ if !isConnected {
+ throw CypherSDKError.offline
+ }
+
try await SpoofTransportClientSettings.shouldDropPacket(
self.username,
.publishKeyBundle
@@ -234,6 +284,10 @@ public final class SpoofTransportClient: ConnectableCypherTransportClient {
}
public func publishBlob(_ blob: C) async throws -> ReferencedBlob {
+ if !isConnected {
+ throw CypherSDKError.offline
+ }
+
try await SpoofTransportClientSettings.shouldDropPacket(
self.username,
.publishBlob
@@ -245,6 +299,10 @@ public final class SpoofTransportClient: ConnectableCypherTransportClient {
}
public func readPublishedBlob(byId id: String, as type: C.Type) async throws -> ReferencedBlob? {
+ if !isConnected {
+ throw CypherSDKError.offline
+ }
+
try await SpoofTransportClientSettings.shouldDropPacket(
self.username,
.readBlob(id: id)
@@ -264,6 +322,10 @@ public final class SpoofTransportClient: ConnectableCypherTransportClient {
pushType: PushType,
messageId: String
) async throws {
+ if !isConnected {
+ throw CypherSDKError.offline
+ }
+
try await SpoofTransportClientSettings.shouldDropPacket(
self.username,
.sendMessage(messageId: messageId)
@@ -273,7 +335,8 @@ public final class SpoofTransportClient: ConnectableCypherTransportClient {
message,
id: messageId,
byUser: self.username,
- deviceId: deviceId
+ deviceId: deviceId,
+ createdAt: Date()
),
to: otherUser,
deviceId: otherUserDeviceId
@@ -285,6 +348,10 @@ public final class SpoofTransportClient: ConnectableCypherTransportClient {
pushType: PushType,
messageId: String
) async throws {
+ if !isConnected {
+ throw CypherSDKError.offline
+ }
+
try await SpoofTransportClientSettings.shouldDropPacket(
self.username,
.sendMessage(messageId: messageId)
diff --git a/Sources/CypherMessaging/Transport/CypherTransportClient.swift b/Sources/CypherMessaging/Transport/CypherTransportClient.swift
index bfe3ac6..f080072 100644
--- a/Sources/CypherMessaging/Transport/CypherTransportClient.swift
+++ b/Sources/CypherMessaging/Transport/CypherTransportClient.swift
@@ -41,10 +41,16 @@ public protocol CypherServerTransportClient: AnyObject {
/// `true` when logged in, `false` on incorrect login, `nil` when no server request has been executed yet
var authenticated: AuthenticationState { get }
+ /// `true` if the client is able to communicate with a/the server
+ var isConnected: Bool { get }
+
/// When `true`, the CypherMessenger's internals may call the `sendMultiRecipientMessage` method.
/// Supporting MultiRecipient Messages allows the app to expend less data uploading files to multiple recipients.
var supportsMultiRecipientMessages: Bool { get }
+ // TODO: Implement support
+ var supportsDelayedRegistration: Bool { get }
+
/// (Re-)starts the connection(s).
func reconnect() async throws
@@ -112,6 +118,10 @@ public protocol CypherServerTransportClient: AnyObject {
func sendMultiRecipientMessage(_ message: MultiRecipientCypherMessage, pushType: PushType, messageId: String) async throws
}
+extension CypherServerTransportClient {
+ public var supportsDelayedRegistration: Bool { false }
+}
+
public protocol ConnectableCypherTransportClient: CypherServerTransportClient {
static func login(_ request: TransportCreationRequest) async throws -> Self
}
@@ -122,29 +132,29 @@ public protocol CypherTransportClientDelegate: AnyObject {
public struct CypherServerEvent {
enum _CypherServerEvent {
- case multiRecipientMessageSent(MultiRecipientCypherMessage, id: String, byUser: Username, deviceId: DeviceId)
- case messageSent(RatchetedCypherMessage, id: String, byUser: Username, deviceId: DeviceId)
- case messageDisplayed(by: Username, deviceId: DeviceId, id: String)
- case messageReceived(by: Username, deviceId: DeviceId, id: String)
+ case multiRecipientMessageSent(MultiRecipientCypherMessage, id: String, byUser: Username, deviceId: DeviceId, createdAt: Date?)
+ case messageSent(RatchetedCypherMessage, id: String, byUser: Username, deviceId: DeviceId, createdAt: Date?)
+ case messageDisplayed(by: Username, deviceId: DeviceId, id: String, receivedAt: Date)
+ case messageReceived(by: Username, deviceId: DeviceId, id: String, receivedAt: Date)
case requestDeviceRegistery(UserDeviceConfig)
}
internal let raw: _CypherServerEvent
- public static func multiRecipientMessageSent(_ message: MultiRecipientCypherMessage, id: String, byUser user: Username, deviceId: DeviceId) -> CypherServerEvent {
- CypherServerEvent(raw: .multiRecipientMessageSent(message, id: id, byUser: user, deviceId: deviceId))
+ public static func multiRecipientMessageSent(_ message: MultiRecipientCypherMessage, id: String, byUser user: Username, deviceId: DeviceId, createdAt: Date? = Date()) -> CypherServerEvent {
+ CypherServerEvent(raw: .multiRecipientMessageSent(message, id: id, byUser: user, deviceId: deviceId, createdAt: createdAt))
}
- public static func messageSent(_ message: RatchetedCypherMessage, id: String, byUser user: Username, deviceId: DeviceId) -> CypherServerEvent {
- CypherServerEvent(raw: .messageSent(message, id: id, byUser: user, deviceId: deviceId))
+ public static func messageSent(_ message: RatchetedCypherMessage, id: String, byUser user: Username, deviceId: DeviceId, createdAt: Date? = Date()) -> CypherServerEvent {
+ CypherServerEvent(raw: .messageSent(message, id: id, byUser: user, deviceId: deviceId, createdAt: createdAt))
}
- public static func messageDisplayed(by user: Username, deviceId: DeviceId, id: String) -> CypherServerEvent {
- CypherServerEvent(raw: .messageDisplayed(by: user, deviceId: deviceId, id: id))
+ public static func messageDisplayed(by user: Username, deviceId: DeviceId, id: String, receivedAt: Date = Date()) -> CypherServerEvent {
+ CypherServerEvent(raw: .messageDisplayed(by: user, deviceId: deviceId, id: id, receivedAt: receivedAt))
}
- public static func messageReceived(by user: Username, deviceId: DeviceId, id: String) -> CypherServerEvent {
- CypherServerEvent(raw: .messageReceived(by: user, deviceId: deviceId, id: id))
+ public static func messageReceived(by user: Username, deviceId: DeviceId, id: String, receivedAt: Date = Date()) -> CypherServerEvent {
+ CypherServerEvent(raw: .messageReceived(by: user, deviceId: deviceId, id: id, receivedAt: receivedAt))
}
public static func requestDeviceRegistery(_ config: UserDeviceConfig) -> CypherServerEvent {
diff --git a/Sources/CypherMessaging/Transport/P2PTransportClient.swift b/Sources/CypherMessaging/Transport/P2PTransportClient.swift
index 3820024..47d594f 100644
--- a/Sources/CypherMessaging/Transport/P2PTransportClient.swift
+++ b/Sources/CypherMessaging/Transport/P2PTransportClient.swift
@@ -5,12 +5,38 @@ public enum ConnectionState {
case connecting, connected, disconnecting, disconnected
}
+public struct Peer: Codable {
+ public let username: Username
+ public let deviceConfig: UserDeviceConfig
+ public var deviceId: DeviceId { deviceConfig.deviceId }
+ public var identity: PublicSigningKey { deviceConfig.identity }
+ public var publicKey: PublicKey { deviceConfig.publicKey }
+}
+
+public struct P2PAdvertisement: Codable {
+ internal struct Advertisement: Codable {
+ let origin: Peer
+ }
+
+ internal let advertisement: Signed
+}
+
+internal actor P2PMeshState {
+
+}
+
/// Can only be initialised by the CypherTextKit
/// Contains internal and public information about the connection and conncted clients
public struct P2PFrameworkState {
- internal let username: Username
- internal let deviceId: DeviceId
- internal let identity: PublicSigningKey
+ public let remote: Peer
+ internal var username: Username { remote.username }
+ internal var deviceId: DeviceId { remote.deviceId }
+ internal var identity: PublicSigningKey { remote.identity }
+ internal let isMeshEnabled: Bool
+
+ // TODO: Attempt Offline Verification
+ internal var verified = true
+ internal let mesh = P2PMeshState()
}
/// PeerToPeerTransportClient is used to create a direct connection between two devices.
@@ -18,7 +44,7 @@ public struct P2PFrameworkState {
/// These transport clients need not concern themselves with end-to-end encryption, as the data they receive is already encrypted.
///
/// CypherTextKit may opt to use a direct connection as a _replacement_ for via-server communication, as to improve security, bandwidth AND latency.
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
public protocol P2PTransportClient: AnyObject {
/// The delegate receives incoming data from the the remote peer. MUST be `weak` to prevent memory leaks.
///
@@ -33,9 +59,6 @@ public protocol P2PTransportClient: AnyObject {
/// Obtained on creation through `P2PTransportClientFactory`
var state: P2PFrameworkState { get }
- /// (Re-)starts the connection(s).
- func reconnect() async throws
-
/// Disconnects any active connections.
func disconnect() async
@@ -43,21 +66,29 @@ public protocol P2PTransportClient: AnyObject {
func sendMessage(_ buffer: ByteBuffer) async throws
}
-public enum P2PTransportClosureOption {
- case reconnnectPossible
-}
+public enum P2PTransportClosureOption {}
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
public protocol P2PTransportClientDelegate: AnyObject {
func p2pConnection(_ connection: P2PTransportClient, receivedMessage buffer: ByteBuffer) async throws
- func p2pConnection(_ connection: P2PTransportClient, closedWithOptions: Set) async throws
+ func p2pConnectionClosed(_ connection: P2PTransportClient) async throws
+}
+
+@available(macOS 10.15, iOS 13, *)
+public protocol P2PTransportFactoryDelegate: AnyObject {
+ func p2pTransportDiscovered(
+ _ connection: P2PTransportClient,
+ remotePeer: Peer
+ ) async throws
+
+ func createLocalDeviceAdvertisement() async throws -> P2PAdvertisement
}
public struct P2PTransportCreationRequest {
public let state: P2PFrameworkState
}
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
public typealias PeerToPeerConnectionBuilder = (P2PTransportCreationRequest) -> P2PTransportClient
/// P2PTransportClientFactory is a _stateful_ factory that can instantiate new connections
@@ -67,10 +98,18 @@ public typealias PeerToPeerConnectionBuilder = (P2PTransportCreationRequest) ->
/// Example: Apple devices can use Multipeer Connectivity, possibly without making use of server-side communication.
///
/// Example: WebRTC based implementations are likely to make use of the handle to send and receive SDPs. The factory can then make use of internal state for storing incomplete connections.
-@available(macOS 12, iOS 15, *)
-public protocol P2PTransportClientFactory {
+@available(macOS 10.15, iOS 13, *)
+public protocol P2PTransportClientFactory: AnyObject {
var transportLayerIdentifier: String { get }
+ /// Whether this transport type supports relaying messages for between two peers
+ var isMeshEnabled: Bool { get }
+
+ /// The delegate receives signals of factory-discovered peers. MUST be `weak` to prevent memory leaks.
+ ///
+ /// CypherTextKit is responsible for managing and delegating data received from this channel
+ var delegate: P2PTransportFactoryDelegate? { get set }
+
func receiveMessage(
_ text: String,
metadata: Document,
@@ -88,8 +127,27 @@ public protocol P2PTransportClientFactory {
) async throws -> P2PTransportClient?
}
+extension P2PTransportClientFactory {
+ public var isMeshEnabled: Bool { false }
+
+ public func createLocalTransportState(
+ advertisement: P2PAdvertisement
+ ) async throws -> P2PFrameworkState {
+ let remote = try advertisement.advertisement.readWithoutVerifying()
+ guard advertisement.advertisement.isSigned(by: remote.origin.identity) else {
+ throw CypherSDKError.invalidSignature
+ }
+
+ return P2PFrameworkState(
+ remote: remote.origin,
+ isMeshEnabled: isMeshEnabled,
+ verified: false
+ )
+ }
+}
+
/// An interface through which can be communicated with the remote device
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
public struct P2PTransportFactoryHandle {
internal let transportLayerIdentifier: String
internal let messenger: CypherMessenger
diff --git a/Sources/CypherMessaging/_Internal/Crypto/EncryptedData.swift b/Sources/CypherMessaging/_Internal/Crypto/EncryptedData.swift
index 8f1fd2c..ea0d336 100644
--- a/Sources/CypherMessaging/_Internal/Crypto/EncryptedData.swift
+++ b/Sources/CypherMessaging/_Internal/Crypto/EncryptedData.swift
@@ -3,7 +3,7 @@ import Foundation
import Crypto
/// Used when encrypting a specific value
-public final class Encrypted: Codable {
+public final class Encrypted: Codable, @unchecked Sendable {
private var value: AES.GCM.SealedBox
private var wrapped: T?
@@ -14,16 +14,20 @@ public final class Encrypted: Codable {
let data = try BSONEncoder().encode(wrapper).makeData()
// Encrypt & store the encoded data
- self.value = try AES.GCM.seal(data, using: encryptionKey)
+ self.value = try! AES.GCM.seal(data, using: encryptionKey)
}
- public func update(to value: T, using encryptionKey: SymmetricKey) async throws {
+ public func update(to value: T, using encryptionKey: SymmetricKey) throws {
self.wrapped = value
let wrapper = PrimitiveWrapper(value: value)
let data = try BSONEncoder().encode(wrapper).makeData()
self.value = try AES.GCM.seal(data, using: encryptionKey)
}
+ public func canDecrypt(using encryptionKey: SymmetricKey) throws {
+ _ = try AES.GCM.open(value, using: encryptionKey)
+ }
+
// The inverse of the initializer
public func decrypt(using encryptionKey: SymmetricKey) throws -> T {
if let wrapped = wrapped {
@@ -38,7 +42,7 @@ public final class Encrypted: Codable {
// Return the value
let value = wrapper.value
-// wrapped = value
+ wrapped = value
return value
}
diff --git a/Sources/CypherMessaging/_Internal/Error.swift b/Sources/CypherMessaging/_Internal/Error.swift
index fe7cdd3..bfb4c9a 100644
--- a/Sources/CypherMessaging/_Internal/Error.swift
+++ b/Sources/CypherMessaging/_Internal/Error.swift
@@ -16,4 +16,9 @@ enum CypherSDKError: Error {
case invalidTransport
case unsupportedTransport
case internalError
+ case notGroupMember, notGroupModerator
+ case duplicateChatMessage
+ case invalidSignature
+ case cannotRegisterDeviceConfig
+ case cannotImportExistingContact
}
diff --git a/Sources/CypherMessaging/_Internal/Helpers+CypherMessenger.swift b/Sources/CypherMessaging/_Internal/Helpers+CypherMessenger.swift
index aded501..6241469 100644
--- a/Sources/CypherMessaging/_Internal/Helpers+CypherMessenger.swift
+++ b/Sources/CypherMessaging/_Internal/Helpers+CypherMessenger.swift
@@ -8,18 +8,23 @@ enum UserIdentityState {
case consistent, newIdentity, changedIdentity
}
-@available(macOS 12, iOS 15, *)
+// TODO: Respect push notification preferences that other users apply on your chat
+// TODO: Server-Side mute and even block another user
+// TODO: Encrypted push notifs
+@available(macOS 10.15, iOS 13, *)
internal extension CypherMessenger {
+ @CryptoActor
+ @discardableResult
func _markMessage(byRemoteId remoteId: String, updatedBy user: Username, as newState: ChatMessageModel.DeliveryState) async throws -> MarkMessageResult {
let message = try await cachedStore.fetchChatMessage(byRemoteId: remoteId)
let decryptedMessage = try await self.decrypt(message)
- guard decryptedMessage.props.senderUser == self.username else {
+ guard await decryptedMessage.props.senderUser == self.username else {
throw CypherSDKError.badInput
}
- let oldState = decryptedMessage.deliveryState
- let result = try await decryptedMessage.transitionDeliveryState(to: newState)
+ let oldState = await decryptedMessage.deliveryState
+ let result = try await decryptedMessage.transitionDeliveryState(to: newState, forUser: user, messenger: self)
do {
try await self._updateChatMessage(decryptedMessage)
@@ -30,6 +35,8 @@ internal extension CypherMessenger {
}
}
+ @CryptoActor
+ @discardableResult
func _markMessage(byId id: UUID?, as newState: ChatMessageModel.DeliveryState) async throws -> MarkMessageResult {
guard let id = id else {
return .error
@@ -37,9 +44,9 @@ internal extension CypherMessenger {
let message = try await cachedStore.fetchChatMessage(byId: id)
let decryptedMessage = try await self.decrypt(message)
- let oldState = decryptedMessage.deliveryState
+ let oldState = await decryptedMessage.deliveryState
- let result = try await decryptedMessage.transitionDeliveryState(to: newState)
+ let result = try await decryptedMessage.transitionDeliveryState(to: newState, forUser: self.username, messenger: self)
do {
try await self._updateChatMessage(decryptedMessage)
@@ -50,9 +57,10 @@ internal extension CypherMessenger {
}
}
+ @CryptoActor
func _updateChatMessage(_ message: DecryptedModel) async throws {
try await self.cachedStore.updateChatMessage(message.encrypted)
- self.eventHandler.onMessageChange(
+ await self.eventHandler.onMessageChange(
AnyChatMessage(
target: message.props.message.target,
messenger: self,
@@ -61,6 +69,7 @@ internal extension CypherMessenger {
)
}
+ @CryptoActor
func _createConversation(
members: Set,
metadata: Document
@@ -70,6 +79,7 @@ internal extension CypherMessenger {
let conversation = try ConversationModel(
props: .init(
members: members,
+ kickedMembers: [],
metadata: metadata,
localOrder: 0
),
@@ -82,14 +92,22 @@ internal extension CypherMessenger {
throw CypherSDKError.internalError
}
- self.eventHandler.onCreateConversation(resolved)
+ await self.eventHandler.onCreateConversation(resolved)
return conversation
}
+ @CryptoActor
func _queueTask(_ task: CypherTask) async throws {
try await self.jobQueue.queueTask(task)
}
+ @CryptoActor
+ func _queueTasks(_ task: [CypherTask]) async throws {
+ try await self.jobQueue.queueTasks(task)
+ }
+
+ @CryptoActor
+ @discardableResult
func _updateUserIdentity(of username: Username, to config: UserConfig) async throws -> UserIdentityState {
if username == self.username {
return .consistent
@@ -99,11 +117,11 @@ internal extension CypherMessenger {
for contact in contacts {
let contact = try await self.decrypt(contact)
- guard contact.props.username == username else {
+ guard await contact.props.username == username else {
continue
}
- if contact.config.identity.data == config.identity.data {
+ if await contact.config.identity.data == config.identity.data {
return .consistent
} else {
try await contact.updateConfig(to: config)
@@ -127,64 +145,116 @@ internal extension CypherMessenger {
)
try await self.cachedStore.createContact(contact)
- self.eventHandler.onCreateContact(
- Contact(messenger: self, model: try await self.decrypt(contact)),
+ await self.eventHandler.onCreateContact(
+ Contact(messenger: self, model: try self.decrypt(contact)),
messenger: self
)
return .newIdentity
}
- func _createDeviceIdentity(from device: UserDeviceConfig, forUsername username: Username) async throws -> DecryptedModel {
+ @CryptoActor
+ @discardableResult
+ func _createDeviceIdentity(
+ from device: UserDeviceConfig,
+ forUsername username: Username,
+ serverVerified: Bool = true
+ ) async throws -> _DecryptedModel {
let deviceIdentities = try await cachedStore.fetchDeviceIdentities()
+ var knownSenderIds = [Int]()
for deviceIdentity in deviceIdentities {
- let deviceIdentity = try await self.decrypt(deviceIdentity)
+ let deviceIdentity = try self._decrypt(deviceIdentity)
if
deviceIdentity.props.username == username,
deviceIdentity.props.deviceId == device.deviceId
{
+ guard
+ deviceIdentity.publicKey == device.publicKey,
+ deviceIdentity.identity.data == device.identity.data
+ else {
+ throw CypherSDKError.invalidSignature
+ }
+
return deviceIdentity
}
+
+ knownSenderIds.append(deviceIdentity.senderId)
}
if username == self.username && device.deviceId == self.deviceId {
throw CypherSDKError.badInput
}
+ var newSenderId: Int
+ knownSenderIds.append(deviceIdentityId)
+
+ repeat {
+ newSenderId = .random(in: 1..]
- ) async throws -> [DecryptedModel] {
+ knownDevices: [_DecryptedModel]
+ ) async throws -> [_DecryptedModel] {
let userConfig = try await self.transport.readKeyBundle(forUsername: username)
+ return try await _processDeviceConfig(
+ userConfig,
+ forUername: username,
+ knownDevices: knownDevices
+ )
+ }
+
+ @CryptoActor
+ @discardableResult
+ func _processDeviceConfig(
+ _ userConfig: UserConfig,
+ forUername username: Username,
+ knownDevices: [_DecryptedModel]
+ ) async throws -> [_DecryptedModel] {
+ if rediscoveredUsernames.contains(username) {
+ return knownDevices
+ }
+
+ rediscoveredUsernames.insert(username)
+
let identityState = try await self._updateUserIdentity(
of: username,
to: userConfig
@@ -192,10 +262,11 @@ internal extension CypherMessenger {
switch identityState {
case .changedIdentity:
- self.eventHandler.onContactIdentityChange(username: username, messenger: self)
+ await self.eventHandler.onContactIdentityChange(username: username, messenger: self)
+ // TODO: Remove unknown devices?
fallthrough
case .consistent, .newIdentity:
- var models = [DecryptedModel]()
+ var models = [_DecryptedModel]()
for device in try userConfig.readAndValidateDevices() {
if let knownDevice = knownDevices.first(where: { $0.props.deviceId == device.deviceId }) {
@@ -217,11 +288,13 @@ internal extension CypherMessenger {
}
}
+ @CryptoActor
func _receiveMultiRecipientMessage(
_ message: MultiRecipientCypherMessage,
messageId: String,
sender: Username,
- senderDevice: DeviceId
+ senderDevice: DeviceId,
+ createdAt: Date?
) async throws {
guard let key = message.keys.first(where: { key in
return key.user == self.username && key.deviceId == self.deviceId
@@ -234,24 +307,78 @@ internal extension CypherMessenger {
multiRecipientContainer: message.container,
messageId: messageId,
sender: sender,
- senderDevice: senderDevice
+ senderDevice: senderDevice,
+ createdAt: createdAt
+ )
+ }
+
+ private func requestResendMessage(
+ messageId: String,
+ sender: Username,
+ senderDevice: DeviceId
+ ) async throws {
+ // Request message is resent
+ let resendRequest = SingleCypherMessage(
+ messageType: .magic,
+ messageSubtype: "_/resend/message",
+ text: messageId,
+ metadata: [:],
+ destructionTimer: nil,
+ sentDate: nil,
+ preferredPushType: PushType.none,
+ order: 0,
+ target: .otherUser(sender)
+ )
+
+ try await _queueTask(
+ .sendMessage(
+ SendMessageTask(
+ message: CypherMessage(message: resendRequest),
+ recipient: sender,
+ recipientDeviceId: senderDevice,
+ localId: nil,
+ pushType: .none,
+ messageId: UUID().uuidString
+ )
+ )
)
}
+ @CryptoActor
func _receiveMessage(
_ inbound: RatchetedCypherMessage,
multiRecipientContainer: MultiRecipientContainer?,
messageId: String,
sender: Username,
- senderDevice: DeviceId
+ senderDevice: DeviceId,
+ createdAt: Date?
) async throws {
// Receive message always retries, do we need to deal with decryption errors as a successful task execution
// Otherwise the task will infinitely run
// However, replies to this message may fail, and must then be retried
let deviceIdentity = try await self._fetchDeviceIdentity(for: sender, deviceId: senderDevice)
+
+ if
+ let createdAt = createdAt,
+ let lastRekey = deviceIdentity.props.lastRekey,
+ createdAt <= lastRekey
+ {
+ // Ignore message, since it was sent in a previous conversation.
+ return try await requestResendMessage(messageId: messageId, sender: sender, senderDevice: senderDevice)
+ }
+
let message: CypherMessage
do {
- let data = try await deviceIdentity._readWithRatchetEngine(ofUser: sender, deviceId: senderDevice, message: inbound, messenger: self)
+ guard let data = try await deviceIdentity._readWithRatchetEngine(
+ message: inbound,
+ messenger: self
+ ) else {
+ return try await self.requestResendMessage(
+ messageId: messageId,
+ sender: sender,
+ senderDevice: senderDevice
+ )
+ }
if let multiRecipientContainer = multiRecipientContainer {
guard data.count == 32 else {
@@ -268,9 +395,13 @@ internal extension CypherMessenger {
} else {
message = try BSONDecoder().decode(CypherMessage.self, from: Document(data: data))
}
+
+ if inbound.rekey {
+ try deviceIdentity.setProp(at: \.lastRekey, to: createdAt ?? Date())
+ }
} catch {
// Message was corrupt or unusable
- return
+ return try await requestResendMessage(messageId: messageId, sender: sender, senderDevice: senderDevice)
}
func processMessage(_ message: SingleCypherMessage) async throws {
@@ -292,23 +423,27 @@ internal extension CypherMessenger {
}
}
+ @CryptoActor
func _processMessage(
message: SingleCypherMessage,
remoteMessageId: String,
- sender: DecryptedModel
+ sender: _DecryptedModel
) async throws {
switch message.target {
case .currentUser:
guard
sender.username == self.username,
- sender.deviceId != self.deviceId,
- sender.isMasterDevice
+ sender.deviceId != self.deviceId
else {
throw CypherSDKError.badInput
}
switch (message.messageType, message.messageSubtype ?? "") {
case (.magic, "_/devices/announce"):
+ guard sender.isMasterDevice else {
+ throw CypherSDKError.badInput
+ }
+
let deviceConfig = try BSONDecoder().decode(
UserDeviceConfig.self,
from: message.metadata
@@ -316,18 +451,32 @@ internal extension CypherMessenger {
if deviceConfig.deviceId == self.deviceId {
// We're not going to add ourselves as a conversation partner
- // But we will mark ourselves as a child device
+ // But we will mark ourselves as a registered device
try await self.updateConfig { config in
- config.registeryMode = .childDevice
+ config.registeryMode = deviceConfig.isMasterDevice ? .masterDevice : .childDevice
}
return
}
- _ = try await self._createDeviceIdentity(
+ try await self._createDeviceIdentity(
from: deviceConfig,
forUsername: self.username
)
return
+ case (.magic, let subType) where subType == "_/devices/rename":
+ let rename = try BSONDecoder().decode(
+ MagicPackets.RenameDevice.self,
+ from: message.metadata
+ )
+
+ guard rename.deviceId != self.deviceId else {
+ // We don't rename ourselves, our identity is not stored like tat
+ return
+ }
+
+ let device = try await _fetchDeviceIdentity(for: username, deviceId: rename.deviceId)
+ try device.updateDeviceName(to: rename.name)
+ try await cachedStore.updateDeviceIdentity(device.encrypted)
case (.magic, let subType) where subType.hasPrefix("_/p2p/0/"):
if
let sentDate = message.sentDate,
@@ -342,7 +491,36 @@ internal extension CypherMessenger {
remoteMessageId: remoteMessageId,
sender: sender
)
- case (.magic, let subType) where subType == "_/ignore":
+ case (.magic, "_/ignore"):
+ return
+ case (.magic, "_/resend/message"):
+ guard let encryptedMessage = try? await self.cachedStore.fetchChatMessage(byRemoteId: message.text) else {
+ return
+ }
+
+ let message = try await self.decrypt(encryptedMessage)
+
+ guard message.encrypted.senderId == self.deviceIdentityId else {
+ // We're not the origin!
+ debugLog("\(sender.username) requested a message not sent by us")
+ return
+ }
+
+ // Check if this message was targetted at that useer
+ // We're the current user, so answer is automatically 'yes'
+ try await _queueTask(
+ .sendMessage(
+ SendMessageTask(
+ message: CypherMessage(message: message.message),
+ recipient: sender.username,
+ recipientDeviceId: sender.deviceId,
+ localId: nil,
+ pushType: .none,
+ messageId: message.encrypted.remoteId
+ )
+ )
+ )
+
return
default:
guard message.messageSubtype?.hasPrefix("_/") != true else {
@@ -366,7 +544,7 @@ internal extension CypherMessenger {
return
case .save:
let conversation = try await self.getInternalConversation()
- let chatMessage = try await conversation._saveMessage(
+ guard let chatMessage = try await conversation._saveMessage(
senderId: sender.props.senderId,
order: message.order,
props: .init(
@@ -376,9 +554,12 @@ internal extension CypherMessenger {
senderDeviceId: sender.props.deviceId
),
remoteId: remoteMessageId
- )
+ ) else {
+ // Message was not saved, probably duplicate
+ return
+ }
- if chatMessage.senderUser == self.username {
+ if await chatMessage.senderUser == self.username {
// Send by our device in this chat
return
}
@@ -390,6 +571,52 @@ internal extension CypherMessenger {
if let subType = message.messageSubtype, subType.hasPrefix("_/") {
switch subType {
case "_/ignore":
+ // Do nothing, it's like a `ping` message without `pong` reply
+ return
+ case "_/resend/message":
+ guard let group = try await getGroupChat(byId: groupId) else {
+ debugLog("\(sender.username) requested a message from an unknown group \(groupId)")
+ return
+ }
+
+ // 1. Check if the user is a member
+ guard await group.conversation.members.contains(sender.username) else {
+ debugLog("\(sender.username) requested a message from group \(groupId) which they're not a member of")
+ return
+ }
+
+ // TODO: 2. Check if the user has had access, I.E. participation date
+ guard let encryptedMessage = try? await self.cachedStore.fetchChatMessage(byRemoteId: message.text) else {
+ return
+ }
+
+ let message = try await self.decrypt(encryptedMessage)
+
+ guard message.encrypted.senderId == self.deviceIdentityId else {
+ // We're not the origin!
+ debugLog("\(sender.username) requested a message not sent by us")
+ return
+ }
+
+ guard group.conversation.encrypted.id == message.encrypted.conversationId else {
+ debugLog("\(sender.username) requested a message from an unrelated chat")
+ return
+ }
+
+ // Check if this message was targetted at that useer
+ // We're the current user, so answer is automatically 'yes'
+ try await _queueTask(
+ .sendMessage(
+ SendMessageTask(
+ message: CypherMessage(message: message.message),
+ recipient: sender.username,
+ recipientDeviceId: sender.deviceId,
+ localId: nil,
+ pushType: .none,
+ messageId: message.encrypted.remoteId
+ )
+ )
+ )
return
default:
debugLog("Unknown message subtype in cypher messenger namespace: ", message.messageSubtype as Any)
@@ -411,7 +638,7 @@ internal extension CypherMessenger {
case .ignore:
return
case .save:
- let chatMessage = try await group._saveMessage(
+ guard let chatMessage = try await group._saveMessage(
senderId: sender.props.senderId,
order: message.order,
props: .init(
@@ -421,9 +648,12 @@ internal extension CypherMessenger {
senderDeviceId: sender.props.deviceId
),
remoteId: remoteMessageId
- )
+ ) else {
+ // Message was not saved, probably duplicate
+ return
+ }
- if chatMessage.senderUser != self.username {
+ if await chatMessage.senderUser != self.username {
try await self.jobQueue.queueTask(
CypherTask.sendMessageDeliveryStateChangeTask(
SendMessageDeliveryStateChangeTask(
@@ -445,11 +675,57 @@ internal extension CypherMessenger {
remoteMessageId: remoteMessageId,
sender: sender
)
- case (.magic, let subType) where subType == "_/ignore":
+ case (.magic, "_/ignore"):
return
- case (.magic, let subType) where subType == "_/devices/announce":
+ case (.magic, "_/devices/announce"):
try await self._refreshDeviceIdentities(for: sender.username)
return
+ case (.magic, "_/resend/message"):
+ guard let encryptedMessage = try? await self.cachedStore.fetchChatMessage(byRemoteId: message.text) else {
+ return
+ }
+
+ let message = try await self.decrypt(encryptedMessage)
+
+ guard message.encrypted.senderId == self.deviceIdentityId else {
+ // We're not the origin!
+ debugLog("\(sender.username) requested a message not sent by us")
+ return
+ }
+
+ // Check if this message was targetted at that useer
+ guard let privateChat = try await getPrivateChat(with: recipient) else {
+ debugLog("\(sender.username) requested a message from an unknown private chat")
+ return
+ }
+
+ let conversationPartner = await privateChat.conversationPartner
+ guard
+ sender.username == conversationPartner // They requested our message
+ || sender.username == self.username // We requested our own message
+ else {
+ debugLog("\(sender.username) requested a message from an unrelated chat")
+ return
+ }
+
+ guard privateChat.conversation.encrypted.id == message.encrypted.conversationId else {
+ debugLog("\(sender.username) requested a message from an unrelated chat")
+ return
+ }
+
+ try await _queueTask(
+ .sendMessage(
+ SendMessageTask(
+ message: CypherMessage(message: message.message),
+ recipient: sender.username,
+ recipientDeviceId: sender.deviceId,
+ localId: nil,
+ pushType: .none,
+ messageId: message.encrypted.remoteId
+ )
+ )
+ )
+ return
case (.magic, let subType), (.media, let subType), (.text, let subType):
if subType.hasPrefix("_/") {
debugLog("Unknown message subtype in cypher messenger namespace: ", message.messageSubtype as Any)
@@ -477,7 +753,7 @@ internal extension CypherMessenger {
return
case .save:
debugLog("Received message is to be saved")
- let chatMessage = try await privateChat._saveMessage(
+ guard let chatMessage = try await privateChat._saveMessage(
senderId: sender.props.senderId,
order: message.order,
props: .init(
@@ -487,9 +763,12 @@ internal extension CypherMessenger {
senderDeviceId: sender.props.deviceId
),
remoteId: remoteMessageId
- )
+ ) else {
+ // Message was not saved, probably duplicate
+ return
+ }
- if chatMessage.senderUser != self.username {
+ if await chatMessage.senderUser != self.username {
try await self.jobQueue.queueTask(
CypherTask.sendMessageDeliveryStateChangeTask(
SendMessageDeliveryStateChangeTask(
@@ -506,11 +785,12 @@ internal extension CypherMessenger {
}
}
+ @CryptoActor
func _fetchKnownDeviceIdentities(
for username: Username
- ) async throws -> [DecryptedModel] {
+ ) async throws -> [_DecryptedModel] {
try await cachedStore.fetchDeviceIdentities().asyncCompactMap { deviceIdentity in
- let deviceIdentity = try await self.decrypt(deviceIdentity)
+ let deviceIdentity = try self._decrypt(deviceIdentity)
if deviceIdentity.username == username {
return deviceIdentity
@@ -520,10 +800,27 @@ internal extension CypherMessenger {
}
}
+ @CryptoActor
+ func _fetchKnownDeviceIdentity(
+ for username: Username,
+ deviceId: DeviceId
+ ) async throws -> _DecryptedModel? {
+ for deviceIdentity in try await cachedStore.fetchDeviceIdentities() {
+ let deviceIdentity = try self._decrypt(deviceIdentity)
+
+ if deviceIdentity.username == username, deviceIdentity.deviceId == deviceId {
+ return deviceIdentity
+ }
+ }
+
+ return nil
+ }
+
+ @CryptoActor
func _fetchDeviceIdentity(
for username: Username,
deviceId: DeviceId
- ) async throws -> DecryptedModel {
+ ) async throws -> _DecryptedModel {
let knownDevices = try await self._fetchKnownDeviceIdentities(for: username)
if let device = knownDevices.first(where: { $0.props.deviceId == deviceId }) {
return device
@@ -537,23 +834,25 @@ internal extension CypherMessenger {
}
}
+ @CryptoActor
func _fetchDeviceIdentities(
for username: Username
- ) async throws -> [DecryptedModel] {
+ ) async throws -> [_DecryptedModel] {
let knownDevices = try await self._fetchKnownDeviceIdentities(for: username)
- if knownDevices.isEmpty, username != self.username {
+ if knownDevices.isEmpty && username != self.username && isOnline {
return try await self._rediscoverDeviceIdentities(for: username, knownDevices: knownDevices)
}
return knownDevices
}
- @Sendable func _fetchDeviceIdentities(
+ @CryptoActor
+ func _fetchDeviceIdentities(
forUsers usernames: Set
- ) async throws -> [DecryptedModel] {
+ ) async throws -> [_DecryptedModel] {
let devices = try await cachedStore.fetchDeviceIdentities()
- let knownDevices = try await devices.asyncCompactMap { deviceIdentity -> DecryptedModel? in
- let deviceIdentity = try await self.decrypt(deviceIdentity)
+ let knownDevices = try await devices.asyncCompactMap { deviceIdentity -> _DecryptedModel? in
+ let deviceIdentity = try self._decrypt(deviceIdentity)
if usernames.contains(deviceIdentity.username) {
return deviceIdentity
@@ -562,11 +861,11 @@ internal extension CypherMessenger {
}
}
- var newDevices = [DecryptedModel]()
+ var newDevices = [_DecryptedModel]()
for username in usernames {
- if username != self.username, await !knownDevices.asyncContains(where: {
+ if username != self.username, !knownDevices.contains(where: {
$0.props.username == username
- }) {
+ }), isOnline {
let rediscovered = try await self._rediscoverDeviceIdentities(for: username, knownDevices: knownDevices)
newDevices.append(contentsOf: rediscovered)
}
diff --git a/Sources/CypherMessaging/_Internal/Logging.swift b/Sources/CypherMessaging/_Internal/Logging.swift
index 6cde4b3..a8a4394 100644
--- a/Sources/CypherMessaging/_Internal/Logging.swift
+++ b/Sources/CypherMessaging/_Internal/Logging.swift
@@ -1,7 +1,16 @@
import Foundation
-enum LogDomain: String {
- case none, webrtc, crypto
+public struct LogDomain {
+ fileprivate enum Raw: String {
+ case none, webrtc, crypto, transport
+ }
+
+ fileprivate let raw: Raw
+
+ public static let none = LogDomain(raw: .none)
+ public static let webrtc = LogDomain(raw: .webrtc)
+ public static let transport = LogDomain(raw: .transport)
+ internal static let crypto = LogDomain(raw: .crypto)
}
fileprivate let formatter: ISO8601DateFormatter = {
@@ -12,9 +21,9 @@ fileprivate let formatter: ISO8601DateFormatter = {
// TODO: Swift-log
// This way this is a NO-OP in release
-@inline(__always) func debugLog(domain: LogDomain = .none, _ args: Any...) {
+@inline(__always) public func debugLog(domain: LogDomain = .none, _ args: Any...) {
#if DEBUG
- print(domain.rawValue, formatter.string(from: Date()), args)
+ print(domain.raw.rawValue, formatter.string(from: Date()), args)
guard var url = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first else {
return
diff --git a/Sources/CypherMessaging/_Internal/Models+Protocol.swift b/Sources/CypherMessaging/_Internal/Models+Protocol.swift
index c87cb50..8ca9061 100644
--- a/Sources/CypherMessaging/_Internal/Models+Protocol.swift
+++ b/Sources/CypherMessaging/_Internal/Models+Protocol.swift
@@ -5,54 +5,76 @@ public protocol MetadataProps {
var metadata: Document { get set }
}
-public protocol Model: Codable {
- associatedtype SecureProps: Codable
+public protocol Model: Codable, Sendable {
+ associatedtype SecureProps: Codable & Sendable
var id: UUID { get }
- var props: Encrypted { get set }
-// func setProps(to props: Encrypted) async
-
-// func save(on store: CypherMessengerStore) async throws
+ var props: Encrypted { get }
}
// TODO: Re-enable cache, and reuse the cache globally
-public final class DecryptedModel {
- private let lock = NSLock()
+public final class DecryptedModel: @unchecked Sendable {
public let encrypted: M
public var id: UUID { encrypted.id }
- public private(set) var props: M.SecureProps
+ @MainActor public private(set) var props: M.SecureProps
private let encryptionKey: SymmetricKey
- public func withLock(_ run: () async throws -> T) async rethrows -> T {
- lock.lock()
- do {
- let result = try await run()
- lock.unlock()
- return result
- } catch {
- lock.unlock()
- throw error
+ @MainActor
+ public func withProps(get: @Sendable (M.SecureProps) async throws -> T) async throws -> T {
+ let props = try encrypted.props.decrypt(using: encryptionKey)
+ return try await get(props)
+ }
+
+ @MainActor
+ public func modifyProps(run: @MainActor @Sendable (inout M.SecureProps) throws -> T) throws -> T {
+ let value = try run(&props)
+ try encrypted.props.update(to: props, using: encryptionKey)
+ return value
+ }
+
+ @MainActor
+ public func setProp(at keyPath: WritableKeyPath, to value: T) throws {
+ try modifyProps { props in
+ props[keyPath: keyPath] = value
}
}
- public func withProps(get: (M.SecureProps) async throws -> T) async throws -> T {
+ @MainActor
+ init(model: M, encryptionKey: SymmetricKey) throws {
+ self.encrypted = model
+ self.encryptionKey = encryptionKey
+ self.props = try model.props.decrypt(using: encryptionKey)
+ }
+}
+
+internal final class _DecryptedModel: @unchecked Sendable {
+ internal let encrypted: M
+ internal var id: UUID { encrypted.id }
+ @CryptoActor public private(set) var props: M.SecureProps
+ private let encryptionKey: SymmetricKey
+
+ @CryptoActor
+ internal func withProps(get: @Sendable (M.SecureProps) async throws -> T) async throws -> T {
let props = try encrypted.props.decrypt(using: encryptionKey)
return try await get(props)
}
- public func modifyProps(run: (inout M.SecureProps) async throws -> T) async throws -> T {
- let value = try await run(&props)
- try await encrypted.props.update(to: props, using: encryptionKey)
+ @CryptoActor
+ internal func modifyProps(run: @CryptoActor @Sendable (inout M.SecureProps) throws -> T) throws -> T {
+ let value = try run(&props)
+ try encrypted.props.update(to: props, using: encryptionKey)
return value
}
- public func setProp(at keyPath: WritableKeyPath, to value: T) async throws {
- try await modifyProps { props in
+ @CryptoActor
+ internal func setProp(at keyPath: WritableKeyPath, to value: T) throws {
+ try modifyProps { props in
props[keyPath: keyPath] = value
}
}
- init(model: M, encryptionKey: SymmetricKey) async throws {
+ @CryptoActor
+ init(model: M, encryptionKey: SymmetricKey) throws {
self.encrypted = model
self.encryptionKey = encryptionKey
self.props = try model.props.decrypt(using: encryptionKey)
diff --git a/Sources/CypherMessaging/_Internal/Models.swift b/Sources/CypherMessaging/_Internal/Models.swift
index f6fa2ba..866d400 100644
--- a/Sources/CypherMessaging/_Internal/Models.swift
+++ b/Sources/CypherMessaging/_Internal/Models.swift
@@ -3,16 +3,19 @@ import BSON
import Foundation
import CypherProtocol
-public final class ConversationModel: Model {
- public struct SecureProps: Codable, MetadataProps {
+public final class ConversationModel: Model, @unchecked Sendable {
+ public struct SecureProps: Codable, @unchecked Sendable, MetadataProps {
+ // TODO: Shorter CodingKeys
+
public var members: Set
+ public var kickedMembers: Set
public var metadata: Document
public var localOrder: Int
}
public let id: UUID
- public var props: Encrypted
+ public let props: Encrypted
public init(id: UUID, props: Encrypted) {
self.id = id
@@ -29,25 +32,37 @@ public final class ConversationModel: Model {
}
extension DecryptedModel where M == ConversationModel {
- public var members: Set {
+ @MainActor public var members: Set {
get { props.members }
}
- public var metadata: Document {
+ @MainActor public var kickedMembers: Set {
+ get { props.kickedMembers }
+ }
+ @MainActor public var allHistoricMembers: Set {
+ get {
+ var members = members
+ members.formUnion(kickedMembers)
+ return members
+ }
+ }
+ @MainActor public var metadata: Document {
get { props.metadata }
}
- public var localOrder: Int {
+ @MainActor public var localOrder: Int {
get { props.localOrder }
}
- func getNextLocalOrder() async throws -> Int {
- let order = localOrder
+ @CryptoActor func getNextLocalOrder() async throws -> Int {
+ let order = await localOrder
try await setProp(at: \.localOrder, to: order &+ 1)
return order
}
}
-public final class DeviceIdentityModel: Model {
- public struct SecureProps: Codable {
+public final class DeviceIdentityModel: Model, @unchecked Sendable {
+ public struct SecureProps: Codable, @unchecked Sendable {
+ // TODO: Shorter CodingKeys
+
let username: Username
let deviceId: DeviceId
let senderId: Int
@@ -55,11 +70,16 @@ public final class DeviceIdentityModel: Model {
let identity: PublicSigningKey
let isMasterDevice: Bool
var doubleRatchet: DoubleRatchetHKDF.State?
+ var deviceName: String?
+
+ // TODO: Verify identity on the server later when possible
+ var serverVerified: Bool?
+ var lastRekey: Date?
}
public let id: UUID
- public var props: Encrypted
+ public let props: Encrypted
public init(id: UUID, props: Encrypted) {
self.id = id
@@ -75,35 +95,43 @@ public final class DeviceIdentityModel: Model {
}
}
-extension DecryptedModel where M == DeviceIdentityModel {
- public var username: Username {
+extension _DecryptedModel where M == DeviceIdentityModel {
+ @CryptoActor var username: Username {
get { props.username }
}
- public var deviceId: DeviceId {
+ @CryptoActor var deviceId: DeviceId {
get { props.deviceId }
}
- public var isMasterDevice: Bool {
+ @CryptoActor var isMasterDevice: Bool {
get { props.isMasterDevice }
}
- public var senderId: Int {
+ @CryptoActor var senderId: Int {
get { props.senderId }
}
- public var publicKey: PublicKey {
+ @CryptoActor var publicKey: PublicKey {
get { props.publicKey }
}
- public var identity: PublicSigningKey {
+ @CryptoActor var identity: PublicSigningKey {
get { props.identity }
}
- public var doubleRatchet: DoubleRatchetHKDF.State? {
+ @CryptoActor var doubleRatchet: DoubleRatchetHKDF.State? {
get { props.doubleRatchet }
}
- func updateDoubleRatchetState(to newValue: DoubleRatchetHKDF.State?) async throws {
- try await setProp(at: \.doubleRatchet, to: newValue)
+ @CryptoActor var deviceName: String? {
+ get { props.deviceName }
+ }
+ @CryptoActor func updateDoubleRatchetState(to newValue: DoubleRatchetHKDF.State?) throws {
+ try setProp(at: \.doubleRatchet, to: newValue)
+ }
+ @CryptoActor func updateDeviceName(to newValue: String?) throws {
+ try setProp(at: \.deviceName, to: newValue)
}
}
-public final class ContactModel: Model {
- public struct SecureProps: Codable, MetadataProps {
+public final class ContactModel: Model, @unchecked Sendable {
+ public struct SecureProps: Codable, @unchecked Sendable, MetadataProps {
+ // TODO: Shorter CodingKeys
+
public let username: Username
public internal(set) var config: UserConfig
public var metadata: Document
@@ -111,7 +139,7 @@ public final class ContactModel: Model {
public let id: UUID
- public var props: Encrypted
+ public let props: Encrypted
public init(id: UUID, props: Encrypted) {
self.id = id
@@ -128,27 +156,27 @@ public final class ContactModel: Model {
}
extension DecryptedModel where M == ContactModel {
- public var username: Username {
+ @MainActor public var username: Username {
get { props.username }
}
- public var config: UserConfig {
+ @MainActor public var config: UserConfig {
get { props.config }
}
- public var metadata: Document {
+ @MainActor public var metadata: Document {
get { props.metadata }
}
- func updateConfig(to newValue: UserConfig) async throws {
+ @CryptoActor func updateConfig(to newValue: UserConfig) async throws {
try await self.setProp(at: \.config, to: newValue)
}
}
-public enum MarkMessageResult {
+public enum MarkMessageResult: Sendable {
case success, error, notModified
}
-@available(macOS 12, iOS 15, *)
-public final class ChatMessageModel: Model {
- public enum DeliveryState: Int, Codable {
+@available(macOS 10.15, iOS 13, *)
+public final class ChatMessageModel: Model, @unchecked Sendable {
+ public enum DeliveryState: Int, Codable, Sendable {
case none = 0
case undelivered = 1
case received = 2
@@ -169,7 +197,7 @@ public final class ChatMessageModel: Model {
}
}
- public struct SecureProps: Codable {
+ public struct SecureProps: Codable, @unchecked Sendable {
private enum CodingKeys: String, CodingKey {
case sendDate = "a"
case receiveDate = "b"
@@ -177,6 +205,7 @@ public final class ChatMessageModel: Model {
case message = "d"
case senderUser = "e"
case senderDeviceId = "f"
+ case deliveryStates = "g"
}
public let sendDate: Date
@@ -185,6 +214,7 @@ public final class ChatMessageModel: Model {
public var message: SingleCypherMessage
public let senderUser: Username
public let senderDeviceId: DeviceId
+ public internal(set) var deliveryStates: Document?
init(
sending message: SingleCypherMessage,
@@ -197,6 +227,7 @@ public final class ChatMessageModel: Model {
self.message = message
self.senderUser = senderUser
self.senderDeviceId = senderDeviceId
+ self.deliveryStates = [:]
}
init(
@@ -211,6 +242,7 @@ public final class ChatMessageModel: Model {
self.message = message
self.senderUser = senderUser
self.senderDeviceId = senderDeviceId
+ self.deliveryStates = [:]
}
}
@@ -224,7 +256,7 @@ public final class ChatMessageModel: Model {
// `remoteId` must be unique, or rejected when saving
public let remoteId: String
- public var props: Encrypted
+ public let props: Encrypted
public init(
id: UUID,
@@ -259,38 +291,71 @@ public final class ChatMessageModel: Model {
}
}
+public struct DeliveryStates {
+ var document: Document
+
+ public subscript(username: Username) -> ChatMessageModel.DeliveryState {
+ get {
+ if
+ let currentStateCode = document[username.raw] as? Int,
+ let currentState = ChatMessageModel.DeliveryState(rawValue: currentStateCode)
+ {
+ return currentState
+ } else {
+ return .none
+ }
+ }
+ set {
+ document[username.raw] = newValue.rawValue
+ }
+ }
+}
+
extension DecryptedModel where M == ChatMessageModel {
- public var sendDate: Date {
+ @MainActor public var sendDate: Date {
get { props.sendDate }
}
- public var receiveDate: Date {
+ @MainActor public var receiveDate: Date {
get { props.receiveDate }
}
- public var deliveryState: ChatMessageModel.DeliveryState {
+ @MainActor public var deliveryState: ChatMessageModel.DeliveryState {
get { props.deliveryState }
}
- public var message: SingleCypherMessage {
+ @MainActor var _deliveryStates: Document {
+ get { props.deliveryStates ?? [:] }
+ }
+ @MainActor var deliveryStates: DeliveryStates {
+ get { DeliveryStates(document: _deliveryStates) }
+ }
+ @MainActor public var message: SingleCypherMessage {
get { props.message }
}
- public var senderUser: Username {
+ @MainActor public var senderUser: Username {
get { props.senderUser }
}
- public var senderDeviceId: DeviceId {
+ @MainActor public var senderDeviceId: DeviceId {
get { props.senderDeviceId }
}
@discardableResult
- func transitionDeliveryState(to newState: ChatMessageModel.DeliveryState) async throws -> MarkMessageResult {
- var state = self.deliveryState
- let result = state.transition(to: newState)
- try await setProp(at: \.deliveryState, to: state)
+ @CryptoActor func transitionDeliveryState(to newState: ChatMessageModel.DeliveryState, forUser user: Username, messenger: CypherMessenger) async throws -> MarkMessageResult {
+ if user != messenger.username {
+ var state = await self.deliveryState
+ state.transition(to: newState)
+ try await setProp(at: \.deliveryState, to: state)
+ }
+
+ var allStates = await self.deliveryStates
+ let result = allStates[user].transition(to: newState)
+ try await setProp(at: \.deliveryStates, to: allStates.document)
+
return result
}
}
-@available(macOS 12, iOS 15, *)
-public final class JobModel: Model {
- public struct SecureProps: Codable {
+@available(macOS 10.15, iOS 13, *)
+public final class JobModel: Model, @unchecked Sendable {
+ public struct SecureProps: Codable, @unchecked Sendable {
private enum CodingKeys: String, CodingKey {
case taskKey = "a"
case task = "b"
@@ -307,18 +372,18 @@ public final class JobModel: Model {
var attempts: Int
let isBackgroundTask: Bool
- init(task: T) throws {
+ init(task: T, scheduledAt: Date = Date()) throws {
self.taskKey = task.key.rawValue
self.isBackgroundTask = task.isBackgroundTask
self.task = try BSONEncoder().encode(task)
- self.scheduledAt = Date()
+ self.scheduledAt = scheduledAt
self.attempts = 0
}
}
// The concrete type is used to avoid collision with Identifiable
public let id: UUID
- public var props: Encrypted
+ public let props: Encrypted
public init(id: UUID, props: Encrypted) {
self.id = id
@@ -331,27 +396,27 @@ public final class JobModel: Model {
}
}
-extension DecryptedModel where M == JobModel {
- public var taskKey: String {
+extension _DecryptedModel where M == JobModel {
+ @CryptoActor var taskKey: String {
get { props.taskKey }
}
- public var task: Document {
+ @CryptoActor var task: Document {
get { props.task }
}
- public var delayedUntil: Date? {
+ @CryptoActor var delayedUntil: Date? {
get { props.delayedUntil }
}
- public var scheduledAt: Date {
+ @CryptoActor var scheduledAt: Date {
get { props.scheduledAt }
}
- public var attempts: Int {
+ @CryptoActor var attempts: Int {
get { props.attempts }
}
- public var isBackgroundTask: Bool {
+ @CryptoActor var isBackgroundTask: Bool {
get { props.isBackgroundTask }
}
- func delayExecution(retryDelay: TimeInterval) async throws {
- try await setProp(at: \.delayedUntil, to: Date().addingTimeInterval(retryDelay))
- try await setProp(at: \.attempts, to: self.attempts + 1)
+ @CryptoActor func delayExecution(retryDelay: TimeInterval) throws {
+ try setProp(at: \.delayedUntil, to: Date().addingTimeInterval(retryDelay))
+ try setProp(at: \.attempts, to: self.attempts + 1)
}
}
diff --git a/Sources/CypherProtocol/CryptoPrimitives.swift b/Sources/CypherProtocol/CryptoPrimitives.swift
index cf0dcda..743ab87 100644
--- a/Sources/CypherProtocol/CryptoPrimitives.swift
+++ b/Sources/CypherProtocol/CryptoPrimitives.swift
@@ -2,7 +2,7 @@ import NIOFoundationCompat
import BSON
import NIO
import Foundation
-import CryptoKit
+import Crypto
typealias PrivateSigningKeyAlg = Curve25519.Signing.PrivateKey
typealias PublicSigningKeyAlg = Curve25519.Signing.PublicKey
@@ -17,7 +17,7 @@ enum CypherProtocolError: Error {
///
/// Private keys are used to sign data, as to authenticate that it was sent by the owner of this private key.
/// The `publicKey` can be shared, and can then be used to verify the signature's validity.
-public struct PrivateSigningKey: Codable {
+public struct PrivateSigningKey: Codable, @unchecked Sendable {
fileprivate let privateKey: PrivateSigningKeyAlg
/// The public key that can verify signatures of this private key, wrapped in a Codable container.
@@ -46,13 +46,21 @@ public struct PrivateSigningKey: Codable {
/// A wrapper around Curve25519 public _signing_ keys that provides Codable support using `Foundation.Data`
///
/// Public signing keys are used to verify signatures by the matching private key.
-public struct PublicSigningKey: Codable {
+public struct PublicSigningKey: Codable, @unchecked Sendable {
fileprivate let publicKey: PublicSigningKeyAlg
fileprivate init(publicKey: PublicSigningKeyAlg) {
self.publicKey = publicKey
}
+ public init?(data: Data) {
+ do {
+ publicKey = try PublicSigningKeyAlg(rawRepresentation: data)
+ } catch {
+ return nil
+ }
+ }
+
public func encode(to encoder: Encoder) throws {
try Binary(buffer: ByteBuffer(data: publicKey.rawRepresentation)).encode(to: encoder)
}
@@ -97,7 +105,7 @@ public struct PublicSigningKey: Codable {
/// A wrapper around Curve25519 private keys that provides Codable support using `Foundation.Data`
///
/// This Private Key type is used for handshakes, to establish a shared secret key over unsafe communication channels.
-public struct PrivateKey: Codable {
+public struct PrivateKey: Codable, @unchecked Sendable {
fileprivate let privateKey: PrivateKeyAgreementKeyAlg
/// Derives a `PublicKey` that can be sent to a third party.
@@ -142,13 +150,25 @@ public struct PrivateKey: Codable {
/// A key that is derived from `PrivateKey`.
/// Used to create a shared secret, known only to the owner of the PrivateKeys that shared their PublicKey.
-public struct PublicKey: Codable, Equatable {
+public struct PublicKey: Codable, Equatable, @unchecked Sendable {
fileprivate let publicKey: PublicKeyAgreementKeyAlg
fileprivate init(publicKey: PublicKeyAgreementKeyAlg) {
self.publicKey = publicKey
}
+ public init?(data: Data) {
+ do {
+ publicKey = try PublicKeyAgreementKeyAlg(rawRepresentation: data)
+ } catch {
+ return nil
+ }
+ }
+
+ public var data: Data {
+ publicKey.rawRepresentation
+ }
+
public func encode(to encoder: Encoder) throws {
try publicKey.rawRepresentation.encode(to: encoder)
}
diff --git a/Sources/CypherProtocol/DeviceId.swift b/Sources/CypherProtocol/DeviceId.swift
index ebdb8ed..f16ea47 100644
--- a/Sources/CypherProtocol/DeviceId.swift
+++ b/Sources/CypherProtocol/DeviceId.swift
@@ -1,7 +1,7 @@
import Foundation
/// A helper wrapper around `String`, so that the type cannot be used interchangably with other String based types
-public struct DeviceId: CustomStringConvertible, Identifiable, Codable, Hashable, Equatable, Comparable {
+public struct DeviceId: CustomStringConvertible, Identifiable, Codable, Hashable, Equatable, Comparable, Sendable {
public let raw: String
public static func ==(lhs: DeviceId, rhs: DeviceId) -> Bool {
diff --git a/Sources/CypherProtocol/DoubleRatchet.swift b/Sources/CypherProtocol/DoubleRatchet.swift
index e33dd1c..db7bc72 100644
--- a/Sources/CypherProtocol/DoubleRatchet.swift
+++ b/Sources/CypherProtocol/DoubleRatchet.swift
@@ -2,7 +2,7 @@
// TODO: Header encryption
import Foundation
-import CryptoKit
+import Crypto
/// A symmetric key encryption & decryption helper
public protocol RatchetSymmetricEncryption {
@@ -184,7 +184,7 @@ public struct SkippedKey: Codable {
}
public struct DoubleRatchetHKDF {
- public struct State: Codable {
+ public struct State: Codable, @unchecked Sendable {
private enum CodingKeys: String, CodingKey {
case rootKey = "a"
case localPrivateKey = "b"
@@ -328,7 +328,10 @@ public struct DoubleRatchetHKDF {
) throws -> (DoubleRatchetHKDF, Data) {
let state = try State(secretKey: secretKey, localPrivateKey: localPrivateKey, configuration: configuration)
var engine = DoubleRatchetHKDF(state: state, configuration: configuration)
- let plaintext = try engine.ratchetDecrypt(initialMessage)
+ guard case let .success(plaintext) = try engine.ratchetDecrypt(initialMessage) else {
+ throw DoubleRatchetError.uninitializedRecipient
+ }
+
return (engine, plaintext)
}
@@ -378,7 +381,7 @@ public struct DoubleRatchetHKDF {
/// The message used as input _must_ be created by the remote party `ratchetEncrypt`
///
/// - Returns: The decrypted data
- public mutating func ratchetDecrypt(_ message: RatchetMessage) throws -> Data {
+ public mutating func ratchetDecrypt(_ message: RatchetMessage) throws -> RatchetDecryptionResult {
var skippedKeys = state.skippedKeys
defer {
state.skippedKeys = skippedKeys
@@ -407,7 +410,7 @@ public struct DoubleRatchetHKDF {
}
}
- func decodeUsingSkippedMessageKeys() throws -> Data? {
+ func decodeUsingSkippedMessageKeys() throws -> RatchetDecryptionResult? {
for i in 0.. {
}
// 1. Try skipped message keys
- if let plaintext = try decodeUsingSkippedMessageKeys() {
- return plaintext
+ if let result = try decodeUsingSkippedMessageKeys() {
+ return result
}
// 2. Check if the publicKey matches the current key
@@ -452,6 +455,8 @@ public struct DoubleRatchetHKDF {
try skipMessageKeys(until: message.header.previousChainLength)
state.skippedKeys = skippedKeys
try diffieHellmanRatchet()
+ } else if message.header.messageNumber < state.receivedMessages {
+ return .keyExpiry
}
// 3.a. On-mismatch, Skip ahead in message keys until max. Store all the inbetween message keys in a history
@@ -469,7 +474,7 @@ public struct DoubleRatchetHKDF {
return try decryptMessage(message, usingKey: messageKey)
}
- private func decryptMessage(_ message: RatchetMessage, usingKey messageKey: SymmetricKey) throws -> Data {
+ private func decryptMessage(_ message: RatchetMessage, usingKey messageKey: SymmetricKey) throws -> RatchetDecryptionResult {
let headerData = try configuration.headerEncoder.encodeRatchetHeader(message.header)
let nonce = configuration.headerEncoder.concatenate(
authenticatedData: configuration.headerAssociatedDataGenerator.generateAssociatedData(),
@@ -480,11 +485,11 @@ public struct DoubleRatchetHKDF {
throw DoubleRatchetError.invalidNonceLength
}
- return try configuration.symmetricEncryption.decrypt(
+ return .success(try configuration.symmetricEncryption.decrypt(
message.ciphertext,
nonce: nonce,
usingKey: messageKey
- )
+ ))
}
}
@@ -515,6 +520,11 @@ public struct RatchetMessage: Codable {
let ciphertext: Data
}
+public enum RatchetDecryptionResult {
+ case success(Data)
+ case keyExpiry
+}
+
enum DoubleRatchetError: Error {
case invalidRootKeySize, uninitializedRecipient, tooManySkippedMessages, invalidNonceLength
}
diff --git a/Sources/CypherProtocol/Message.swift b/Sources/CypherProtocol/Message.swift
index e01339e..44fb984 100644
--- a/Sources/CypherProtocol/Message.swift
+++ b/Sources/CypherProtocol/Message.swift
@@ -136,9 +136,13 @@ extension RatchetMessage {
public func decrypt(
as type: D.Type,
using engine: inout DoubleRatchetHKDF
- ) throws -> D {
- let data = try engine.ratchetDecrypt(self)
- return try BSONDecoder().decode(type, from: Document(data: data))
+ ) throws -> D? {
+ switch try engine.ratchetDecrypt(self) {
+ case .success(let data):
+ return try BSONDecoder().decode(type, from: Document(data: data))
+ case .keyExpiry:
+ return nil
+ }
}
public init(
diff --git a/Sources/CypherProtocol/Username.swift b/Sources/CypherProtocol/Username.swift
index 0615d20..b32f721 100644
--- a/Sources/CypherProtocol/Username.swift
+++ b/Sources/CypherProtocol/Username.swift
@@ -1,5 +1,5 @@
/// A helper wrapper around `String`, so that the type cannot be used interchangably with other String based types
-public struct Username: CustomStringConvertible, Identifiable, Codable, Hashable, Equatable, Comparable, ExpressibleByStringLiteral {
+public struct Username: CustomStringConvertible, Identifiable, Codable, Hashable, Equatable, Comparable, ExpressibleByStringLiteral, Sendable {
public let raw: String
public static func ==(lhs: Username, rhs: Username) -> Bool {
diff --git a/Sources/MessagingHelpers/Plugin.swift b/Sources/MessagingHelpers/Plugin.swift
index ddbb0a5..74dc1ec 100644
--- a/Sources/MessagingHelpers/Plugin.swift
+++ b/Sources/MessagingHelpers/Plugin.swift
@@ -1,6 +1,6 @@
import CypherMessaging
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
public protocol Plugin {
static var pluginIdentifier: String { get }
@@ -21,7 +21,9 @@ public protocol Plugin {
func onP2PClientClose(messenger: CypherMessenger)
func onRemoveContact(_ contact: Contact)
func onRemoveChatMessage(_ message: AnyChatMessage)
- func onDeviceRegistery(_ deviceId: DeviceId, messenger: CypherMessenger) async throws
+ func onDeviceRegistery(_ deviceId: DeviceId, messenger: CypherMessenger)
+ func onOtherUserDeviceRegistery(username: Username, deviceId: DeviceId, messenger: CypherMessenger)
+ func onCustomConfigChange()
}
extension Plugin {
@@ -42,78 +44,51 @@ extension Plugin {
public func onP2PClientClose(messenger: CypherMessenger) {}
public func onRemoveContact(_ contact: Contact) {}
public func onRemoveChatMessage(_ message: AnyChatMessage) {}
- public func onDeviceRegistery(_ deviceId: DeviceId, messenger: CypherMessenger) async throws {}
+ public func onDeviceRegistery(_ deviceId: DeviceId, messenger: CypherMessenger) {}
+ public func onOtherUserDeviceRegistery(username: Username, deviceId: DeviceId, messenger: CypherMessenger) {}
+ public func onCustomConfigChange() {}
}
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
extension Plugin {
public var pluginIdentifier: String { Self.pluginIdentifier }
}
-@available(macOS 12, iOS 15, *)
-extension DecryptedModel where M == ContactModel {
- public func getProp(
- fromMetadata type: C.Type,
- forPlugin plugin: P.Type,
- run: (C) throws -> Result
- ) throws -> Result {
- let pluginStorage = metadata[plugin.pluginIdentifier] ?? Document()
- let pluginMetadata = try BSONDecoder().decode(type, fromPrimitive: pluginStorage)
- return try run(pluginMetadata)
- }
-
- public func withMetadata(
- ofType type: C.Type,
- forPlugin plugin: P.Type,
- run: (inout C) throws -> Result
- ) async throws -> Result {
- var metadata = self.metadata
- let pluginStorage = metadata[plugin.pluginIdentifier] ?? Document()
- var pluginMetadata = try BSONDecoder().decode(type, fromPrimitive: pluginStorage)
- let result = try run(&pluginMetadata)
- metadata[plugin.pluginIdentifier] = try BSONEncoder().encode(pluginMetadata)
- try await self.setProp(at: \.metadata, to: metadata)
-
- return result
- }
-}
-
extension Contact {
- public func modifyMetadata(
+ @MainActor public func modifyMetadata(
ofType type: C.Type,
forPlugin plugin: P.Type,
run: (inout C) throws -> Result
) async throws -> Result {
- let result = try await model.withMetadata(ofType: type, forPlugin: plugin, run: run)
-
+ let result = try model.withMetadata(ofType: type, forPlugin: plugin, run: run)
try await self.save()
return result
}
}
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
extension DecryptedModel where M.SecureProps: MetadataProps {
- public func getProp(
+ @MainActor public func getProp(
ofType type: C.Type,
forPlugin plugin: P.Type,
- run: (C) throws -> Result
+ run: @Sendable (C) throws -> Result
) throws -> Result {
let pluginStorage = props.metadata[plugin.pluginIdentifier] ?? Document()
let pluginMetadata = try BSONDecoder().decode(type, fromPrimitive: pluginStorage)
return try run(pluginMetadata)
}
- public func withMetadata(
+ @MainActor public func withMetadata(
ofType type: C.Type,
forPlugin plugin: P.Type,
run: (inout C) throws -> Result
- ) async throws -> Result {
+ ) throws -> Result {
var metadata = self.props.metadata
let pluginStorage = metadata[plugin.pluginIdentifier] ?? Document()
var pluginMetadata = try BSONDecoder().decode(type, fromPrimitive: pluginStorage)
let result = try run(&pluginMetadata)
metadata[plugin.pluginIdentifier] = try BSONEncoder().encode(pluginMetadata)
- try await self.setProp(at: \.metadata, to: metadata)
+ try self.setProp(at: \.metadata, to: metadata)
return result
}
@@ -123,7 +98,7 @@ extension AnyConversation {
public func modifyMetadata(
ofType type: C.Type,
forPlugin plugin: P.Type,
- run: (inout C) throws -> Result
+ run: @Sendable (inout C) throws -> Result
) async throws -> Result {
let result = try await conversation.withMetadata(ofType: type, forPlugin: plugin, run: run)
try await self.save()
@@ -131,7 +106,7 @@ extension AnyConversation {
}
}
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
extension CypherMessenger {
public func withCustomConfig(
ofType type: C.Type,
diff --git a/Sources/MessagingHelpers/PluginEventHandler.swift b/Sources/MessagingHelpers/PluginEventHandler.swift
index 9612947..59322fd 100644
--- a/Sources/MessagingHelpers/PluginEventHandler.swift
+++ b/Sources/MessagingHelpers/PluginEventHandler.swift
@@ -1,6 +1,6 @@
import CypherMessaging
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
public struct PluginEventHandler: CypherMessengerEventHandler {
private var plugins: [Plugin]
@@ -30,8 +30,6 @@ public struct PluginEventHandler: CypherMessengerEventHandler {
public func onReceiveMessage(
_ message: ReceivedMessageContext
) async throws -> ProcessMessageAction {
- // TODO: Parse synchronisation messages
-
for plugin in plugins {
if let result = try await plugin.onReceiveMessage(message) {
return result
@@ -167,11 +165,21 @@ public struct PluginEventHandler: CypherMessengerEventHandler {
}
}
- public func onDeviceRegistery(_ deviceId: DeviceId, messenger: CypherMessenger) async throws {
+ public func onDeviceRegistery(_ deviceId: DeviceId, messenger: CypherMessenger) {
for plugin in plugins {
- try await plugin.onDeviceRegistery(deviceId, messenger: messenger)
+ plugin.onDeviceRegistery(deviceId, messenger: messenger)
+ }
+ }
+
+ public func onOtherUserDeviceRegistery(username: Username, deviceId: DeviceId, messenger: CypherMessenger) {
+ for plugin in plugins {
+ plugin.onOtherUserDeviceRegistery(username: username, deviceId: deviceId, messenger: messenger)
+ }
+ }
+
+ public func onCustomConfigChange() {
+ for plugin in plugins {
+ plugin.onCustomConfigChange()
}
-
- // TODO: Synchronise state to new device
}
}
diff --git a/Sources/MessagingHelpers/Plugins/ChatActivityPlugin/ChatActivityPlugin.swift b/Sources/MessagingHelpers/Plugins/ChatActivityPlugin/ChatActivityPlugin.swift
index 5002e73..508bfc2 100644
--- a/Sources/MessagingHelpers/Plugins/ChatActivityPlugin/ChatActivityPlugin.swift
+++ b/Sources/MessagingHelpers/Plugins/ChatActivityPlugin/ChatActivityPlugin.swift
@@ -8,7 +8,7 @@ fileprivate struct ChatActivityMetadata: Codable {
// TODO: Use synchronisation framework for own devices
// TODO: Select contacts to share the profile changes with
// TODO: Broadcast to a user that doesn't have a private chat
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
public struct ChatActivityPlugin: Plugin {
public static let pluginIdentifier = "@/chats/activity"
@@ -47,9 +47,9 @@ public struct ChatActivityPlugin: Plugin {
}
}
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
extension AnyConversation {
- public var lastActivity: Date? {
+ @MainActor public var lastActivity: Date? {
try? self.conversation.getProp(
ofType: ChatActivityMetadata.self,
forPlugin: ChatActivityPlugin.self,
diff --git a/Sources/MessagingHelpers/Plugins/ContactProfile/UserProfilePlugin.swift b/Sources/MessagingHelpers/Plugins/ContactProfile/UserProfilePlugin.swift
index dee24a4..b79646f 100644
--- a/Sources/MessagingHelpers/Plugins/ContactProfile/UserProfilePlugin.swift
+++ b/Sources/MessagingHelpers/Plugins/ContactProfile/UserProfilePlugin.swift
@@ -4,13 +4,16 @@ import NIO
public struct ContactMetadata: Codable {
public var status: String?
public var nickname: String?
+ public var firstName: String?
+ public var lastName: String?
+ public var email: String?
+ public var phone: String?
public var image: Data?
}
-// TODO: Use synchronisation framework for own devices
// TODO: Select contacts to share the profile changes with
// TODO: Broadcast to a user that doesn't have a private chat
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
public struct UserProfilePlugin: Plugin {
enum RekeyAction {
case none, resetProfile
@@ -20,14 +23,88 @@ public struct UserProfilePlugin: Plugin {
public init() {}
- public func onContactIdentityChange(username: Username, messenger: CypherMessenger) {
- Task.detached {
- let contact = try await messenger.createContact(byUsername: username)
- try await contact.modifyMetadata(
+ public func onDeviceRegistery(_ deviceId: DeviceId, messenger: CypherMessenger) {
+ Task {
+ let internalChat = try await messenger.getInternalConversation()
+
+ try await messenger.withCustomConfig(
ofType: ContactMetadata.self,
forPlugin: Self.self
) { metadata in
- metadata = .init()
+ if let status = metadata.status {
+ try await internalChat.sendMagicPacket(
+ messageSubtype: "@/contacts/profile/status/update",
+ text: status,
+ toDeviceId: deviceId
+ )
+ }
+
+ if let firstName = metadata.firstName, let lastName = metadata.lastName {
+ try await internalChat.sendMagicPacket(
+ messageSubtype: "@/contacts/profile/name/update",
+ text: "",
+ metadata: [
+ "firstName": firstName,
+ "lastName": lastName,
+ ],
+ toDeviceId: deviceId
+ )
+ }
+
+ if let image = metadata.image {
+ try await internalChat.sendMagicPacket(
+ messageSubtype: "@/contacts/profile/picture/update",
+ text: "",
+ metadata: [
+ "blob": Binary(buffer: ByteBuffer(data: image))
+ ],
+ toDeviceId: deviceId
+ )
+ }
+ }
+ }
+ }
+
+ public func onOtherUserDeviceRegistery(username: Username, deviceId: DeviceId, messenger: CypherMessenger) {
+ Task {
+ // TODO: Select contacts to share the profile changes with
+ // TODO: Broadcast to a user that doesn't have a private chat
+ let chat = try await messenger.createPrivateChat(with: username)
+
+ try await messenger.withCustomConfig(
+ ofType: ContactMetadata.self,
+ forPlugin: Self.self
+ ) { metadata in
+ if let status = metadata.status {
+ try await chat.sendMagicPacket(
+ messageSubtype: "@/contacts/profile/status/update",
+ text: status,
+ toDeviceId: deviceId
+ )
+ }
+
+ if let firstName = metadata.firstName, let lastName = metadata.lastName {
+ try await chat.sendMagicPacket(
+ messageSubtype: "@/contacts/profile/name/update",
+ text: "",
+ metadata: [
+ "firstName": firstName,
+ "lastName": lastName,
+ ],
+ toDeviceId: deviceId
+ )
+ }
+
+ if let image = metadata.image {
+ try await chat.sendMagicPacket(
+ messageSubtype: "@/contacts/profile/picture/update",
+ text: "",
+ metadata: [
+ "blob": Binary(buffer: ByteBuffer(data: image))
+ ],
+ toDeviceId: deviceId
+ )
+ }
}
}
}
@@ -46,51 +123,50 @@ public struct UserProfilePlugin: Plugin {
let sender = message.sender.username
let username = messenger.username
- switch subType {
- case "status/update":
+ func withMetadata(perform: @escaping (inout ContactMetadata) -> ()) async throws -> ProcessMessageAction {
if sender == username {
return try await messenger.modifyCustomConfig(
ofType: ContactMetadata.self,
forPlugin: Self.self
) { metadata in
- metadata.status = message.message.text
+ perform(&metadata)
return .ignore
}
- }
-
- let contact = try await messenger.createContact(byUsername: sender)
- return try await contact.modifyMetadata(
- ofType: ContactMetadata.self,
- forPlugin: Self.self
- ) { metadata in
- metadata.status = message.message.text
- return .ignore
- }
- case "picture/update":
- guard let imageBlob = message.message.metadata["blob"] as? Binary else {
- return .ignore
- }
-
- let image = imageBlob.data
-
- if sender == username {
- return try await messenger.modifyCustomConfig(
+ } else {
+ let contact = try await messenger.createContact(byUsername: sender)
+ return try await contact.modifyMetadata(
ofType: ContactMetadata.self,
forPlugin: Self.self
) { metadata in
- metadata.image = image
+ perform(&metadata)
return .ignore
}
}
+ }
+
+ switch subType {
+ case "status/update":
+ return try await withMetadata { $0.status = message.message.text }
+ case "name/update":
+ guard
+ let firstName = message.message.metadata["firstName"] as? String,
+ let lastName = message.message.metadata["lastName"] as? String
+ else {
+ return .ignore
+ }
- let contact = try await messenger.createContact(byUsername: sender)
- return try await contact.modifyMetadata(
- ofType: ContactMetadata.self,
- forPlugin: Self.self
- ) { metadata in
- metadata.image = image
+ return try await withMetadata { metadata in
+ metadata.firstName = firstName
+ metadata.lastName = lastName
+ }
+ case "picture/update":
+ guard let imageBlob = message.message.metadata["blob"] as? Binary else {
return .ignore
}
+
+ return try await withMetadata { metadata in
+ metadata.image = imageBlob.data
+ }
default:
return .ignore
}
@@ -111,33 +187,73 @@ public struct UserProfilePlugin: Plugin {
}
}
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
extension Contact {
- public var status: String? {
+ @MainActor public var status: String? {
try? self.model.getProp(
- fromMetadata: ContactMetadata.self,
+ ofType: ContactMetadata.self,
forPlugin: UserProfilePlugin.self,
run: \.status
)
}
- public var image: Data? {
+ @MainActor public var image: Data? {
try? self.model.getProp(
- fromMetadata: ContactMetadata.self,
+ ofType: ContactMetadata.self,
forPlugin: UserProfilePlugin.self,
run: \.image
)
}
- public var nickname: String {
+ @MainActor public var firstName: String? {
+ try? self.model.getProp(
+ ofType: ContactMetadata.self,
+ forPlugin: UserProfilePlugin.self,
+ run: \.firstName
+ )
+ }
+
+ @MainActor public var lastName: String? {
+ try? self.model.getProp(
+ ofType: ContactMetadata.self,
+ forPlugin: UserProfilePlugin.self,
+ run: \.lastName
+ )
+ }
+
+ @MainActor public var email: String? {
+ try? self.model.getProp(
+ ofType: ContactMetadata.self,
+ forPlugin: UserProfilePlugin.self,
+ run: \.email
+ )
+ }
+
+ @MainActor public var phone: String? {
+ try? self.model.getProp(
+ ofType: ContactMetadata.self,
+ forPlugin: UserProfilePlugin.self,
+ run: \.phone
+ )
+ }
+
+ @MainActor public var nickname: String? {
(try? self.model.getProp(
- fromMetadata: ContactMetadata.self,
+ ofType: ContactMetadata.self,
forPlugin: UserProfilePlugin.self,
run: \.nickname
- )) ?? self.username.raw
+ ))
+ }
+
+ @MainActor public var contactMetadata: ContactMetadata? {
+ try? self.model.getProp(
+ ofType: ContactMetadata.self,
+ forPlugin: UserProfilePlugin.self,
+ run: { $0 }
+ )
}
- public func setNickname(to nickname: String) async throws {
+ @CryptoActor public func setNickname(to nickname: String) async throws {
try await self.model.withMetadata(
ofType: ContactMetadata.self,
forPlugin: UserProfilePlugin.self
@@ -147,14 +263,14 @@ extension Contact {
}
}
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
extension CypherMessenger {
public func changeProfileStatus(
to status: String
) async throws {
for contact in try await listContacts() {
let chat = try await createPrivateChat(with: contact.model.username)
- _ = try await chat.sendRawMessage(
+ try await chat.sendRawMessage(
type: .magic,
messageSubtype: "@/contacts/profile/status/update",
text: status,
@@ -163,7 +279,7 @@ extension CypherMessenger {
}
let chat = try await getInternalConversation()
- _ = try await chat.sendRawMessage(
+ try await chat.sendRawMessage(
type: .magic,
messageSubtype: "@/contacts/profile/status/update",
text: status,
@@ -178,32 +294,53 @@ extension CypherMessenger {
}
}
- public func changeProfilePicture(
- to data: Data
- ) async throws {
+ // TODO: Update firstName, lastName, phone, email
+ // TODO: Allow app to control which contacts get this info
+
+ private func sendProfileUpdate(subtype: String, text: String, metadata: Document = [:]) async throws {
+ // TODO: limit who can see your changes?
for contact in try await listContacts() {
let chat = try await createPrivateChat(with: contact.model.username)
- _ = try await chat.sendRawMessage(
- type: .magic,
- messageSubtype: "@/contacts/profile/picture/update",
- text: "",
- metadata: [
- "blob": data
- ],
+ try await chat.sendMagicPacketMessage(
+ messageSubtype: "@/contacts/profile/\(subtype)",
+ text: text,
+ metadata: metadata,
preferredPushType: .none
)
}
let chat = try await getInternalConversation()
- _ = try await chat.sendRawMessage(
- type: .magic,
- messageSubtype: "@/contacts/profile/picture/update",
- text: "",
- metadata: [
- "blob": data
- ],
- preferredPushType: .none
+ try await chat.sendMagicPacket(
+ messageSubtype: "@/contacts/profile/\(subtype)",
+ text: text,
+ metadata: metadata
)
+ }
+
+ public func changeName(
+ firstName: String,
+ lastName: String
+ ) async throws {
+ try await sendProfileUpdate(subtype: "name/update", text: "", metadata: [
+ "firstName": firstName,
+ "lastName": lastName
+ ])
+
+ try await self.modifyCustomConfig(
+ ofType: ContactMetadata.self,
+ forPlugin: UserProfilePlugin.self
+ ) { metadata in
+ metadata.firstName = firstName
+ metadata.lastName = lastName
+ }
+ }
+
+ public func changeProfilePicture(
+ to data: Data
+ ) async throws {
+ try await sendProfileUpdate(subtype: "picture/update", text: "", metadata: [
+ "blob": data
+ ])
try await self.modifyCustomConfig(
ofType: ContactMetadata.self,
diff --git a/Sources/MessagingHelpers/Plugins/FriendshipPlugin/FriendshipPlugin.swift b/Sources/MessagingHelpers/Plugins/FriendshipPlugin/FriendshipPlugin.swift
index 2976ece..005a0b2 100644
--- a/Sources/MessagingHelpers/Plugins/FriendshipPlugin/FriendshipPlugin.swift
+++ b/Sources/MessagingHelpers/Plugins/FriendshipPlugin/FriendshipPlugin.swift
@@ -44,7 +44,7 @@ fileprivate struct ChangeFriendshipState: Codable {
let subject: Username
}
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
public struct FriendshipPlugin: Plugin {
public static let pluginIdentifier = "@/contacts/friendship"
public let ruleset: FriendshipRuleset
@@ -53,10 +53,10 @@ public struct FriendshipPlugin: Plugin {
self.ruleset = ruleset
}
- public func onReceiveMessage(_ message: ReceivedMessageContext) async throws -> ProcessMessageAction? {
+ @MainActor public func onReceiveMessage(_ message: ReceivedMessageContext) async throws -> ProcessMessageAction? {
let senderUsername = message.sender.username
let target = await message.conversation.getTarget()
- let username: Username = ""// = await message.messenger.username
+ let username = message.messenger.username
if case .groupChat = target {
if ruleset.blockAffectsGroupChats, senderUsername != username {
@@ -67,7 +67,7 @@ public struct FriendshipPlugin: Plugin {
return nil
}
- if senderUsername == username{
+ if senderUsername == username {
guard case .currentUser = target else {
return nil
}
@@ -94,9 +94,9 @@ public struct FriendshipPlugin: Plugin {
default:
()
}
-
- return .ignore
}
+
+ return nil
}
let contact = try await message.messenger.createContact(byUsername: senderUsername)
@@ -122,7 +122,7 @@ public struct FriendshipPlugin: Plugin {
case "query":
let changedState = try BSONEncoder().encode(
ChangeFriendshipState(
- newState: contact.ourState,
+ newState: contact.ourFriendshipState,
subject: contact.username
)
)
@@ -140,7 +140,7 @@ public struct FriendshipPlugin: Plugin {
}
}
- switch (contact.ourState, contact.theirState) {
+ switch (contact.ourFriendshipState, contact.theirFriendshipState) {
case (.blocked, _), (_, .blocked):
return .ignore
case (.undecided, _), (_, .undecided):
@@ -156,6 +156,14 @@ public struct FriendshipPlugin: Plugin {
}
}
+ public func onDeviceRegistery(_ deviceId: DeviceId, messenger: CypherMessenger) {
+ // TODO: Sync states to new device
+ }
+
+ public func onOtherUserDeviceRegistery(username: Username, deviceId: DeviceId, messenger: CypherMessenger) {
+ // TODO: Sync states to new device
+ }
+
public func createContactMetadata(for username: Username, messenger: CypherMessenger) async throws -> Document {
let metadata = FriendshipMetadata(
ourState: .undecided,
@@ -166,9 +174,9 @@ public struct FriendshipPlugin: Plugin {
}
}
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
extension Contact {
- public var ourState: FriendshipStatus {
+ @MainActor public var ourFriendshipState: FriendshipStatus {
(try? self.model.getProp(
ofType: FriendshipMetadata.self,
forPlugin: FriendshipPlugin.self,
@@ -176,7 +184,7 @@ extension Contact {
)) ?? .undecided
}
- public var theirState: FriendshipStatus {
+ @MainActor public var theirFriendshipState: FriendshipStatus {
(try? self.model.getProp(
ofType: FriendshipMetadata.self,
forPlugin: FriendshipPlugin.self,
@@ -184,7 +192,7 @@ extension Contact {
)) ?? .undecided
}
- public var isMutualFriendship: Bool {
+ @MainActor public var isMutualFriendship: Bool {
(try? self.model.getProp(
ofType: FriendshipMetadata.self,
forPlugin: FriendshipPlugin.self,
@@ -192,7 +200,7 @@ extension Contact {
)) ?? false
}
- public var isBlocked: Bool {
+ @MainActor public var isBlocked: Bool {
(try? self.model.getProp(
ofType: FriendshipMetadata.self,
forPlugin: FriendshipPlugin.self,
@@ -200,21 +208,21 @@ extension Contact {
)) ?? false
}
- public func block() async throws {
+ @MainActor public func block() async throws {
try await changeOurState(to: .blocked)
}
- public func befriend() async throws {
+ @MainActor public func befriend() async throws {
try await changeOurState(to: .friend)
}
- public func unfriend() async throws {
+ @MainActor public func unfriend() async throws {
try await changeOurState(to: .notFriend)
}
- public func query() async throws {
+ @MainActor public func query() async throws {
let privateChat = try await self.messenger.createPrivateChat(with: self.username)
- _ = try await privateChat.sendRawMessage(
+ try await privateChat.sendRawMessage(
type: .magic,
messageSubtype: "@/contacts/friendship/query",
text: "",
@@ -222,12 +230,12 @@ extension Contact {
)
}
- public func unblock() async throws {
- guard ourState == .blocked else {
+ @MainActor public func unblock() async throws {
+ guard ourFriendshipState == .blocked else {
return
}
- let oldState = (try? await self.model.withMetadata(
+ let oldState = (try? self.model.withMetadata(
ofType: FriendshipMetadata.self,
forPlugin: FriendshipPlugin.self,
run: \.ourPreBlockedState
@@ -236,7 +244,7 @@ extension Contact {
return try await changeOurState(to: oldState)
}
- fileprivate func changeOurState(to newState: FriendshipStatus) async throws {
+ @MainActor fileprivate func changeOurState(to newState: FriendshipStatus) async throws {
try await self.modifyMetadata(
ofType: FriendshipMetadata.self,
forPlugin: FriendshipPlugin.self
@@ -259,17 +267,8 @@ extension Contact {
)
)
- let internalChat = try await self.messenger.getInternalConversation()
- _ = try await internalChat.sendRawMessage(
- type: .magic,
- messageSubtype: "@/contacts/friendship/change-state",
- text: "",
- metadata: message,
- preferredPushType: .none
- )
-
let privateChat = try await self.messenger.createPrivateChat(with: self.username)
- _ = try await privateChat.sendRawMessage(
+ try await privateChat.sendRawMessage(
type: .magic,
messageSubtype: "@/contacts/friendship/change-state",
text: "",
diff --git a/Sources/MessagingHelpers/Plugins/GroupMembershipPlugin/GroupMembershipPlugin.swift b/Sources/MessagingHelpers/Plugins/GroupMembershipPlugin/GroupMembershipPlugin.swift
index b4abe44..3d06ef0 100644
--- a/Sources/MessagingHelpers/Plugins/GroupMembershipPlugin/GroupMembershipPlugin.swift
+++ b/Sources/MessagingHelpers/Plugins/GroupMembershipPlugin/GroupMembershipPlugin.swift
@@ -1,8 +1 @@
-//
-// File.swift
-//
-//
-// Created by Joannis Orlandos on 04/06/2021.
-//
-
-import Foundation
+// TODO: Group name, notification preferences
diff --git a/Sources/MessagingHelpers/Plugins/ModifyMessagePlugin/ModifyMessagePlugin.swift b/Sources/MessagingHelpers/Plugins/ModifyMessagePlugin/ModifyMessagePlugin.swift
index 3733343..4cdbb9d 100644
--- a/Sources/MessagingHelpers/Plugins/ModifyMessagePlugin/ModifyMessagePlugin.swift
+++ b/Sources/MessagingHelpers/Plugins/ModifyMessagePlugin/ModifyMessagePlugin.swift
@@ -1,10 +1,11 @@
import CypherMessaging
-@available(macOS 12, iOS 15, *)
+// TODO: Edit message history?
+@available(macOS 10.15, iOS 13, *)
public struct ModifyMessagePlugin: Plugin {
public static let pluginIdentifier = "@/messaging/mutate-history"
- public func onReceiveMessage(_ message: ReceivedMessageContext) async throws -> ProcessMessageAction? {
+ @MainActor public func onReceiveMessage(_ message: ReceivedMessageContext) async throws -> ProcessMessageAction? {
guard
message.message.messageType == .magic,
var subType = message.message.messageSubtype,
@@ -31,7 +32,7 @@ public struct ModifyMessagePlugin: Plugin {
}
}
- public func onSendMessage(
+ @CryptoActor public func onSendMessage(
_ message: SentMessageContext
) async throws -> SendMessageAction? {
guard
@@ -46,11 +47,11 @@ public struct ModifyMessagePlugin: Plugin {
}
}
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
extension AnyChatMessage {
- public func revoke() async throws {
+ @CryptoActor public func revoke() async throws {
let chat = try await self.target.resolve(in: self.messenger)
- _ = try await chat.sendRawMessage(
+ try await chat.sendRawMessage(
type: .magic,
messageSubtype: "@/messaging/mutate-history/revoke",
text: self.raw.encrypted.remoteId,
diff --git a/Sources/MessagingHelpers/Plugins/SwiftUIEventEmitterPlugin.swift b/Sources/MessagingHelpers/Plugins/SwiftUIEventEmitterPlugin.swift
index 4bb9d1b..7637d8a 100644
--- a/Sources/MessagingHelpers/Plugins/SwiftUIEventEmitterPlugin.swift
+++ b/Sources/MessagingHelpers/Plugins/SwiftUIEventEmitterPlugin.swift
@@ -1,4 +1,4 @@
-//#if canImport(SwiftUI) && canImport(Combine)
+#if canImport(SwiftUI) && canImport(Combine) && (os(macOS) || os(iOS))
import SwiftUI
import CypherMessaging
import Combine
@@ -11,6 +11,8 @@ public final class SwiftUIEventEmitter: ObservableObject {
public let chatMessageRemoved = PassthroughSubject()
public let conversationChanged = PassthroughSubject()
public let contactChanged = PassthroughSubject()
+ public let userDevicesChanged = PassthroughSubject()
+ public let customConfigChanged = PassthroughSubject()
public let p2pClientConnected = PassthroughSubject()
@@ -19,16 +21,25 @@ public final class SwiftUIEventEmitter: ObservableObject {
@Published public private(set) var conversations = [TargetConversation.Resolved]()
@Published public fileprivate(set) var contacts = [Contact]()
- let sortChats: (TargetConversation.Resolved, TargetConversation.Resolved) -> Bool
+ let sortChats: @MainActor @Sendable (TargetConversation.Resolved, TargetConversation.Resolved) -> Bool
- public init(sortChats: @escaping (TargetConversation.Resolved, TargetConversation.Resolved) -> Bool) {
+ public init(sortChats: @escaping @Sendable @MainActor (TargetConversation.Resolved, TargetConversation.Resolved) -> Bool) {
self.sortChats = sortChats
}
- public func boot(for messenger: CypherMessenger) async {
+ @MainActor public func boot(for messenger: CypherMessenger) async {
do {
self.conversations = try await messenger.listConversations(includingInternalConversation: true, increasingOrder: sortChats)
self.contacts = try await messenger.listContacts()
+
+ Task {
+ while !messenger.isOnline {
+ try await Task.sleep(nanoseconds: NSEC_PER_SEC)
+ }
+
+ try await Task.sleep(nanoseconds: NSEC_PER_SEC)
+ await messenger.resumeJobQueue()
+ }
} catch {}
}
}
@@ -51,62 +62,58 @@ public struct SwiftUIEventEmitterPlugin: Plugin {
}
}
+ public func onDeviceRegistery(_ deviceId: DeviceId, messenger: CypherMessenger) async throws {
+ DispatchQueue.main.async {
+ emitter.userDevicesChanged.send()
+ }
+ }
+
public func onMessageChange(_ message: AnyChatMessage) {
DispatchQueue.main.async {
emitter.chatMessageChanged.send(message)
}
}
- public func onConversationChange(_ conversation: AnyConversation) {
+ public func onConversationChange(_ viewModel: AnyConversation) {
Task.detached {
- let conversation = await conversation.resolveTarget()
+ let viewModel = await viewModel.resolveTarget()
DispatchQueue.main.async {
- emitter.conversationChanged.send(conversation)
+ emitter.conversationChanged.send(viewModel)
}
}
}
public func onContactChange(_ contact: Contact) {
- DispatchQueue.main.async {
- emitter.contactChanged.send(contact)
- }
+ emitter.contactChanged.send(contact)
}
public func onCreateContact(_ contact: Contact, messenger: CypherMessenger) {
- DispatchQueue.main.async {
- emitter.contacts.append(contact)
- emitter.contactAdded.send(contact)
- }
+ emitter.contacts.append(contact)
+ emitter.contactAdded.send(contact)
}
- public func onCreateConversation(_ conversation: AnyConversation) {
- DispatchQueue.main.async {
- emitter.conversationAdded.send(conversation)
- }
+ public func onCreateConversation(_ viewModel: AnyConversation) {
+ emitter.conversationAdded.send(viewModel)
}
public func onCreateChatMessage(_ chatMessage: AnyChatMessage) {
- DispatchQueue.main.async {
- self.emitter.savedChatMessages.send(chatMessage)
- }
+ self.emitter.savedChatMessages.send(chatMessage)
}
public func onRemoveContact(_ contact: Contact) {
- DispatchQueue.main.async {
- self.emitter.contacts.removeAll { $0.id == contact.id }
- }
+ self.emitter.contacts.removeAll { $0.id == contact.id }
}
public func onRemoveChatMessage(_ message: AnyChatMessage) {
- DispatchQueue.main.async {
- self.emitter.chatMessageRemoved.send(message)
- }
+ self.emitter.chatMessageRemoved.send(message)
}
public func onP2PClientOpen(_ client: P2PClient, messenger: CypherMessenger) {
- DispatchQueue.main.async {
- emitter.p2pClientConnected.send(client)
- }
+ emitter.p2pClientConnected.send(client)
+ }
+
+ public func onCustomConfigChange() {
+ emitter.customConfigChanged.send()
}
}
-//#endif
+#endif
diff --git a/Sources/MessagingHelpers/Plugins/UserVerificationPlugin/VerificationPlugin.swift b/Sources/MessagingHelpers/Plugins/UserVerificationPlugin/VerificationPlugin.swift
new file mode 100644
index 0000000..23dc554
--- /dev/null
+++ b/Sources/MessagingHelpers/Plugins/UserVerificationPlugin/VerificationPlugin.swift
@@ -0,0 +1,85 @@
+import CypherMessaging
+import NIO
+
+fileprivate struct UserVerificationMetadata: Codable {
+ var isVerified: Bool
+}
+
+@available(macOS 10.15, iOS 13, *)
+public struct UserVerificationPlugin: Plugin {
+ public static let pluginIdentifier = "@/user-verification"
+
+ public init() {}
+
+ public func onDeviceRegistery(_ deviceId: DeviceId, messenger: CypherMessenger) {
+ Task {
+ var verifiedFlags = Document()
+ let internalChat = try await messenger.getInternalConversation()
+
+ for contact in try await messenger.listContacts() {
+ await verifiedFlags[contact.username.raw] = contact.isVerified
+ }
+
+ try await internalChat.sendMagicPacket(
+ messageSubtype: Self.pluginIdentifier,
+ text: "",
+ metadata: verifiedFlags,
+ toDeviceId: deviceId
+ )
+ }
+ }
+
+ public func onReceiveMessage(_ message: ReceivedMessageContext) async throws -> ProcessMessageAction? {
+ guard message.message.messageType == .magic, message.message.messageSubtype == Self.pluginIdentifier else {
+ return nil
+ }
+
+ for (username, value) in message.message.metadata {
+ let username = Username(username)
+ guard let isVerified = value as? Bool else {
+ // Unknown operation
+ debugLog("Unknown verification change")
+ continue
+ }
+
+ let contact = try await message.messenger.createContact(byUsername: username)
+ try await contact.modifyMetadata(
+ ofType: UserVerificationMetadata.self,
+ forPlugin: UserVerificationPlugin.self
+ ) { metadata in
+ metadata.isVerified = isVerified
+ }
+ }
+
+ return .ignore
+ }
+}
+
+@available(macOS 10.15, iOS 13, *)
+extension Contact {
+ @MainActor public var isVerified: Bool {
+ (try? self.model.getProp(
+ ofType: UserVerificationMetadata.self,
+ forPlugin: UserVerificationPlugin.self,
+ run: \.isVerified
+ )) ?? false
+ }
+
+ @MainActor func setVerification(to isVerified: Bool) async throws {
+ try await modifyMetadata(
+ ofType: UserVerificationMetadata.self,
+ forPlugin: UserVerificationPlugin.self
+ ) { metadata in
+ metadata.isVerified = isVerified
+ }
+
+ let internalChat = try await messenger.getInternalConversation()
+ try await internalChat.sendMagicPacket(
+ messageSubtype: UserVerificationPlugin.pluginIdentifier,
+ text: "",
+ metadata: [
+ self.username.raw: isVerified
+ ]
+ )
+ }
+}
diff --git a/Sources/MessagingHelpers/VaporTransport.swift b/Sources/MessagingHelpers/VaporTransport.swift
index 464a5e7..f7f0664 100644
--- a/Sources/MessagingHelpers/VaporTransport.swift
+++ b/Sources/MessagingHelpers/VaporTransport.swift
@@ -1,3 +1,4 @@
+#if os(iOS) || os(macOS)
import BSON
import Foundation
import CypherProtocol
@@ -25,8 +26,19 @@ extension TransportCreationRequest: JWTAlgorithm {
}
public struct UserDeviceId: Hashable, Codable {
- let user: Username
- let device: DeviceId
+ public let user: Username
+ public let device: DeviceId
+
+ public init(user: Username, device: DeviceId) {
+ self.user = user
+ self.device = device
+ }
+}
+
+public struct Blob: Codable {
+ public let _id: String
+ public let creator: Username
+ public var document: C
}
struct Token: JWTPayload {
@@ -52,7 +64,7 @@ public struct UserProfile: Decodable {
enum MessageType: String, Codable {
case message = "a"
case multiRecipientMessage = "b"
- case readReceipt = "c"
+ case receipt = "c"
case ack = "d"
}
@@ -74,22 +86,23 @@ struct ChatMultiRecipientMessagePacket: Codable {
let multiRecipientMessage: MultiRecipientCypherMessage
}
-struct ReadReceiptPacket: Codable {
+struct ReadReceipt: Codable {
enum State: Int, Codable {
case received = 0
case displayed = 1
}
- let _id: ObjectId
let messageId: String
let state: State
- let sender: UserDeviceId
+ let sender: Username
+ let senderDevice: UserDeviceId
let recipient: UserDeviceId
+ let receivedAt: Date
}
-let maxBodySize = 500_000
+let maxBodySize = 4_000_000
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
extension URLSession {
func getBSON(
httpHost: String,
@@ -110,6 +123,7 @@ extension URLSession {
return try BSONDecoder().decode(type, from: Document(data: data))
}
+ @discardableResult
func postBSON(
httpHost: String,
url: String,
@@ -121,6 +135,7 @@ extension URLSession {
var request = URLRequest(url: URL(string: "\(httpHost)/\(url)")!)
request.httpMethod = "POST"
request.addValue("application/bson", forHTTPHeaderField: "Content-Type")
+ request.addValue("application/bson", forHTTPHeaderField: "Accept")
request.addValue(username.raw, forHTTPHeaderField: "X-API-User")
request.addValue(deviceId.raw, forHTTPHeaderField: "X-API-Device")
if let token = token {
@@ -177,6 +192,8 @@ public final class VaporTransport: CypherServerTransportClient {
let host: String
var httpHost: String { "https://\(host)" }
var appleToken: String?
+ public let isConnected = true
+ public private(set) var authenticated = AuthenticationState.unauthenticated
private var wantsConnection = true
private var webSocket: WebSocket?
private(set) var signer: TransportCreationRequest
@@ -284,8 +301,6 @@ public final class VaporTransport: CypherServerTransportClient {
return transport
}
- public private(set) var authenticated = AuthenticationState.unauthenticated
-
private func makeToken() -> String? {
return try? JWTSigner(algorithm: signer).sign(
Token(
@@ -300,7 +315,6 @@ public final class VaporTransport: CypherServerTransportClient {
public func disconnect() async {
do {
- self.authenticated = .unauthenticated
self.wantsConnection = false
return try await (webSocket?.close() ?? eventLoop.makeSucceededVoidFuture()).get()
} catch {}
@@ -308,7 +322,11 @@ public final class VaporTransport: CypherServerTransportClient {
public func reconnect() async {
do {
- if authenticated == .authenticated {
+ if
+ authenticated == .authenticated,
+ let webSocket = webSocket,
+ !webSocket.isClosed
+ {
// Already connected
return
}
@@ -327,13 +345,13 @@ public final class VaporTransport: CypherServerTransportClient {
try await WebSocket.connect(
to: "wss://\(host)/websocket",
headers: headers,
- configuration: .init(maxFrameSize: 512_000),
+ configuration: .init(maxFrameSize: maxBodySize),
on: eventLoop
) { webSocket in
self.webSocket = webSocket
self.authenticated = .authenticated
- _ = webSocket.eventLoop.scheduleRepeatedTask(
+ webSocket.eventLoop.scheduleRepeatedTask(
initialDelay: .seconds(15),
delay: .seconds(15)
) { task in
@@ -356,6 +374,11 @@ public final class VaporTransport: CypherServerTransportClient {
let body: Document
}
+ struct Receipt: Codable {
+ let id: ObjectId
+ let type: MessageType
+ }
+
struct Ack: Codable {
let type: MessageType
let id: ObjectId
@@ -379,7 +402,8 @@ public final class VaporTransport: CypherServerTransportClient {
message.message,
id: message.messageId,
byUser: message.sender.user,
- deviceId: message.sender.device
+ deviceId: message.sender.device,
+ createdAt: message.createdAt
)
)
case .multiRecipientMessage:
@@ -390,34 +414,34 @@ public final class VaporTransport: CypherServerTransportClient {
message.multiRecipientMessage,
id: message.messageId,
byUser: message.sender.user,
- deviceId: message.sender.device
+ deviceId: message.sender.device,
+ createdAt: message.createdAt
)
)
- case .readReceipt:
- let receipt = try BSONDecoder().decode(ReadReceiptPacket.self, from: packet.body)
+ case .receipt:
+ let receipt = try BSONDecoder().decode(ReadReceipt.self, from: packet.body)
switch receipt.state {
case .displayed:
- // delegate.receiveServerEvent(.)
- ()
+ try await delegate.receiveServerEvent(.messageDisplayed(by: receipt.sender, deviceId: receipt.senderDevice.device, id: receipt.messageId, receivedAt: receipt.receivedAt))
case .received:
- // delegate.receiveServerEvent(.messageReceived(by: receipt., deviceId: receipt.sender, id: receipt.messageId))
- ()
+ try await delegate.receiveServerEvent(.messageReceived(by: receipt.sender, deviceId: receipt.senderDevice.device, id: receipt.messageId, receivedAt: receipt.receivedAt))
}
case .ack:
()
}
let ack = try BSONEncoder().encode(Ack(id: packet.id)).makeData()
- webSocket.send(raw: ack, opcode: .binary)
+ try await webSocket.send(raw: ack, opcode: .binary)
} catch {
- _ = await transport.disconnect()
+ await transport.disconnect()
}
}
}
webSocket.onClose.whenComplete { [weak self] _ in
if let transport = self, transport.wantsConnection == true {
+ transport.authenticated = .unauthenticated
Task.detached {
await transport.reconnect()
}
@@ -448,7 +472,7 @@ public final class VaporTransport: CypherServerTransportClient {
}
public func publishKeyBundle(_ data: UserConfig) async throws {
- _ = try await self.httpClient.postBSON(
+ try await self.httpClient.postBSON(
httpHost: httpHost,
url: "current-user/config",
username: self.username,
@@ -459,7 +483,7 @@ public final class VaporTransport: CypherServerTransportClient {
}
public func registerAPNSToken(_ token: Data) async throws {
- _ = try await self.httpClient.postBSON(
+ try await self.httpClient.postBSON(
httpHost: httpHost,
url: "current-device/token",
username: self.username,
@@ -478,11 +502,43 @@ public final class VaporTransport: CypherServerTransportClient {
}
public func publishBlob(_ blob: C) async throws -> ReferencedBlob where C : Decodable, C : Encodable {
- fatalError()
+ let (data, response) = try await httpClient.postBSON(
+ httpHost: httpHost,
+ url: "blobs",
+ username: self.username,
+ deviceId: self.deviceId,
+ token: self.makeToken(),
+ body: blob
+ )
+
+ if let response = response as? HTTPURLResponse {
+ guard response.statusCode >= 200 && response.statusCode < 300 else {
+ debugLog("Status code \(response.statusCode)")
+ throw VaporTransportError.sendMessageFailed
+ }
+ }
+
+ let document = Document(data: data)
+
+ guard document.validate().isValid else {
+ throw VaporTransportError.sendMessageFailed
+ }
+
+ let blob = try BSONDecoder().decode(Blob.self, from: document)
+ return ReferencedBlob(id: blob._id, blob: blob.document)
}
- public func readPublishedBlob(byId id: String, as type: C.Type) async throws -> ReferencedBlob? where C : Decodable, C : Encodable {
- fatalError()
+ public func readPublishedBlob(byId id: String, as type: C.Type) async throws -> ReferencedBlob? {
+ let blob = try await httpClient.getBSON(
+ httpHost: httpHost,
+ url: "blobs/\(id)",
+ username: username,
+ deviceId: deviceId,
+ token: self.makeToken(),
+ as: Blob.self
+ )
+
+ return ReferencedBlob(id: blob._id, blob: blob.document)
}
public func sendMessage(
@@ -492,7 +548,7 @@ public final class VaporTransport: CypherServerTransportClient {
pushType: PushType,
messageId: String
) async throws {
- _ = try await self.httpClient.postBSON(
+ try await self.httpClient.postBSON(
httpHost: httpHost,
url: "users/\(username.raw)/devices/\(deviceId.raw)/send-message",
username: self.username,
@@ -511,7 +567,7 @@ public final class VaporTransport: CypherServerTransportClient {
pushType: PushType,
messageId: String
) async throws {
- _ = try await self.httpClient.postBSON(
+ let (_, response) = try await self.httpClient.postBSON(
httpHost: httpHost,
url: "actions/send-message",
username: self.username,
@@ -523,6 +579,13 @@ public final class VaporTransport: CypherServerTransportClient {
messageId: messageId
)
)
+
+ if let response = response as? HTTPURLResponse {
+ guard response.statusCode >= 200 && response.statusCode < 300 else {
+ debugLog("Status code \(response.statusCode)")
+ throw VaporTransportError.sendMessageFailed
+ }
+ }
}
}
@@ -550,3 +613,4 @@ extension DataProtocol {
return String(bytesNoCopy: ptr, length: hexLen, encoding: .utf8, freeWhenDone: true)!
}
}
+#endif
diff --git a/Tests/CypherMessagingHelpersTests/ChatActivityPluginTests.swift b/Tests/CypherMessagingHelpersTests/ChatActivityPluginTests.swift
index e506dc2..1a88c56 100644
--- a/Tests/CypherMessagingHelpersTests/ChatActivityPluginTests.swift
+++ b/Tests/CypherMessagingHelpersTests/ChatActivityPluginTests.swift
@@ -2,13 +2,13 @@ import XCTest
import CypherMessaging
import MessagingHelpers
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
final class ChatActivityPluginTests: XCTestCase {
override func setUpWithError() throws {
SpoofTransportClient.resetServer()
}
- func testPrivateChat() async throws {
+ @MainActor func testPrivateChat() async throws {
let m0 = try await CypherMessenger.registerMessenger(
username: "m0",
authenticationMethod: .password("m0"),
@@ -50,7 +50,7 @@ final class ChatActivityPluginTests: XCTestCase {
XCTAssertNotNil(m1Chat.lastActivity)
}
- func testGroupChat() async throws {
+ @MainActor func testGroupChat() async throws {
let m0 = try await CypherMessenger.registerMessenger(
username: "m0",
authenticationMethod: .password("m0"),
diff --git a/Tests/CypherMessagingHelpersTests/FriendshipPluginTests.swift b/Tests/CypherMessagingHelpersTests/FriendshipPluginTests.swift
index 0680199..fb49ef4 100644
--- a/Tests/CypherMessagingHelpersTests/FriendshipPluginTests.swift
+++ b/Tests/CypherMessagingHelpersTests/FriendshipPluginTests.swift
@@ -2,7 +2,7 @@ import XCTest
import CypherMessaging
import MessagingHelpers
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
struct Synchronisation {
let apps: [CypherMessenger]
@@ -24,11 +24,11 @@ struct Synchronisation {
}
}
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
struct CustomMagicPacketPlugin: Plugin {
static let pluginIdentifier = "custom-magic-packet"
- let onInput: () -> ()
- let onOutput: () -> ()
+ let onInput: @Sendable () async -> ()
+ let onOutput: @Sendable () async -> ()
func onDeviceRegisteryRequest(_ config: UserDeviceConfig, messenger: CypherMessenger) async throws {
try await messenger.addDevice(config)
@@ -36,7 +36,7 @@ struct CustomMagicPacketPlugin: Plugin {
func onReceiveMessage(_ message: ReceivedMessageContext) async throws -> ProcessMessageAction? {
if message.message.messageSubtype == "custom-magic-packet" {
- onInput()
+ await onInput()
}
return nil
@@ -44,20 +44,20 @@ struct CustomMagicPacketPlugin: Plugin {
func onSendMessage(_ message: SentMessageContext) async throws -> SendMessageAction? {
if message.message.messageSubtype == "custom-magic-packet" {
- onOutput()
+ await onOutput()
}
return nil
}
}
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
final class FriendshipPluginTests: XCTestCase {
override func setUpWithError() throws {
SpoofTransportClient.resetServer()
}
- func testIgnoreUndecided() async throws {
+ @MainActor func testIgnoreUndecided() async throws {
var ruleset = FriendshipRuleset()
ruleset.ignoreWhenUndecided = true
ruleset.blockAffectsGroupChats = false
@@ -340,13 +340,20 @@ final class FriendshipPluginTests: XCTestCase {
await XCTAssertAsyncEqual(try await m1GroupChat.allMessages(sortedBy: .descending).count, 1)
}
- func testBlockingCanPreventOtherPlugins() async throws {
+ @MainActor func testBlockingCanPreventOtherPlugins() async throws {
var ruleset = FriendshipRuleset()
ruleset.ignoreWhenUndecided = true
ruleset.blockAffectsGroupChats = false
ruleset.canIgnoreMagicPackets = true
ruleset.preventSendingDisallowedMessages = false
- var inputCount = 0
+ actor Result {
+ var inputCount = 0
+
+ func inc() {
+ inputCount += 1
+ }
+ }
+ let result = Result()
let m0 = try await CypherMessenger.registerMessenger(
username: "m0",
@@ -357,7 +364,7 @@ final class FriendshipPluginTests: XCTestCase {
eventHandler: PluginEventHandler(plugins: [
FriendshipPlugin(ruleset: ruleset),
CustomMagicPacketPlugin(onInput: {
- inputCount += 1
+ await result.inc()
}, onOutput: {})
])
)
@@ -371,7 +378,7 @@ final class FriendshipPluginTests: XCTestCase {
eventHandler: PluginEventHandler(plugins: [
FriendshipPlugin(ruleset: ruleset),
CustomMagicPacketPlugin(onInput: {
- inputCount += 1
+ await result.inc()
}, onOutput: {})
])
)
@@ -392,7 +399,7 @@ final class FriendshipPluginTests: XCTestCase {
try await sync.synchronise()
- await XCTAssertAsyncEqual(inputCount, 0)
+ await XCTAssertAsyncEqual(await result.inputCount, 0)
let m1Contact = try await m1.createContact(byUsername: "m0")
try await m0Contact.befriend()
@@ -411,7 +418,7 @@ final class FriendshipPluginTests: XCTestCase {
try await sync.synchronise()
- await XCTAssertAsyncEqual(inputCount, 1)
+ await XCTAssertAsyncEqual(await result.inputCount, 1)
_ = m0
_ = m1
}
diff --git a/Tests/CypherMessagingHelpersTests/UserProfilePluginTests.swift b/Tests/CypherMessagingHelpersTests/UserProfilePluginTests.swift
index e7ff068..2e898a8 100644
--- a/Tests/CypherMessagingHelpersTests/UserProfilePluginTests.swift
+++ b/Tests/CypherMessagingHelpersTests/UserProfilePluginTests.swift
@@ -54,7 +54,7 @@ func XCTAssertAsyncTrue(_ run: @autoclosure () async throws -> Bool) async {
}
}
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
struct AcceptAllDeviceRegisteriesPlugin: Plugin {
static let pluginIdentifier = "accept-all-device-registeries"
@@ -63,13 +63,13 @@ struct AcceptAllDeviceRegisteriesPlugin: Plugin {
}
}
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
final class UserProfilePluginTests: XCTestCase {
override func setUpWithError() throws {
SpoofTransportClient.resetServer()
}
- func testChangeStatus() async throws {
+ @MainActor func testChangeStatus() async throws {
let m0 = try await CypherMessenger.registerMessenger(
username: "m0",
authenticationMethod: .password("m0"),
diff --git a/Tests/CypherMessagingTests/SDKTests.swift b/Tests/CypherMessagingTests/SDKTests.swift
index d42eede..d525fc3 100644
--- a/Tests/CypherMessagingTests/SDKTests.swift
+++ b/Tests/CypherMessagingTests/SDKTests.swift
@@ -6,11 +6,25 @@ import CypherMessaging
import SystemConfiguration
import CypherProtocol
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
struct Synchronisation {
let apps: [CypherMessenger]
- func synchronise() async throws {
+ func flush(untilEmpty: Bool = false) async throws {
+ var hasWork = true
+
+ repeat {
+ hasWork = false
+
+ for app in apps {
+ if try await app.processJobQueue(untilEmpty: untilEmpty) == .synchronised {
+ hasWork = true
+ }
+ }
+ } while hasWork
+ }
+
+ func synchronise(untilEmpty: Bool = false) async throws {
var hasWork = true
repeat {
@@ -20,7 +34,7 @@ struct Synchronisation {
}
for app in apps {
- if try await app.processJobQueue() == .synchronised {
+ if try await app.processJobQueue(untilEmpty: untilEmpty) == .synchronised {
hasWork = true
}
}
@@ -71,12 +85,16 @@ func XCTAssertAsyncEqual(_ run: @autoclosure () async throws -> T,
}
}
-@available(macOS 12, iOS 15, *)
+@available(macOS 10.15, iOS 13, *)
final class CypherSDKTests: XCTestCase {
override func setUpWithError() throws {
SpoofTransportClient.resetServer()
}
+ func testDisableMultiRecipientMessage() async throws {
+
+ }
+
func testPrivateChatWithYourself() async throws {
let m0 = try await CypherMessenger.registerMessenger(
username: "m0",
@@ -90,22 +108,489 @@ final class CypherSDKTests: XCTestCase {
await XCTAssertThrowsAsyncError(try await m0.createPrivateChat(with: "m0"))
}
+ @CypherTextKitActor func testOfflineSupport() async throws {
+ let m0 = try await CypherMessenger.registerMessenger(
+ username: "m0",
+ authenticationMethod: .password("m0"),
+ appPassword: "",
+ usingTransport: SpoofTransportClient.self,
+ database: MemoryCypherMessengerStore(),
+ eventHandler: SpoofCypherEventHandler()
+ )
+
+ let m1 = try await CypherMessenger.registerMessenger(
+ username: "m1",
+ authenticationMethod: .password("m1"),
+ appPassword: "",
+ usingTransport: SpoofTransportClient.self,
+ database: MemoryCypherMessengerStore(),
+ eventHandler: SpoofCypherEventHandler()
+ )
+
+ let sync = Synchronisation(apps: [m0, m1])
+ try await sync.synchronise()
+
+ try await m1.transport.disconnect()
+ let m0Chat = try await m0.createPrivateChat(with: "m1")
+
+ _ = try await m0Chat.sendRawMessage(
+ type: .text,
+ text: "Hello",
+ preferredPushType: .none
+ )
+
+ _ = try await m0Chat.sendRawMessage(
+ type: .text,
+ text: "Hello",
+ preferredPushType: .none
+ )
+
+ try await sync.flush()
+ if (try? await m1.getPrivateChat(with: "m0")) != nil {
+ XCTFail()
+ }
+
+ try await m1.transport.reconnect()
+ try await sync.synchronise(untilEmpty: true)
+ guard let m1Chat = try await m1.getPrivateChat(with: "m0") else {
+ return XCTFail()
+ }
+
+ try await sync.synchronise()
+ await XCTAssertAsyncEqual(try await m0Chat.allMessages(sortedBy: .descending).count, 2)
+ await XCTAssertAsyncEqual(try await m1Chat.allMessages(sortedBy: .descending).count, 2)
+
+ try await m1.transport.disconnect()
+
+ for i in 0..<200 {
+ _ = try await m0Chat.sendRawMessage(
+ type: .text,
+ text: "Hello \(i)",
+ preferredPushType: .none
+ )
+ }
+
+ try await sync.flush()
+ await XCTAssertAsyncEqual(try await m0Chat.allMessages(sortedBy: .descending).count, 202)
+ await XCTAssertAsyncEqual(try await m1Chat.allMessages(sortedBy: .descending).count, 2)
+
+ try await m0.transport.disconnect()
+ try await m1.transport.reconnect()
+ try await sync.flush()
+ await XCTAssertAsyncEqual(try await m0Chat.allMessages(sortedBy: .descending).count, 202)
+ await XCTAssertAsyncEqual(try await m1Chat.allMessages(sortedBy: .descending).count, 202)
+
+ for i in 0..<150 {
+ _ = try await m1Chat.sendRawMessage(
+ type: .text,
+ text: "Hello \(i)",
+ preferredPushType: .none
+ )
+ }
+
+ await XCTAssertAsyncEqual(try await m0Chat.allMessages(sortedBy: .descending).count, 202)
+ await XCTAssertAsyncEqual(try await m1Chat.allMessages(sortedBy: .descending).count, 352)
+
+ try await m0.transport.reconnect()
+ try await sync.synchronise()
+ await XCTAssertAsyncEqual(try await m0Chat.allMessages(sortedBy: .descending).count, 352)
+ await XCTAssertAsyncEqual(try await m1Chat.allMessages(sortedBy: .descending).count, 352)
+ }
+
+ @CypherTextKitActor func testParallelOfflineSupport() async throws {
+ let m0 = try await CypherMessenger.registerMessenger(
+ username: "m0",
+ authenticationMethod: .password("m0"),
+ appPassword: "",
+ usingTransport: SpoofTransportClient.self,
+ database: MemoryCypherMessengerStore(),
+ eventHandler: SpoofCypherEventHandler()
+ )
+
+ let m1 = try await CypherMessenger.registerMessenger(
+ username: "m1",
+ authenticationMethod: .password("m1"),
+ appPassword: "",
+ usingTransport: SpoofTransportClient.self,
+ database: MemoryCypherMessengerStore(),
+ eventHandler: SpoofCypherEventHandler()
+ )
+
+ let sync = Synchronisation(apps: [m0, m1])
+ try await sync.synchronise()
+
+ try await m1.transport.disconnect()
+ let m0Chat = try await m0.createPrivateChat(with: "m1")
+
+ _ = try await m0Chat.sendRawMessage(
+ type: .text,
+ text: "Hello",
+ preferredPushType: .none
+ )
+
+ _ = try await m0Chat.sendRawMessage(
+ type: .text,
+ text: "Hello",
+ preferredPushType: .none
+ )
+
+ try await sync.flush()
+ if (try? await m1.getPrivateChat(with: "m0")) != nil {
+ XCTFail()
+ }
+
+ try await m1.transport.reconnect()
+ try await sync.synchronise(untilEmpty: true)
+ guard let m1Chat = try await m1.getPrivateChat(with: "m0") else {
+ return XCTFail()
+ }
+
+ try await sync.synchronise()
+ await XCTAssertAsyncEqual(try await m0Chat.allMessages(sortedBy: .descending).count, 2)
+ await XCTAssertAsyncEqual(try await m1Chat.allMessages(sortedBy: .descending).count, 2)
+
+ try await m1.transport.disconnect()
+
+ for i in 0..<200 {
+ _ = try await m0Chat.sendRawMessage(
+ type: .text,
+ text: "Hello \(i)",
+ preferredPushType: .none
+ )
+ }
+
+ for i in 0..<150 {
+ _ = try await m1Chat.sendRawMessage(
+ type: .text,
+ text: "Hello \(i)",
+ preferredPushType: .none
+ )
+ }
+
+ while try await m0.processJobQueue(untilEmpty: true) == .synchronised {}
+ await XCTAssertAsyncEqual(try await m0Chat.allMessages(sortedBy: .descending).count, 202)
+ await XCTAssertAsyncEqual(try await m1Chat.allMessages(sortedBy: .descending).count, 152)
+
+ try await m0.transport.disconnect()
+ try await m1.transport.reconnect()
+ while try await m1.processJobQueue(untilEmpty: true) == .synchronised {}
+
+ await XCTAssertAsyncEqual(try await m0Chat.allMessages(sortedBy: .descending).count, 202)
+ await XCTAssertAsyncEqual(try await m1Chat.allMessages(sortedBy: .descending).count, 352)
+
+ try await m0.transport.reconnect()
+ try await sync.synchronise()
+ await XCTAssertAsyncEqual(try await m0Chat.allMessages(sortedBy: .descending).count, 352)
+ await XCTAssertAsyncEqual(try await m1Chat.allMessages(sortedBy: .descending).count, 352)
+ }
+
+ @CypherTextKitActor func testParallelHalfBackloggedOfflineSupport() async throws {
+ let m0 = try await CypherMessenger.registerMessenger(
+ username: "m0",
+ authenticationMethod: .password("m0"),
+ appPassword: "",
+ usingTransport: SpoofTransportClient.self,
+ database: MemoryCypherMessengerStore(),
+ eventHandler: SpoofCypherEventHandler()
+ )
+
+ let m1 = try await CypherMessenger.registerMessenger(
+ username: "m1",
+ authenticationMethod: .password("m1"),
+ appPassword: "",
+ usingTransport: SpoofTransportClient.self,
+ database: MemoryCypherMessengerStore(),
+ eventHandler: SpoofCypherEventHandler()
+ )
+
+ let sync = Synchronisation(apps: [m0, m1])
+ try await sync.synchronise()
+
+ try await m1.transport.disconnect()
+ let m0Chat = try await m0.createPrivateChat(with: "m1")
+
+ _ = try await m0Chat.sendRawMessage(
+ type: .text,
+ text: "Hello",
+ preferredPushType: .none
+ )
+
+ _ = try await m0Chat.sendRawMessage(
+ type: .text,
+ text: "Hello",
+ preferredPushType: .none
+ )
+
+ try await sync.flush()
+ if (try? await m1.getPrivateChat(with: "m0")) != nil {
+ XCTFail()
+ }
+
+ try await m1.transport.reconnect()
+ try await sync.synchronise(untilEmpty: true)
+ guard let m1Chat = try await m1.getPrivateChat(with: "m0") else {
+ return XCTFail()
+ }
+
+ try await sync.synchronise()
+ await XCTAssertAsyncEqual(try await m0Chat.allMessages(sortedBy: .descending).count, 2)
+ await XCTAssertAsyncEqual(try await m1Chat.allMessages(sortedBy: .descending).count, 2)
+
+ try await m1.transport.disconnect()
+
+ for i in 0..<200 {
+ _ = try await m0Chat.sendRawMessage(
+ type: .text,
+ text: "Hello \(i)",
+ preferredPushType: .none
+ )
+ }
+
+ for i in 0..<150 {
+ _ = try await m1Chat.sendRawMessage(
+ type: .text,
+ text: "Hello \(i)",
+ preferredPushType: .none
+ )
+ }
+
+ while try await m0.processJobQueue(untilEmpty: true) == .synchronised {}
+ let poppedBacklog = SpoofTransportClientSettings.removeBacklog()
+
+ for i in 0..<200 {
+ _ = try await m0Chat.sendRawMessage(
+ type: .text,
+ text: "Hello \(i)",
+ preferredPushType: .none
+ )
+ }
+
+ while try await m0.processJobQueue(untilEmpty: true) == .synchronised {}
+ SpoofTransportClientSettings.addBacklog(poppedBacklog)
+
+ await XCTAssertAsyncEqual(try await m0Chat.allMessages(sortedBy: .descending).count, 402)
+ await XCTAssertAsyncEqual(try await m1Chat.allMessages(sortedBy: .descending).count, 152)
+
+ try await m0.transport.disconnect()
+ try await m1.transport.reconnect()
+ while try await m1.processJobQueue(untilEmpty: true) == .synchronised {}
+
+ try await m0.transport.reconnect()
+ try await sync.synchronise()
+ // Eventually consistent chats
+ await XCTAssertAsyncEqual(try await m0Chat.allMessages(sortedBy: .descending).count, 552)
+ await XCTAssertAsyncEqual(try await m1Chat.allMessages(sortedBy: .descending).count, 552)
+ }
+
+ @CypherTextKitActor func testMeshSupport() async throws {
+ defer { SpoofP2PTransportFactory.clearMesh() }
+
+ SpoofTransportClientSettings.isOffline = true
+ defer { SpoofTransportClientSettings.isOffline = false }
+
+ let m0 = try await CypherMessenger.registerMessenger(
+ username: "m0",
+ authenticationMethod: .password("m0"),
+ appPassword: "",
+ usingTransport: SpoofTransportClient.self,
+ p2pFactories: [
+ SpoofP2PTransportFactory(meshId: "m0")
+ ],
+ database: MemoryCypherMessengerStore(),
+ eventHandler: SpoofCypherEventHandler()
+ )
+
+ let m1_0 = try await CypherMessenger.registerMessenger(
+ username: "m1_0",
+ authenticationMethod: .password("m1"),
+ appPassword: "",
+ usingTransport: SpoofTransportClient.self,
+ p2pFactories: [
+ SpoofP2PTransportFactory(meshId: "m1_0")
+ ],
+ database: MemoryCypherMessengerStore(),
+ eventHandler: SpoofCypherEventHandler()
+ )
+
+ let m1_1 = try await CypherMessenger.registerMessenger(
+ username: "m1_1",
+ authenticationMethod: .password("m1"),
+ appPassword: "",
+ usingTransport: SpoofTransportClient.self,
+ p2pFactories: [
+ SpoofP2PTransportFactory(meshId: "m1_1")
+ ],
+ database: MemoryCypherMessengerStore(),
+ eventHandler: SpoofCypherEventHandler()
+ )
+
+ let m2 = try await CypherMessenger.registerMessenger(
+ username: "m2",
+ authenticationMethod: .password("m1"),
+ appPassword: "",
+ usingTransport: SpoofTransportClient.self,
+ p2pFactories: [
+ SpoofP2PTransportFactory(meshId: "m2")
+ ],
+ database: MemoryCypherMessengerStore(),
+ eventHandler: SpoofCypherEventHandler()
+ )
+
+ let sync = Synchronisation(apps: [m0, m1_0, m1_1, m2])
+ try await sync.synchronise()
+
+ try await SpoofP2PTransportFactory.connectMesh(from: "m0", to: "m1_0")
+ try await SpoofP2PTransportFactory.connectMesh(from: "m0", to: "m1_1")
+ try await SpoofP2PTransportFactory.connectMesh(from: "m1_0", to: "m2")
+ try await SpoofP2PTransportFactory.connectMesh(from: "m1_1", to: "m2")
+
+ let m0Card = try await m0.createContactCard()
+ let m2Card = try await m2.createContactCard()
+
+ try await m2.importContactCard(m0Card)
+ try await m0.importContactCard(m2Card)
+
+ let m0Chat = try await m0.createPrivateChat(with: "m2")
+
+ _ = try await m0Chat.sendRawMessage(
+ type: .text,
+ text: "Hello",
+ preferredPushType: .none
+ )
+
+ try await sync.synchronise()
+
+ guard let m2Chat = try await m2.getPrivateChat(with: "m0") else {
+ return XCTFail()
+ }
+
+ await XCTAssertAsyncEqual(try await m0Chat.allMessages(sortedBy: .descending).count, 1)
+ await XCTAssertAsyncEqual(try await m2Chat.allMessages(sortedBy: .descending).count, 1)
+
+ try await sync.synchronise()
+
+ _ = try await m0Chat.sendRawMessage(
+ type: .text,
+ text: "Hello",
+ preferredPushType: .none
+ )
+
+ try await sync.synchronise()
+
+ await XCTAssertAsyncEqual(try await m0Chat.allMessages(sortedBy: .descending).count, 2)
+ await XCTAssertAsyncEqual(try await m2Chat.allMessages(sortedBy: .descending).count, 2)
+ }
+
+ @CypherTextKitActor func testP2PMeshNetworkTransport() async throws {
+ let m0 = try await CypherMessenger.registerMessenger(
+ username: "m0",
+ authenticationMethod: .password("m0"),
+ appPassword: "",
+ usingTransport: SpoofTransportClient.self,
+ p2pFactories: [
+ SpoofP2PTransportFactory(meshId: "m0")
+ ],
+ database: MemoryCypherMessengerStore(),
+ eventHandler: SpoofCypherEventHandler()
+ )
+
+ let m1_0 = try await CypherMessenger.registerMessenger(
+ username: "m1_0",
+ authenticationMethod: .password("m1"),
+ appPassword: "",
+ usingTransport: SpoofTransportClient.self,
+ p2pFactories: [
+ SpoofP2PTransportFactory(meshId: "m1_0")
+ ],
+ database: MemoryCypherMessengerStore(),
+ eventHandler: SpoofCypherEventHandler()
+ )
+
+ let m1_1 = try await CypherMessenger.registerMessenger(
+ username: "m1_1",
+ authenticationMethod: .password("m1"),
+ appPassword: "",
+ usingTransport: SpoofTransportClient.self,
+ p2pFactories: [
+ SpoofP2PTransportFactory(meshId: "m1_1")
+ ],
+ database: MemoryCypherMessengerStore(),
+ eventHandler: SpoofCypherEventHandler()
+ )
+
+ let m2 = try await CypherMessenger.registerMessenger(
+ username: "m2",
+ authenticationMethod: .password("m1"),
+ appPassword: "",
+ usingTransport: SpoofTransportClient.self,
+ p2pFactories: [
+ SpoofP2PTransportFactory(meshId: "m2")
+ ],
+ database: MemoryCypherMessengerStore(),
+ eventHandler: SpoofCypherEventHandler()
+ )
+
+ let sync = Synchronisation(apps: [m0, m1_0, m1_1, m2])
+ try await sync.synchronise()
+
+ let m0Chat = try await m0.createPrivateChat(with: "m2")
+
+ _ = try await m0Chat.sendRawMessage(
+ type: .text,
+ text: "Hello",
+ preferredPushType: .none
+ )
+
+ try await sync.synchronise()
+
+ let m2Chat = try await m2.getPrivateChat(with: "m0")!
+
+ await XCTAssertAsyncEqual(try await m0Chat.allMessages(sortedBy: .descending).count, 1)
+ await XCTAssertAsyncEqual(try await m2Chat.allMessages(sortedBy: .descending).count, 1)
+
+ SpoofTransportClientSettings.isOffline = true
+ defer { SpoofTransportClientSettings.isOffline = false }
+
+ defer { SpoofP2PTransportFactory.clearMesh() }
+ try await SpoofP2PTransportFactory.connectMesh(from: "m0", to: "m1_0")
+ try await SpoofP2PTransportFactory.connectMesh(from: "m0", to: "m1_1")
+ try await SpoofP2PTransportFactory.connectMesh(from: "m1_0", to: "m2")
+ try await SpoofP2PTransportFactory.connectMesh(from: "m1_1", to: "m2")
+
+ try await sync.synchronise()
+
+ _ = try await m0Chat.sendRawMessage(
+ type: .text,
+ text: "Hello",
+ preferredPushType: .none
+ )
+
+ try await sync.synchronise()
+
+ await XCTAssertAsyncEqual(try await m0Chat.allMessages(sortedBy: .descending).count, 2)
+ await XCTAssertAsyncEqual(try await m2Chat.allMessages(sortedBy: .descending).count, 2)
+ }
+
func testIPv6P2P() async throws {
- try await runP2PTests(IPv6TCPP2PTransportClientFactory())
+ try await runP2PTests {
+ IPv6TCPP2PTransportClientFactory()
+ }
}
func testInMemoryP2P() async throws {
- try await runP2PTests(SpoofP2PTransportFactory())
+ try await runP2PTests {
+ SpoofP2PTransportFactory()
+ }
}
- func runP2PTests(_ factory: Factory) async throws {
+ func runP2PTests(_ factory: () -> Factory) async throws {
let m0 = try await CypherMessenger.registerMessenger(
username: "m0",
authenticationMethod: .password("m0"),
appPassword: "",
usingTransport: SpoofTransportClient.self,
p2pFactories: [
- factory
+ factory()
],
database: MemoryCypherMessengerStore(),
eventHandler: SpoofCypherEventHandler()
@@ -117,7 +602,7 @@ final class CypherSDKTests: XCTestCase {
appPassword: "",
usingTransport: SpoofTransportClient.self,
p2pFactories: [
- factory
+ factory()
],
database: MemoryCypherMessengerStore(),
eventHandler: SpoofCypherEventHandler()
@@ -155,7 +640,6 @@ final class CypherSDKTests: XCTestCase {
preferredPushType: .none
)
-
try await sync.synchronise()
await XCTAssertAsyncEqual(try await m0Chat.allMessages(sortedBy: .descending).count, 3)
@@ -350,7 +834,16 @@ final class CypherSDKTests: XCTestCase {
func testMultiDevicePrivateChatUnderLoad() async throws {
struct DroppedPacket: Error {}
- var droppedMessageIds = Set()
+ actor Result {
+ var droppedMessageIds = Set()
+ func addMessagedId(_ id: String) throws {
+ if !droppedMessageIds.contains(id) {
+ droppedMessageIds.insert(id)
+ throw DroppedPacket()
+ }
+ }
+ }
+ let result = Result()
// Always retry, because we don't want the test to take forever
_CypherTaskConfig.sendMessageRetryMode = .always
SpoofTransportClientSettings.shouldDropPacket = { username, type in
@@ -364,10 +857,7 @@ final class CypherSDKTests: XCTestCase {
.readReceipt(remoteId: let id, otherUser: _),
.receiveReceipt(remoteId: let id, otherUser: _):
// Cause as much chaos as possible
- if !droppedMessageIds.contains(id) {
- droppedMessageIds.insert(id)
- throw DroppedPacket()
- }
+ try await result.addMessagedId(id)
case .readKeyBundle, .publishBlob, .readBlob:
if Bool.random() {
throw DroppedPacket()