Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Frames Client #302

Merged
merged 7 commits into from
Apr 3, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Package.resolved
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/xmtp/libxmtp-swift",
"state" : {
"revision" : "f8c4be0d591671067c8e7772b7c402931f33ed54",
"version" : "0.4.3-beta1"
"revision" : "b129e203957b690cbab91c165af90ddb5fa8bdb5",
"version" : "0.4.3-beta3"
}
},
{
Expand Down
101 changes: 101 additions & 0 deletions Sources/XMTPiOS/Frames/FramesClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
//
// 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

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: self.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(digest: Data) async throws -> Signature {
let key = self.xmtpClient.keys.identityKey
let privateKey = try PrivateKey(key)
let signature = try await privateKey.sign(Data(digest))
return signature
}

private func getPublicKeyBundle() async throws -> PublicKeyBundle {
let bundleBytes = self.xmtpClient.publicKeyBundle;
return try PublicKeyBundle(bundleBytes);
}

private func buildSignedFrameAction(actionBodyInputs: FrameActionBody) async throws -> Data {

let digest = sha256(input: try actionBodyInputs.serializedData())
let signature = try await self.signDigest(digest: digest)

let publicKeyBundle = try await self.getPublicKeyBundle()
var frameAction = FrameAction()
frameAction.actionBody = try actionBodyInputs.serializedData()
frameAction.signature = signature
frameAction.signedPublicKeyBundle = try SignedPublicKeyBundle(publicKeyBundle)

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 InvalidArgumentsError()
alexrisch marked this conversation as resolved.
Show resolved Hide resolved
}
guard let combined = (conversationTopic.lowercased() + dmInputs.participantAccountAddresses.map { $0.lowercased() }.sorted().joined()).data(using: .utf8) else {
throw InvalidArgumentsError()
alexrisch marked this conversation as resolved.
Show resolved Hide resolved
}
let digest = sha256(input: combined)
return digest.base64EncodedString()
}
}

}
12 changes: 12 additions & 0 deletions Sources/XMTPiOS/Frames/FramesConstants.swift
Original file line number Diff line number Diff line change
@@ -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"
31 changes: 31 additions & 0 deletions Sources/XMTPiOS/Frames/FramesErrors.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//
// File.swift
//
//
// Created by Alex Risch on 3/28/24.
//

import Foundation

enum FramesApiError: Error {
case customError(String, Int)

var localizedDescription: String {
switch self {
case .customError(let message, let status):
return "Message: \(message), Status: \(status)"
}
}

var status: Int {
switch self {
case .customError(_, let status):
return status
}
}
}

class InvalidArgumentsError: Error {

}
alexrisch marked this conversation as resolved.
Show resolved Hide resolved

166 changes: 166 additions & 0 deletions Sources/XMTPiOS/Frames/FramesTypes.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
//
// 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 InvalidArgumentsError()
alexrisch marked this conversation as resolved.
Show resolved Hide resolved
}
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
}
}

38 changes: 38 additions & 0 deletions Sources/XMTPiOS/Frames/OpenFramesProxy.swift
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
Loading