Native macOS app for posting to Mastodon, WordPress (self-hosted), and Micro.blog. Features: global hotkey (Ctrl+Option+Cmd+T), multiple accounts with selection, image attachments (up to 4, drag-and-drop), floating panel UI, Keychain storage.
166 lines
6.8 KiB
Swift
166 lines
6.8 KiB
Swift
import Foundation
|
|
|
|
struct WordPressClient: PostingService {
|
|
let account: Account
|
|
private let token: String
|
|
private let baseURL: String
|
|
|
|
init(account: Account, token: String) {
|
|
self.account = account
|
|
self.token = token // format: "username:application_password"
|
|
self.baseURL = account.instanceURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
|
}
|
|
|
|
private var authHeader: String {
|
|
"Basic \(Data(token.utf8).base64EncodedString())"
|
|
}
|
|
|
|
func uploadMedia(imageData: Data, filename: String, altText: String?) async throws -> String {
|
|
let url = URL(string: "\(baseURL)/wp-json/wp/v2/media")!
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "POST"
|
|
request.setValue(authHeader, forHTTPHeaderField: "Authorization")
|
|
request.setValue(mimeTypeFor(filename), forHTTPHeaderField: "Content-Type")
|
|
request.setValue("attachment; filename=\"\(filename)\"", forHTTPHeaderField: "Content-Disposition")
|
|
request.httpBody = imageData
|
|
|
|
let (data, response) = try await URLSession.shared.data(for: request)
|
|
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
|
|
|
|
guard (200...201).contains(statusCode) else {
|
|
let body = String(data: data, encoding: .utf8) ?? "Unknown error"
|
|
throw PostingError(message: "WordPress media upload failed (\(statusCode)): \(body)")
|
|
}
|
|
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let mediaID = json["id"] as? Int
|
|
else {
|
|
throw PostingError(message: "Invalid response from WordPress media upload")
|
|
}
|
|
|
|
// Update alt text if provided
|
|
if let altText, !altText.isEmpty {
|
|
try await updateMediaAltText(mediaID: mediaID, altText: altText)
|
|
}
|
|
|
|
guard let sourceURL = json["source_url"] as? String else {
|
|
throw PostingError(message: "No source_url in WordPress media response")
|
|
}
|
|
|
|
return sourceURL
|
|
}
|
|
|
|
private func updateMediaAltText(mediaID: Int, altText: String) async throws {
|
|
let url = URL(string: "\(baseURL)/wp-json/wp/v2/media/\(mediaID)")!
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "POST"
|
|
request.setValue(authHeader, forHTTPHeaderField: "Authorization")
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
request.httpBody = try JSONSerialization.data(withJSONObject: ["alt_text": altText])
|
|
|
|
let (_, response) = try await URLSession.shared.data(for: request)
|
|
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
|
|
guard statusCode == 200 else {
|
|
throw PostingError(message: "Failed to update alt text (\(statusCode))")
|
|
}
|
|
}
|
|
|
|
func createPost(text: String, mediaIDs: [String]) async throws -> URL {
|
|
// mediaIDs are source_url strings for WordPress
|
|
let url = URL(string: "\(baseURL)/wp-json/wp/v2/posts")!
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "POST"
|
|
request.setValue(authHeader, forHTTPHeaderField: "Authorization")
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
|
|
var content = text
|
|
// Append images as HTML
|
|
for imageURL in mediaIDs {
|
|
content += "\n<img src=\"\(imageURL)\" />"
|
|
}
|
|
|
|
let body: [String: Any] = [
|
|
"content": content,
|
|
"status": "publish",
|
|
"format": "status",
|
|
]
|
|
|
|
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 (200...201).contains(statusCode) else {
|
|
let errorBody = String(data: data, encoding: .utf8) ?? "Unknown error"
|
|
throw PostingError(message: "WordPress post failed (\(statusCode)): \(errorBody)")
|
|
}
|
|
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let link = json["link"] as? String,
|
|
let postURL = URL(string: link)
|
|
else {
|
|
throw PostingError(message: "Invalid response from WordPress")
|
|
}
|
|
|
|
return postURL
|
|
}
|
|
|
|
// MARK: - Auth Discovery
|
|
|
|
static func discoverAuthEndpoint(siteURL: String) async throws -> String {
|
|
let base = siteURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
|
let url = URL(string: "\(base)/wp-json/")!
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "GET"
|
|
|
|
let (data, response) = try await URLSession.shared.data(for: request)
|
|
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
|
|
|
|
guard statusCode == 200 else {
|
|
throw PostingError(message: "Cannot reach WordPress API at \(siteURL)")
|
|
}
|
|
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let auth = json["authentication"] as? [String: Any],
|
|
let appPasswords = auth["application-passwords"] as? [String: Any],
|
|
let endpoints = appPasswords["endpoints"] as? [String: Any],
|
|
let authURL = endpoints["authorization"] as? String
|
|
else {
|
|
throw PostingError(message: "Application Passwords not available on this site. Ensure WordPress 5.6+.")
|
|
}
|
|
|
|
return authURL
|
|
}
|
|
|
|
static func buildAuthURL(authEndpoint: String) -> URL {
|
|
var components = URLComponents(string: authEndpoint)!
|
|
components.queryItems = [
|
|
URLQueryItem(name: "app_name", value: "qStatus"),
|
|
URLQueryItem(name: "app_id", value: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"),
|
|
URLQueryItem(name: "success_url", value: "qstatus://wordpress-callback"),
|
|
URLQueryItem(name: "reject_url", value: "qstatus://wordpress-rejected"),
|
|
]
|
|
return components.url!
|
|
}
|
|
|
|
static func verifyCredentials(siteURL: String, username: String, password: String) async throws -> Bool {
|
|
let base = siteURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
|
let url = URL(string: "\(base)/wp-json/wp/v2/users/me")!
|
|
var request = URLRequest(url: url)
|
|
let creds = "\(username):\(password)"
|
|
request.setValue("Basic \(Data(creds.utf8).base64EncodedString())", forHTTPHeaderField: "Authorization")
|
|
|
|
let (_, response) = try await URLSession.shared.data(for: request)
|
|
return (response as? HTTPURLResponse)?.statusCode == 200
|
|
}
|
|
|
|
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"
|
|
}
|
|
}
|
|
}
|