Skip to content

Commit

Permalink
V3 Frames Client (#432)
Browse files Browse the repository at this point in the history
* try and remove the bad dependencies

* getting closer on removing the cocoapods

* more clean up

* update dependenecies

* update package resolved

* remove all the old cocopods

* try bumping the xcode version down

* remove the old gzip library

* remove force unwrap

* remove another force unwrap

* add back sec

* add target

* try with another library

* another try on signing

* get the example app building

* bump the proto

* bring back all the frames code

* update the signer

* update the protos

* small tweak to test

* closer on frames

* get the tests passing
  • Loading branch information
nplasterer authored Dec 20, 2024
1 parent bd7c6b0 commit cb9e270
Show file tree
Hide file tree
Showing 7 changed files with 628 additions and 109 deletions.
118 changes: 118 additions & 0 deletions Sources/XMTPiOS/Frames/FramesClient.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}

}
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"
165 changes: 165 additions & 0 deletions Sources/XMTPiOS/Frames/FramesTypes.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
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

0 comments on commit cb9e270

Please sign in to comment.