- Use application(_:open:) instead of NSAppleEventManager for URL scheme handling (SwiftUI lifecycle compatibility) - Add waiting UI during Mastodon OAuth flow with cancel support - Improve error messages for all three services - Rewrite README with full setup instructions and project overview - Clean up .gitignore
198 lines
7.8 KiB
Swift
198 lines
7.8 KiB
Swift
import Foundation
|
|
|
|
struct MastodonClient: PostingService {
|
|
let account: Account
|
|
private let token: String
|
|
private let baseURL: String
|
|
|
|
init(account: Account, token: String) {
|
|
self.account = account
|
|
self.token = token
|
|
self.baseURL = account.instanceURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
|
}
|
|
|
|
func uploadMedia(imageData: Data, filename: String, altText: String?) async throws -> String {
|
|
let url = URL(string: "\(baseURL)/api/v2/media")!
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "POST"
|
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
|
|
var form = MultipartFormData()
|
|
form.addFile(name: "file", filename: filename, mimeType: mimeTypeFor(filename), data: imageData)
|
|
if let altText, !altText.isEmpty {
|
|
form.addField(name: "description", value: altText)
|
|
}
|
|
|
|
request.setValue(form.contentType, forHTTPHeaderField: "Content-Type")
|
|
request.httpBody = form.finalized
|
|
|
|
let (data, response) = try await URLSession.shared.data(for: request)
|
|
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
|
|
|
|
guard (200...202).contains(statusCode) else {
|
|
let body = String(data: data, encoding: .utf8) ?? "Unknown error"
|
|
throw PostingError(message: "Mastodon media upload failed (\(statusCode)): \(body)")
|
|
}
|
|
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let mediaID = json["id"] as? String
|
|
else {
|
|
throw PostingError(message: "Invalid response from Mastodon media upload")
|
|
}
|
|
|
|
return mediaID
|
|
}
|
|
|
|
func createPost(text: String, mediaIDs: [String]) async throws -> URL {
|
|
let url = URL(string: "\(baseURL)/api/v1/statuses")!
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "POST"
|
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
request.setValue(UUID().uuidString, forHTTPHeaderField: "Idempotency-Key")
|
|
|
|
var body: [String: Any] = ["status": text, "visibility": "public"]
|
|
if !mediaIDs.isEmpty {
|
|
body["media_ids"] = mediaIDs
|
|
}
|
|
|
|
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
|
|
|
let (data, response) = try await URLSession.shared.data(for: request)
|
|
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
|
|
|
|
guard statusCode == 200 else {
|
|
let errorBody = String(data: data, encoding: .utf8) ?? "Unknown error"
|
|
throw PostingError(message: "Mastodon post failed (\(statusCode)): \(errorBody)")
|
|
}
|
|
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let urlString = json["url"] as? String,
|
|
let postURL = URL(string: urlString)
|
|
else {
|
|
throw PostingError(message: "Invalid response from Mastodon")
|
|
}
|
|
|
|
return postURL
|
|
}
|
|
|
|
// MARK: - OAuth Registration
|
|
|
|
static func registerApp(instanceURL: String) async throws -> (clientID: String, clientSecret: String) {
|
|
let base = instanceURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
|
guard let url = URL(string: "\(base)/api/v1/apps") else {
|
|
throw PostingError(message: "Invalid instance URL: \(instanceURL)")
|
|
}
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "POST"
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
|
|
let body: [String: String] = [
|
|
"client_name": "qStatus",
|
|
"redirect_uris": "qstatus://mastodon-callback",
|
|
"scopes": "write:statuses write:media",
|
|
]
|
|
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
|
|
|
let data: Data
|
|
let response: URLResponse
|
|
do {
|
|
(data, response) = try await URLSession.shared.data(for: request)
|
|
} catch {
|
|
throw PostingError(message: "Cannot connect to \(instanceURL). Check the URL and try again.")
|
|
}
|
|
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
|
|
|
|
guard statusCode == 200 else {
|
|
throw PostingError(message: "Failed to register app on \(instanceURL)")
|
|
}
|
|
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let clientID = json["client_id"] as? String,
|
|
let clientSecret = json["client_secret"] as? String
|
|
else {
|
|
throw PostingError(message: "Invalid registration response")
|
|
}
|
|
|
|
return (clientID, clientSecret)
|
|
}
|
|
|
|
static func authorizeURL(instanceURL: String, clientID: String) -> URL {
|
|
let base = instanceURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
|
var components = URLComponents(string: "\(base)/oauth/authorize")!
|
|
components.queryItems = [
|
|
URLQueryItem(name: "client_id", value: clientID),
|
|
URLQueryItem(name: "scope", value: "write:statuses write:media"),
|
|
URLQueryItem(name: "redirect_uri", value: "qstatus://mastodon-callback"),
|
|
URLQueryItem(name: "response_type", value: "code"),
|
|
]
|
|
return components.url!
|
|
}
|
|
|
|
static func exchangeCode(
|
|
instanceURL: String, clientID: String, clientSecret: String, code: String
|
|
) async throws -> String {
|
|
let base = instanceURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
|
let url = URL(string: "\(base)/oauth/token")!
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "POST"
|
|
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
|
|
|
let params = [
|
|
"grant_type=authorization_code",
|
|
"client_id=\(clientID)",
|
|
"client_secret=\(clientSecret)",
|
|
"redirect_uri=qstatus://mastodon-callback",
|
|
"code=\(code)",
|
|
].joined(separator: "&")
|
|
|
|
request.httpBody = params.data(using: .utf8)
|
|
|
|
let (data, response) = try await URLSession.shared.data(for: request)
|
|
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
|
|
|
|
guard statusCode == 200 else {
|
|
throw PostingError(message: "Token exchange failed (\(statusCode))")
|
|
}
|
|
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let token = json["access_token"] as? String
|
|
else {
|
|
throw PostingError(message: "Invalid token response")
|
|
}
|
|
|
|
return token
|
|
}
|
|
|
|
static func verifyCredentials(instanceURL: String, token: String) async throws -> String {
|
|
let base = instanceURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
|
let url = URL(string: "\(base)/api/v1/accounts/verify_credentials")!
|
|
var request = URLRequest(url: url)
|
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
|
|
let (data, response) = try await URLSession.shared.data(for: request)
|
|
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
|
|
|
|
guard statusCode == 200 else {
|
|
throw PostingError(message: "Credential verification failed")
|
|
}
|
|
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let username = json["username"] as? String
|
|
else {
|
|
throw PostingError(message: "Invalid credentials response")
|
|
}
|
|
|
|
return username
|
|
}
|
|
|
|
private func mimeTypeFor(_ filename: String) -> String {
|
|
let ext = (filename as NSString).pathExtension.lowercased()
|
|
switch ext {
|
|
case "png": return "image/png"
|
|
case "gif": return "image/gif"
|
|
case "webp": return "image/webp"
|
|
default: return "image/jpeg"
|
|
}
|
|
}
|
|
}
|