diff --git a/Sources/XMTPiOS/Frames/FramesClient.swift b/Sources/XMTPiOS/Frames/FramesClient.swift new file mode 100644 index 00000000..6604a726 --- /dev/null +++ b/Sources/XMTPiOS/Frames/FramesClient.swift @@ -0,0 +1,118 @@ +// +// FramesClient.swift +// +// +// Created by Alex Risch on 3/28/24. +// + +import Foundation +import LibXMTP + +public typealias FrameActionBody = Xmtp_MessageContents_FrameActionBody +public typealias FrameAction = Xmtp_MessageContents_FrameAction + +enum FramesClientError: Error { + case missingConversationTopic + case missingTarget + case readMetadataFailed(message: String, code: Int) + case postFrameFailed(message: String, code: Int) +} + +public class FramesClient { + var xmtpClient: Client + public var proxy: OpenFramesProxy + + public init(xmtpClient: Client, proxy: OpenFramesProxy? = nil) { + self.xmtpClient = xmtpClient + self.proxy = proxy ?? OpenFramesProxy() + } + + public func signFrameAction(inputs: FrameActionInputs) async throws + -> FramePostPayload + { + let opaqueConversationIdentifier = try self.buildOpaqueIdentifier( + inputs: inputs) + let frameUrl = inputs.frameUrl + let buttonIndex = inputs.buttonIndex + let inputText = inputs.inputText ?? "" + let state = inputs.state ?? "" + let now = Date().timeIntervalSince1970 + let timestamp = now + + var toSign = FrameActionBody() + toSign.frameURL = frameUrl + toSign.buttonIndex = buttonIndex + toSign.opaqueConversationIdentifier = opaqueConversationIdentifier + toSign.timestamp = UInt64(timestamp) + toSign.inputText = inputText + toSign.unixTimestamp = UInt32(now) + toSign.state = state + + let signedAction = try await self.buildSignedFrameAction( + actionBodyInputs: toSign) + + let untrustedData = FramePostUntrustedData( + url: frameUrl, timestamp: UInt64(now), buttonIndex: buttonIndex, + inputText: inputText, state: state, + walletAddress: xmtpClient.address, + opaqueConversationIdentifier: opaqueConversationIdentifier, + unixTimestamp: UInt32(now) + ) + + let trustedData = FramePostTrustedData( + messageBytes: signedAction.base64EncodedString()) + + let payload = FramePostPayload( + clientProtocol: "xmtp@\(PROTOCOL_VERSION)", + untrustedData: untrustedData, trustedData: trustedData + ) + + return payload + } + + private func signDigest(message: String) async throws -> Data { + return try xmtpClient.signWithInstallationKey(message: message) + } + + private func buildSignedFrameAction(actionBodyInputs: FrameActionBody) + async throws -> Data + { + let digest = sha256(input: try actionBodyInputs.serializedData()).toHex + let signature = try await self.signDigest(message: digest) + + var frameAction = FrameAction() + frameAction.actionBody = try actionBodyInputs.serializedData() + frameAction.installationSignature = signature + frameAction.installationID = xmtpClient.installationID.hexToData + frameAction.inboxID = xmtpClient.inboxID + + return try frameAction.serializedData() + } + + private func buildOpaqueIdentifier(inputs: FrameActionInputs) throws + -> String + { + switch inputs.conversationInputs { + case .group(let groupInputs): + let combined = groupInputs.groupId + groupInputs.groupSecret + let digest = sha256(input: combined) + return digest.base64EncodedString() + case .dm(let dmInputs): + guard let conversationTopic = dmInputs.conversationTopic else { + throw FramesClientError.missingConversationTopic + } + guard + let combined = + (conversationTopic.lowercased() + + dmInputs.participantAccountAddresses.map { + $0.lowercased() + }.sorted().joined()).data(using: .utf8) + else { + throw FramesClientError.missingConversationTopic + } + let digest = sha256(input: combined) + return digest.base64EncodedString() + } + } + +} diff --git a/Sources/XMTPiOS/Frames/FramesConstants.swift b/Sources/XMTPiOS/Frames/FramesConstants.swift new file mode 100644 index 00000000..192c4ab3 --- /dev/null +++ b/Sources/XMTPiOS/Frames/FramesConstants.swift @@ -0,0 +1,12 @@ +// +// File.swift +// +// +// Created by Alex Risch on 3/28/24. +// + +import Foundation + +let OPEN_FRAMES_PROXY_URL = "https://frames.xmtp.chat/" + +let PROTOCOL_VERSION = "2024-02-09" diff --git a/Sources/XMTPiOS/Frames/FramesTypes.swift b/Sources/XMTPiOS/Frames/FramesTypes.swift new file mode 100644 index 00000000..11bd29a8 --- /dev/null +++ b/Sources/XMTPiOS/Frames/FramesTypes.swift @@ -0,0 +1,165 @@ +// +// File.swift +// +// +// Created by Alex Risch on 3/28/24. +// + +import Foundation + +typealias AcceptedFrameClients = [String: String] + +enum OpenFrameButton: Codable { + case link(target: String, label: String) + case mint(target: String, label: String) + case post(target: String?, label: String) + case postRedirect(target: String?, label: String) + + enum CodingKeys: CodingKey { + case action, target, label + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let action = try container.decode(String.self, forKey: .action) + guard let target = try container.decodeIfPresent(String.self, forKey: .target) else { + throw FramesClientError.missingTarget + } + let label = try container.decode(String.self, forKey: .label) + + switch action { + case "link": + self = .link(target: target, label: label) + case "mint": + self = .mint(target: target, label: label) + case "post": + self = .post(target: target, label: label) + case "post_redirect": + self = .postRedirect(target: target, label: label) + default: + throw DecodingError.dataCorruptedError(forKey: .action, in: container, debugDescription: "Invalid action value") + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .link(let target, let label): + try container.encode("link", forKey: .action) + try container.encode(target, forKey: .target) + try container.encode(label, forKey: .label) + case .mint(let target, let label): + try container.encode("mint", forKey: .action) + try container.encode(target, forKey: .target) + try container.encode(label, forKey: .label) + case .post(let target, let label): + try container.encode("post", forKey: .action) + try container.encode(target, forKey: .target) + try container.encode(label, forKey: .label) + case .postRedirect(let target, let label): + try container.encode("post_redirect", forKey: .action) + try container.encode(target, forKey: .target) + try container.encode(label, forKey: .label) + } + } +} + +public struct OpenFrameImage: Codable { + let content: String + let aspectRatio: AspectRatio? + let alt: String? +} + +public enum AspectRatio: String, Codable { + case ratio_1_91_1 = "1.91.1" + case ratio_1_1 = "1:1" +} + +public struct TextInput: Codable { + let content: String +} + +struct OpenFrameResult: Codable { + let acceptedClients: AcceptedFrameClients + let image: OpenFrameImage + let postUrl: String? + let textInput: TextInput? + let buttons: [String: OpenFrameButton]? + let ogImage: String + let state: String? +}; + +public struct GetMetadataResponse: Codable { + let url: String + public let extractedTags: [String: String] +} + +public struct PostRedirectResponse: Codable { + let originalUrl: String + let redirectedTo: String +}; + +public struct OpenFramesUntrustedData: Codable { + let url: String + let timestamp: Int + let buttonIndex: Int + let inputText: String? + let state: String? +} + +public typealias FramesApiRedirectResponse = PostRedirectResponse; + +public struct FramePostUntrustedData: Codable { + let url: String + let timestamp: UInt64 + let buttonIndex: Int32 + let inputText: String? + let state: String? + let walletAddress: String + let opaqueConversationIdentifier: String + let unixTimestamp: UInt32 +} + +public struct FramePostTrustedData: Codable { + let messageBytes: String +} + +public struct FramePostPayload: Codable { + let clientProtocol: String + let untrustedData: FramePostUntrustedData + let trustedData: FramePostTrustedData +} + +public struct DmActionInputs: Codable { + public let conversationTopic: String? + public let participantAccountAddresses: [String] + public init(conversationTopic: String? = nil, participantAccountAddresses: [String]) { + self.conversationTopic = conversationTopic + self.participantAccountAddresses = participantAccountAddresses + } +} + +public struct GroupActionInputs: Codable { + let groupId: Data + let groupSecret: Data +} + +public enum ConversationActionInputs: Codable { + case dm(DmActionInputs) + case group(GroupActionInputs) +} + +public struct FrameActionInputs: Codable { + let frameUrl: String + let buttonIndex: Int32 + let inputText: String? + let state: String? + let conversationInputs: ConversationActionInputs + public init(frameUrl: String, buttonIndex: Int32, inputText: String?, state: String?, conversationInputs: ConversationActionInputs) { + self.frameUrl = frameUrl + self.buttonIndex = buttonIndex + self.inputText = inputText + self.state = state + self.conversationInputs = conversationInputs + } +} diff --git a/Sources/XMTPiOS/Frames/OpenFramesProxy.swift b/Sources/XMTPiOS/Frames/OpenFramesProxy.swift new file mode 100644 index 00000000..22ea3f83 --- /dev/null +++ b/Sources/XMTPiOS/Frames/OpenFramesProxy.swift @@ -0,0 +1,38 @@ +// +// File.swift +// +// +// Created by Alex Risch on 3/28/24. +// + +import Foundation + +public class OpenFramesProxy { + let inner: ProxyClient + + init(baseUrl: String = OPEN_FRAMES_PROXY_URL) { + self.inner = ProxyClient(baseUrl: baseUrl); + } + + public func readMetadata(url: String) async throws -> GetMetadataResponse { + return try await self.inner.readMetadata(url: url); + } + + public func post(url: String, payload: FramePostPayload) async throws -> GetMetadataResponse { + return try await self.inner.post(url: url, payload: payload); + } + + public func postRedirect( + url: String, + payload: FramePostPayload + ) async throws -> FramesApiRedirectResponse { + return try await self.inner.postRedirect(url: url, payload: payload); + } + + public func mediaUrl(url: String) async throws -> String { + if url.hasPrefix("data:") { + return url + } + return self.inner.mediaUrl(url: url); + } +} diff --git a/Sources/XMTPiOS/Frames/ProxyClient.swift b/Sources/XMTPiOS/Frames/ProxyClient.swift new file mode 100644 index 00000000..332dea21 --- /dev/null +++ b/Sources/XMTPiOS/Frames/ProxyClient.swift @@ -0,0 +1,97 @@ +// +// File.swift +// +// +// Created by Alex Risch on 3/28/24. +// + +import Foundation + +struct Metadata: Codable { + let title: String + let description: String + let imageUrl: String +} + +class ProxyClient { + var baseUrl: String + + init(baseUrl: String) { + self.baseUrl = baseUrl + } + + func readMetadata(url: String) async throws -> GetMetadataResponse { + let encodedUrl = url.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + let fullUrl = "\(self.baseUrl)?url=\(encodedUrl)" + guard let url = URL(string: fullUrl) else { + throw URLError(.badURL) + } + + let (data, response) = try await URLSession.shared.data(from: url) + guard let httpResponse = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + + guard httpResponse.statusCode == 200 else { + throw FramesClientError.readMetadataFailed(message: "Failed to read metadata for \(url)", code: httpResponse.statusCode) + } + + let decoder = JSONDecoder() + let metadataResponse: GetMetadataResponse = try decoder.decode(GetMetadataResponse.self, from: data) + return metadataResponse + } + + func post(url: String, payload: Codable) async throws -> GetMetadataResponse { + let encodedUrl = url.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + let fullUrl = "\(self.baseUrl)?url=\(encodedUrl)" + guard let url = URL(string: fullUrl) else { + throw URLError(.badURL) + } + let encoder = JSONEncoder() + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try encoder.encode(payload) + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + throw URLError(.badServerResponse) + } + + let decoder = JSONDecoder() + let metadataResponse = try decoder.decode(GetMetadataResponse.self, from: data) + return metadataResponse + } + + func postRedirect(url: String, payload: Codable) async throws -> PostRedirectResponse { + let encodedUrl = url.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + let fullUrl = "\(self.baseUrl)redirect?url=\(encodedUrl)" + guard let url = URL(string: fullUrl) else { + throw URLError(.badURL) + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: payload) + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + + guard httpResponse.statusCode == 200 else { + throw FramesClientError.postFrameFailed(message: "Failed to post to frame \(url)", code: httpResponse.statusCode) + } + + let decoder = JSONDecoder() + let postRedirectResponse = try decoder.decode(PostRedirectResponse.self, from: data) + return postRedirectResponse + } + + func mediaUrl(url: String) -> String { + let encodedUrl = url.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + let result = "\(self.baseUrl)media?url=\(encodedUrl)" + return result + } +} diff --git a/Sources/XMTPiOS/Proto/message_contents/frames.pb.swift b/Sources/XMTPiOS/Proto/message_contents/frames.pb.swift index 1e97f05d..511ee371 100644 --- a/Sources/XMTPiOS/Proto/message_contents/frames.pb.swift +++ b/Sources/XMTPiOS/Proto/message_contents/frames.pb.swift @@ -1,5 +1,6 @@ // DO NOT EDIT. // swift-format-ignore-file +// swiftlint:disable all // // Generated by the Swift generator plugin for the protocol buffer compiler. // Source: message_contents/frames.proto @@ -24,7 +25,7 @@ fileprivate struct _GeneratedWithProtocGenSwiftVersion: SwiftProtobuf.ProtobufAP /// The message that will be signed by the Client and returned inside the /// `action_body` field of the FrameAction message -public struct Xmtp_MessageContents_FrameActionBody { +public struct Xmtp_MessageContents_FrameActionBody: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -37,6 +38,8 @@ public struct Xmtp_MessageContents_FrameActionBody { public var buttonIndex: Int32 = 0 /// Timestamp of the click in milliseconds since the epoch + /// + /// NOTE: This field was marked as deprecated in the .proto file. public var timestamp: UInt64 = 0 /// A unique identifier for the conversation, not tied to anything on the @@ -65,14 +68,15 @@ public struct Xmtp_MessageContents_FrameActionBody { /// The outer payload that will be sent as the `messageBytes` in the /// `trusted_data` part of the Frames message -public struct Xmtp_MessageContents_FrameAction { +public struct Xmtp_MessageContents_FrameAction: @unchecked Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. + /// NOTE: This field was marked as deprecated in the .proto file. public var signature: Xmtp_MessageContents_Signature { - get {return _signature ?? Xmtp_MessageContents_Signature()} - set {_signature = newValue} + get {return _signature ?? Xmtp_MessageContents_Signature()} + set {_signature = newValue} } /// Returns true if `signature` has been explicitly set. public var hasSignature: Bool {return self._signature != nil} @@ -81,9 +85,11 @@ public struct Xmtp_MessageContents_FrameAction { /// The SignedPublicKeyBundle of the signer, used to link the XMTP signature /// with a blockchain account through a chain of signatures. + /// + /// NOTE: This field was marked as deprecated in the .proto file. public var signedPublicKeyBundle: Xmtp_MessageContents_SignedPublicKeyBundle { - get {return _signedPublicKeyBundle ?? Xmtp_MessageContents_SignedPublicKeyBundle()} - set {_signedPublicKeyBundle = newValue} + get {return _signedPublicKeyBundle ?? Xmtp_MessageContents_SignedPublicKeyBundle()} + set {_signedPublicKeyBundle = newValue} } /// Returns true if `signedPublicKeyBundle` has been explicitly set. public var hasSignedPublicKeyBundle: Bool {return self._signedPublicKeyBundle != nil} @@ -94,6 +100,15 @@ public struct Xmtp_MessageContents_FrameAction { /// happen on a byte-perfect representation of the message public var actionBody: Data = Data() + /// The installation signature + public var installationSignature: Data = Data() + + /// The public installation id used to sign. + public var installationID: Data = Data() + + /// The inbox id of the installation used to sign. + public var inboxID: String = String() + public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} @@ -102,11 +117,6 @@ public struct Xmtp_MessageContents_FrameAction { fileprivate var _signedPublicKeyBundle: Xmtp_MessageContents_SignedPublicKeyBundle? = nil } -#if swift(>=5.5) && canImport(_Concurrency) -extension Xmtp_MessageContents_FrameActionBody: @unchecked Sendable {} -extension Xmtp_MessageContents_FrameAction: @unchecked Sendable {} -#endif // swift(>=5.5) && canImport(_Concurrency) - // MARK: - Code below here is support for the SwiftProtobuf runtime. fileprivate let _protobuf_package = "xmtp.message_contents" @@ -114,127 +124,145 @@ fileprivate let _protobuf_package = "xmtp.message_contents" extension Xmtp_MessageContents_FrameActionBody: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".FrameActionBody" public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 1: .standard(proto: "frame_url"), - 2: .standard(proto: "button_index"), - 3: .same(proto: "timestamp"), - 4: .standard(proto: "opaque_conversation_identifier"), - 5: .standard(proto: "unix_timestamp"), - 6: .standard(proto: "input_text"), - 7: .same(proto: "state"), - 8: .same(proto: "address"), - 9: .standard(proto: "transaction_id"), + 1: .standard(proto: "frame_url"), + 2: .standard(proto: "button_index"), + 3: .same(proto: "timestamp"), + 4: .standard(proto: "opaque_conversation_identifier"), + 5: .standard(proto: "unix_timestamp"), + 6: .standard(proto: "input_text"), + 7: .same(proto: "state"), + 8: .same(proto: "address"), + 9: .standard(proto: "transaction_id"), ] public mutating func decodeMessage(decoder: inout D) throws { - while let fieldNumber = try decoder.nextFieldNumber() { - // The use of inline closures is to circumvent an issue where the compiler - // allocates stack space for every case branch when no optimizations are - // enabled. https://github.com/apple/swift-protobuf/issues/1034 - switch fieldNumber { - case 1: try { try decoder.decodeSingularStringField(value: &self.frameURL) }() - case 2: try { try decoder.decodeSingularInt32Field(value: &self.buttonIndex) }() - case 3: try { try decoder.decodeSingularUInt64Field(value: &self.timestamp) }() - case 4: try { try decoder.decodeSingularStringField(value: &self.opaqueConversationIdentifier) }() - case 5: try { try decoder.decodeSingularUInt32Field(value: &self.unixTimestamp) }() - case 6: try { try decoder.decodeSingularStringField(value: &self.inputText) }() - case 7: try { try decoder.decodeSingularStringField(value: &self.state) }() - case 8: try { try decoder.decodeSingularStringField(value: &self.address) }() - case 9: try { try decoder.decodeSingularStringField(value: &self.transactionID) }() - default: break - } - } + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.frameURL) }() + case 2: try { try decoder.decodeSingularInt32Field(value: &self.buttonIndex) }() + case 3: try { try decoder.decodeSingularUInt64Field(value: &self.timestamp) }() + case 4: try { try decoder.decodeSingularStringField(value: &self.opaqueConversationIdentifier) }() + case 5: try { try decoder.decodeSingularUInt32Field(value: &self.unixTimestamp) }() + case 6: try { try decoder.decodeSingularStringField(value: &self.inputText) }() + case 7: try { try decoder.decodeSingularStringField(value: &self.state) }() + case 8: try { try decoder.decodeSingularStringField(value: &self.address) }() + case 9: try { try decoder.decodeSingularStringField(value: &self.transactionID) }() + default: break + } + } } public func traverse(visitor: inout V) throws { - if !self.frameURL.isEmpty { - try visitor.visitSingularStringField(value: self.frameURL, fieldNumber: 1) - } - if self.buttonIndex != 0 { - try visitor.visitSingularInt32Field(value: self.buttonIndex, fieldNumber: 2) - } - if self.timestamp != 0 { - try visitor.visitSingularUInt64Field(value: self.timestamp, fieldNumber: 3) - } - if !self.opaqueConversationIdentifier.isEmpty { - try visitor.visitSingularStringField(value: self.opaqueConversationIdentifier, fieldNumber: 4) - } - if self.unixTimestamp != 0 { - try visitor.visitSingularUInt32Field(value: self.unixTimestamp, fieldNumber: 5) - } - if !self.inputText.isEmpty { - try visitor.visitSingularStringField(value: self.inputText, fieldNumber: 6) - } - if !self.state.isEmpty { - try visitor.visitSingularStringField(value: self.state, fieldNumber: 7) - } - if !self.address.isEmpty { - try visitor.visitSingularStringField(value: self.address, fieldNumber: 8) - } - if !self.transactionID.isEmpty { - try visitor.visitSingularStringField(value: self.transactionID, fieldNumber: 9) - } - try unknownFields.traverse(visitor: &visitor) + if !self.frameURL.isEmpty { + try visitor.visitSingularStringField(value: self.frameURL, fieldNumber: 1) + } + if self.buttonIndex != 0 { + try visitor.visitSingularInt32Field(value: self.buttonIndex, fieldNumber: 2) + } + if self.timestamp != 0 { + try visitor.visitSingularUInt64Field(value: self.timestamp, fieldNumber: 3) + } + if !self.opaqueConversationIdentifier.isEmpty { + try visitor.visitSingularStringField(value: self.opaqueConversationIdentifier, fieldNumber: 4) + } + if self.unixTimestamp != 0 { + try visitor.visitSingularUInt32Field(value: self.unixTimestamp, fieldNumber: 5) + } + if !self.inputText.isEmpty { + try visitor.visitSingularStringField(value: self.inputText, fieldNumber: 6) + } + if !self.state.isEmpty { + try visitor.visitSingularStringField(value: self.state, fieldNumber: 7) + } + if !self.address.isEmpty { + try visitor.visitSingularStringField(value: self.address, fieldNumber: 8) + } + if !self.transactionID.isEmpty { + try visitor.visitSingularStringField(value: self.transactionID, fieldNumber: 9) + } + try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: Xmtp_MessageContents_FrameActionBody, rhs: Xmtp_MessageContents_FrameActionBody) -> Bool { - if lhs.frameURL != rhs.frameURL {return false} - if lhs.buttonIndex != rhs.buttonIndex {return false} - if lhs.timestamp != rhs.timestamp {return false} - if lhs.opaqueConversationIdentifier != rhs.opaqueConversationIdentifier {return false} - if lhs.unixTimestamp != rhs.unixTimestamp {return false} - if lhs.inputText != rhs.inputText {return false} - if lhs.state != rhs.state {return false} - if lhs.address != rhs.address {return false} - if lhs.transactionID != rhs.transactionID {return false} - if lhs.unknownFields != rhs.unknownFields {return false} - return true + if lhs.frameURL != rhs.frameURL {return false} + if lhs.buttonIndex != rhs.buttonIndex {return false} + if lhs.timestamp != rhs.timestamp {return false} + if lhs.opaqueConversationIdentifier != rhs.opaqueConversationIdentifier {return false} + if lhs.unixTimestamp != rhs.unixTimestamp {return false} + if lhs.inputText != rhs.inputText {return false} + if lhs.state != rhs.state {return false} + if lhs.address != rhs.address {return false} + if lhs.transactionID != rhs.transactionID {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true } } extension Xmtp_MessageContents_FrameAction: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".FrameAction" public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 1: .same(proto: "signature"), - 2: .standard(proto: "signed_public_key_bundle"), - 3: .standard(proto: "action_body"), + 1: .same(proto: "signature"), + 2: .standard(proto: "signed_public_key_bundle"), + 3: .standard(proto: "action_body"), + 4: .standard(proto: "installation_signature"), + 5: .standard(proto: "installation_id"), + 6: .standard(proto: "inbox_id"), ] public mutating func decodeMessage(decoder: inout D) throws { - while let fieldNumber = try decoder.nextFieldNumber() { - // The use of inline closures is to circumvent an issue where the compiler - // allocates stack space for every case branch when no optimizations are - // enabled. https://github.com/apple/swift-protobuf/issues/1034 - switch fieldNumber { - case 1: try { try decoder.decodeSingularMessageField(value: &self._signature) }() - case 2: try { try decoder.decodeSingularMessageField(value: &self._signedPublicKeyBundle) }() - case 3: try { try decoder.decodeSingularBytesField(value: &self.actionBody) }() - default: break - } - } + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularMessageField(value: &self._signature) }() + case 2: try { try decoder.decodeSingularMessageField(value: &self._signedPublicKeyBundle) }() + case 3: try { try decoder.decodeSingularBytesField(value: &self.actionBody) }() + case 4: try { try decoder.decodeSingularBytesField(value: &self.installationSignature) }() + case 5: try { try decoder.decodeSingularBytesField(value: &self.installationID) }() + case 6: try { try decoder.decodeSingularStringField(value: &self.inboxID) }() + default: break + } + } } public func traverse(visitor: inout V) throws { - // The use of inline closures is to circumvent an issue where the compiler - // allocates stack space for every if/case branch local when no optimizations - // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and - // https://github.com/apple/swift-protobuf/issues/1182 - try { if let v = self._signature { - try visitor.visitSingularMessageField(value: v, fieldNumber: 1) - } }() - try { if let v = self._signedPublicKeyBundle { - try visitor.visitSingularMessageField(value: v, fieldNumber: 2) - } }() - if !self.actionBody.isEmpty { - try visitor.visitSingularBytesField(value: self.actionBody, fieldNumber: 3) - } - try unknownFields.traverse(visitor: &visitor) + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + try { if let v = self._signature { + try visitor.visitSingularMessageField(value: v, fieldNumber: 1) + } }() + try { if let v = self._signedPublicKeyBundle { + try visitor.visitSingularMessageField(value: v, fieldNumber: 2) + } }() + if !self.actionBody.isEmpty { + try visitor.visitSingularBytesField(value: self.actionBody, fieldNumber: 3) + } + if !self.installationSignature.isEmpty { + try visitor.visitSingularBytesField(value: self.installationSignature, fieldNumber: 4) + } + if !self.installationID.isEmpty { + try visitor.visitSingularBytesField(value: self.installationID, fieldNumber: 5) + } + if !self.inboxID.isEmpty { + try visitor.visitSingularStringField(value: self.inboxID, fieldNumber: 6) + } + try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: Xmtp_MessageContents_FrameAction, rhs: Xmtp_MessageContents_FrameAction) -> Bool { - if lhs._signature != rhs._signature {return false} - if lhs._signedPublicKeyBundle != rhs._signedPublicKeyBundle {return false} - if lhs.actionBody != rhs.actionBody {return false} - if lhs.unknownFields != rhs.unknownFields {return false} - return true + if lhs._signature != rhs._signature {return false} + if lhs._signedPublicKeyBundle != rhs._signedPublicKeyBundle {return false} + if lhs.actionBody != rhs.actionBody {return false} + if lhs.installationSignature != rhs.installationSignature {return false} + if lhs.installationID != rhs.installationID {return false} + if lhs.inboxID != rhs.inboxID {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true } } diff --git a/Tests/XMTPTests/FramesTests.swift b/Tests/XMTPTests/FramesTests.swift new file mode 100644 index 00000000..fb870aab --- /dev/null +++ b/Tests/XMTPTests/FramesTests.swift @@ -0,0 +1,61 @@ +// +// FramesTests.swift +// +// +// Created by Alex Risch on 4/1/24. +// + +import Foundation +import XCTest +@testable import XMTPiOS + +final class FramesTests: XCTestCase { + func testInstantiateFramesClient() async throws { + let frameUrl = "https://fc-polls-five.vercel.app/polls/03710836-bc1d-4921-9e24-89d82015c53b?env=production" + + let key = try Crypto.secureRandomBytes(count: 32) + let bo = try PrivateKey.generate() + let client = try await Client.create( + account: bo, + options: .init( + api: .init(env: .production, isSecure: true), + dbEncryptionKey: key + ) + ) + + let framesClient = FramesClient(xmtpClient: client) + let metadata = try await framesClient.proxy.readMetadata(url: frameUrl) + let conversationTopic = "foo" + let participantAccountAddresses = ["amal", "bola"] + let dmInputs = DmActionInputs( + conversationTopic: conversationTopic, participantAccountAddresses: participantAccountAddresses) + let conversationInputs = ConversationActionInputs.dm(dmInputs) + let frameInputs = FrameActionInputs(frameUrl: frameUrl, buttonIndex: 1, inputText: nil, state: nil, conversationInputs: conversationInputs) + let signedPayload = try await framesClient.signFrameAction(inputs: frameInputs) + + guard let postUrl = metadata.extractedTags["fc:frame:post_url"] else { + throw NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "postUrl should exist"]) + } + let response = try await framesClient.proxy.post(url: postUrl, payload: signedPayload) + + guard response.extractedTags["fc:frame"] == "vNext" else { + throw NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "response should have expected extractedTags"]) + } + + guard let imageUrl = response.extractedTags["fc:frame:image"] else { + throw NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "imageUrl should exist"]) + } + + let mediaUrl = try await framesClient.proxy.mediaUrl(url: imageUrl) + + let (_, mediaResponse) = try await URLSession.shared.data(from: URL(string: mediaUrl)!) + + guard (mediaResponse as? HTTPURLResponse)?.statusCode == 200 else { + throw NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "downloadedMedia should be ok"]) + } + + guard (mediaResponse as? HTTPURLResponse)?.mimeType == "image/png" else { + throw NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "downloadedMedia should be image/png"]) + } + } +}