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

V3 Frames Client #432

Merged
merged 28 commits into from
Dec 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
2581d56
try and remove the bad dependencies
nplasterer Nov 8, 2024
25508b0
getting closer on removing the cocoapods
nplasterer Nov 15, 2024
0b86bf8
more clean up
nplasterer Nov 15, 2024
f72c0a7
Merge branch 'main' of https://github.com/xmtp/xmtp-ios into np/remov…
nplasterer Nov 18, 2024
6eb2cc9
update dependenecies
nplasterer Nov 18, 2024
e88d7ca
update package resolved
nplasterer Nov 18, 2024
8768fbc
remove all the old cocopods
nplasterer Nov 18, 2024
18efc0b
try bumping the xcode version down
nplasterer Nov 18, 2024
9f731ca
remove the old gzip library
nplasterer Nov 18, 2024
5cecd1d
remove force unwrap
nplasterer Nov 18, 2024
9c806cd
remove another force unwrap
nplasterer Nov 18, 2024
de64769
add back sec
nplasterer Nov 19, 2024
6d326a7
add target
nplasterer Nov 19, 2024
0be941e
try with another library
nplasterer Nov 19, 2024
f537e83
Merge branch 'main' of https://github.com/xmtp/xmtp-ios into np/remov…
nplasterer Nov 19, 2024
2f5aaab
another try on signing
nplasterer Nov 19, 2024
278040e
get the example app building
nplasterer Nov 20, 2024
9050a6f
bump the proto
nplasterer Nov 20, 2024
a441bed
bring back all the frames code
nplasterer Nov 20, 2024
874fe6d
Merge branch 'main' of https://github.com/xmtp/xmtp-ios into np/frame…
nplasterer Nov 20, 2024
4e23584
update the signer
nplasterer Nov 20, 2024
c819afc
update the protos
nplasterer Nov 28, 2024
7d55704
Merge branch 'main' of https://github.com/xmtp/xmtp-ios into np/frame…
nplasterer Dec 16, 2024
8d83a74
Merge branch 'main' of https://github.com/xmtp/xmtp-ios into np/frame…
nplasterer Dec 19, 2024
4be7d6a
small tweak to test
nplasterer Dec 19, 2024
9f3773b
closer on frames
nplasterer Dec 19, 2024
130ab54
Merge branch 'main' of https://github.com/xmtp/xmtp-ios into np/frame…
nplasterer Dec 20, 2024
b4f2e9a
get the tests passing
nplasterer Dec 20, 2024
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
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
Loading