Deduplicate shared constants, MIME helper, and cache normalized URLs
- Extract OAuth UserDefaults keys into shared OAuthKeys enum - Extract triplicated mimeTypeFor() into shared mimeTypeForImage() - Cache normalized base URL at client init instead of per-request
This commit is contained in:
parent
79f24ea1a4
commit
733334df9b
8 changed files with 54 additions and 84 deletions
|
|
@ -4,9 +4,6 @@ import SwiftUI
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
private static let pendingMastodonAccountKey = "qstatus.pending-mastodon-account"
|
|
||||||
private static let pendingMastodonErrorKey = "qstatus.pending-mastodon-account-error"
|
|
||||||
|
|
||||||
let appState = AppState()
|
let appState = AppState()
|
||||||
let accountStore = AccountStore()
|
let accountStore = AccountStore()
|
||||||
var floatingPanel: FloatingPanel?
|
var floatingPanel: FloatingPanel?
|
||||||
|
|
@ -203,7 +200,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
guard let code = components.queryItems?.first(where: { $0.name == "code" })?.value else { return }
|
guard let code = components.queryItems?.first(where: { $0.name == "code" })?.value else { return }
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
guard let accountData = UserDefaults.standard.data(forKey: Self.pendingMastodonAccountKey),
|
guard let accountData = UserDefaults.standard.data(forKey: OAuthKeys.pendingMastodonAccount),
|
||||||
var account = try? JSONDecoder().decode(Account.self, from: accountData),
|
var account = try? JSONDecoder().decode(Account.self, from: accountData),
|
||||||
let clientID = account.mastodonClientID,
|
let clientID = account.mastodonClientID,
|
||||||
let clientSecret = account.mastodonClientSecret
|
let clientSecret = account.mastodonClientSecret
|
||||||
|
|
@ -226,14 +223,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
account.displayName = "@\(username)@\(account.instanceURL.replacingOccurrences(of: "https://", with: ""))"
|
account.displayName = "@\(username)@\(account.instanceURL.replacingOccurrences(of: "https://", with: ""))"
|
||||||
try accountStore.addAccount(account, token: token)
|
try accountStore.addAccount(account, token: token)
|
||||||
|
|
||||||
UserDefaults.standard.removeObject(forKey: Self.pendingMastodonAccountKey)
|
UserDefaults.standard.removeObject(forKey: OAuthKeys.pendingMastodonAccount)
|
||||||
UserDefaults.standard.removeObject(forKey: Self.pendingMastodonErrorKey)
|
UserDefaults.standard.removeObject(forKey: OAuthKeys.pendingMastodonError)
|
||||||
} catch {
|
} catch {
|
||||||
UserDefaults.standard.set(
|
UserDefaults.standard.set(
|
||||||
error.localizedDescription,
|
error.localizedDescription,
|
||||||
forKey: Self.pendingMastodonErrorKey
|
forKey: OAuthKeys.pendingMastodonError
|
||||||
)
|
)
|
||||||
UserDefaults.standard.removeObject(forKey: Self.pendingMastodonAccountKey)
|
UserDefaults.standard.removeObject(forKey: OAuthKeys.pendingMastodonAccount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,3 +10,18 @@ struct PostingError: LocalizedError {
|
||||||
let message: String
|
let message: String
|
||||||
var errorDescription: String? { message }
|
var errorDescription: String? { message }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum OAuthKeys {
|
||||||
|
static let pendingMastodonAccount = "qstatus.pending-mastodon-account"
|
||||||
|
static let pendingMastodonError = "qstatus.pending-mastodon-account-error"
|
||||||
|
}
|
||||||
|
|
||||||
|
func mimeTypeForImage(_ 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,27 +3,25 @@ import Foundation
|
||||||
struct MastodonClient: PostingService {
|
struct MastodonClient: PostingService {
|
||||||
let account: Account
|
let account: Account
|
||||||
private let token: String
|
private let token: String
|
||||||
private let rawBaseURL: String
|
private let baseURL: String
|
||||||
|
|
||||||
init(account: Account, token: String) {
|
init(account: Account, token: String) throws {
|
||||||
self.account = account
|
self.account = account
|
||||||
self.token = token
|
self.token = token
|
||||||
self.rawBaseURL = account.instanceURL
|
self.baseURL = try URLNormalizer.normalizeBaseURL(
|
||||||
|
account.instanceURL, fieldName: "Mastodon instance URL"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func uploadMedia(imageData: Data, filename: String, altText: String?) async throws -> String {
|
func uploadMedia(imageData: Data, filename: String, altText: String?) async throws -> String {
|
||||||
let url = try URLNormalizer.endpointURL(
|
let url = try URLNormalizer.endpointURL(base: baseURL, path: "/api/v2/media")
|
||||||
baseURL: rawBaseURL,
|
|
||||||
path: "/api/v2/media",
|
|
||||||
fieldName: "Mastodon instance URL"
|
|
||||||
)
|
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.httpMethod = "POST"
|
request.httpMethod = "POST"
|
||||||
request.timeoutInterval = 30
|
request.timeoutInterval = 30
|
||||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
|
||||||
var form = MultipartFormData()
|
var form = MultipartFormData()
|
||||||
form.addFile(name: "file", filename: filename, mimeType: mimeTypeFor(filename), data: imageData)
|
form.addFile(name: "file", filename: filename, mimeType: mimeTypeForImage(filename), data: imageData)
|
||||||
if let altText, !altText.isEmpty {
|
if let altText, !altText.isEmpty {
|
||||||
form.addField(name: "description", value: altText)
|
form.addField(name: "description", value: altText)
|
||||||
}
|
}
|
||||||
|
|
@ -52,11 +50,7 @@ struct MastodonClient: PostingService {
|
||||||
}
|
}
|
||||||
|
|
||||||
func createPost(text: String, mediaIDs: [String]) async throws -> URL {
|
func createPost(text: String, mediaIDs: [String]) async throws -> URL {
|
||||||
let url = try URLNormalizer.endpointURL(
|
let url = try URLNormalizer.endpointURL(base: baseURL, path: "/api/v1/statuses")
|
||||||
baseURL: rawBaseURL,
|
|
||||||
path: "/api/v1/statuses",
|
|
||||||
fieldName: "Mastodon instance URL"
|
|
||||||
)
|
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.httpMethod = "POST"
|
request.httpMethod = "POST"
|
||||||
request.timeoutInterval = 30
|
request.timeoutInterval = 30
|
||||||
|
|
@ -226,14 +220,4 @@ struct MastodonClient: PostingService {
|
||||||
|
|
||||||
return username
|
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ struct MicroblogClient: PostingService {
|
||||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
|
||||||
var form = MultipartFormData()
|
var form = MultipartFormData()
|
||||||
form.addFile(name: "file", filename: filename, mimeType: mimeTypeFor(filename), data: imageData)
|
form.addFile(name: "file", filename: filename, mimeType: mimeTypeForImage(filename), data: imageData)
|
||||||
|
|
||||||
request.setValue(form.contentType, forHTTPHeaderField: "Content-Type")
|
request.setValue(form.contentType, forHTTPHeaderField: "Content-Type")
|
||||||
request.httpBody = form.finalized
|
request.httpBody = form.finalized
|
||||||
|
|
@ -139,14 +139,4 @@ struct MicroblogClient: PostingService {
|
||||||
|
|
||||||
return "Micro.blog"
|
return "Micro.blog"
|
||||||
}
|
}
|
||||||
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ struct PostingManager {
|
||||||
|
|
||||||
switch account.serviceType {
|
switch account.serviceType {
|
||||||
case .mastodon:
|
case .mastodon:
|
||||||
let client = MastodonClient(account: account, token: token)
|
let client = try MastodonClient(account: account, token: token)
|
||||||
var mediaIDs: [String] = []
|
var mediaIDs: [String] = []
|
||||||
for img in imageData {
|
for img in imageData {
|
||||||
let id = try await client.uploadMedia(imageData: img.data, filename: img.filename, altText: nil)
|
let id = try await client.uploadMedia(imageData: img.data, filename: img.filename, altText: nil)
|
||||||
|
|
@ -44,7 +44,7 @@ struct PostingManager {
|
||||||
return try await client.createPost(text: text, mediaIDs: mediaIDs)
|
return try await client.createPost(text: text, mediaIDs: mediaIDs)
|
||||||
|
|
||||||
case .wordpress:
|
case .wordpress:
|
||||||
let client = WordPressClient(account: account, token: token)
|
let client = try WordPressClient(account: account, token: token)
|
||||||
var mediaIDs: [String] = []
|
var mediaIDs: [String] = []
|
||||||
for img in imageData {
|
for img in imageData {
|
||||||
let id = try await client.uploadMedia(imageData: img.data, filename: img.filename, altText: nil)
|
let id = try await client.uploadMedia(imageData: img.data, filename: img.filename, altText: nil)
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,14 @@ import Foundation
|
||||||
struct WordPressClient: PostingService {
|
struct WordPressClient: PostingService {
|
||||||
let account: Account
|
let account: Account
|
||||||
private let token: String
|
private let token: String
|
||||||
private let rawBaseURL: String
|
private let baseURL: String
|
||||||
|
|
||||||
init(account: Account, token: String) {
|
init(account: Account, token: String) throws {
|
||||||
self.account = account
|
self.account = account
|
||||||
self.token = token // format: "username:application_password"
|
self.token = token // format: "username:application_password"
|
||||||
self.rawBaseURL = account.instanceURL
|
self.baseURL = try URLNormalizer.normalizeBaseURL(
|
||||||
|
account.instanceURL, fieldName: "WordPress site URL"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var authHeader: String {
|
private var authHeader: String {
|
||||||
|
|
@ -16,16 +18,12 @@ struct WordPressClient: PostingService {
|
||||||
}
|
}
|
||||||
|
|
||||||
func uploadMedia(imageData: Data, filename: String, altText: String?) async throws -> String {
|
func uploadMedia(imageData: Data, filename: String, altText: String?) async throws -> String {
|
||||||
let url = try URLNormalizer.endpointURL(
|
let url = try URLNormalizer.endpointURL(base: baseURL, path: "/wp-json/wp/v2/media")
|
||||||
baseURL: rawBaseURL,
|
|
||||||
path: "/wp-json/wp/v2/media",
|
|
||||||
fieldName: "WordPress site URL"
|
|
||||||
)
|
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.httpMethod = "POST"
|
request.httpMethod = "POST"
|
||||||
request.timeoutInterval = 30
|
request.timeoutInterval = 30
|
||||||
request.setValue(authHeader, forHTTPHeaderField: "Authorization")
|
request.setValue(authHeader, forHTTPHeaderField: "Authorization")
|
||||||
request.setValue(mimeTypeFor(filename), forHTTPHeaderField: "Content-Type")
|
request.setValue(mimeTypeForImage(filename), forHTTPHeaderField: "Content-Type")
|
||||||
request.setValue("attachment; filename=\"\(filename)\"", forHTTPHeaderField: "Content-Disposition")
|
request.setValue("attachment; filename=\"\(filename)\"", forHTTPHeaderField: "Content-Disposition")
|
||||||
request.httpBody = imageData
|
request.httpBody = imageData
|
||||||
|
|
||||||
|
|
@ -59,11 +57,7 @@ struct WordPressClient: PostingService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateMediaAltText(mediaID: Int, altText: String) async throws {
|
private func updateMediaAltText(mediaID: Int, altText: String) async throws {
|
||||||
let url = try URLNormalizer.endpointURL(
|
let url = try URLNormalizer.endpointURL(base: baseURL, path: "/wp-json/wp/v2/media/\(mediaID)")
|
||||||
baseURL: rawBaseURL,
|
|
||||||
path: "/wp-json/wp/v2/media/\(mediaID)",
|
|
||||||
fieldName: "WordPress site URL"
|
|
||||||
)
|
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.httpMethod = "POST"
|
request.httpMethod = "POST"
|
||||||
request.timeoutInterval = 30
|
request.timeoutInterval = 30
|
||||||
|
|
@ -83,11 +77,7 @@ struct WordPressClient: PostingService {
|
||||||
|
|
||||||
func createPost(text: String, mediaIDs: [String]) async throws -> URL {
|
func createPost(text: String, mediaIDs: [String]) async throws -> URL {
|
||||||
// mediaIDs are source_url strings for WordPress
|
// mediaIDs are source_url strings for WordPress
|
||||||
let url = try URLNormalizer.endpointURL(
|
let url = try URLNormalizer.endpointURL(base: baseURL, path: "/wp-json/wp/v2/posts")
|
||||||
baseURL: rawBaseURL,
|
|
||||||
path: "/wp-json/wp/v2/posts",
|
|
||||||
fieldName: "WordPress site URL"
|
|
||||||
)
|
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.httpMethod = "POST"
|
request.httpMethod = "POST"
|
||||||
request.timeoutInterval = 30
|
request.timeoutInterval = 30
|
||||||
|
|
@ -204,14 +194,4 @@ struct WordPressClient: PostingService {
|
||||||
throw PostingError(message: "Unexpected response (\(statusCode)): \(body)")
|
throw PostingError(message: "Unexpected response (\(statusCode)): \(body)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,4 +39,11 @@ enum URLNormalizer {
|
||||||
}
|
}
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func endpointURL(base normalizedBase: String, path: String) throws -> URL {
|
||||||
|
guard let url = URL(string: normalizedBase + path) else {
|
||||||
|
throw PostingError(message: "Failed to build API URL.")
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,9 +73,6 @@ struct SettingsView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
struct AddAccountView: View {
|
struct AddAccountView: View {
|
||||||
private static let pendingMastodonAccountKey = "qstatus.pending-mastodon-account"
|
|
||||||
private static let pendingMastodonErrorKey = "qstatus.pending-mastodon-account-error"
|
|
||||||
|
|
||||||
let accountStore: AccountStore
|
let accountStore: AccountStore
|
||||||
@Binding var isPresented: Bool
|
@Binding var isPresented: Bool
|
||||||
@State private var selectedService: ServiceType = .mastodon
|
@State private var selectedService: ServiceType = .mastodon
|
||||||
|
|
@ -133,8 +130,8 @@ struct AddAccountView: View {
|
||||||
Button(waitingForOAuth ? "Cancel Auth" : "Cancel") {
|
Button(waitingForOAuth ? "Cancel Auth" : "Cancel") {
|
||||||
if waitingForOAuth {
|
if waitingForOAuth {
|
||||||
waitingForOAuth = false
|
waitingForOAuth = false
|
||||||
UserDefaults.standard.removeObject(forKey: Self.pendingMastodonAccountKey)
|
UserDefaults.standard.removeObject(forKey: OAuthKeys.pendingMastodonAccount)
|
||||||
UserDefaults.standard.removeObject(forKey: Self.pendingMastodonErrorKey)
|
UserDefaults.standard.removeObject(forKey: OAuthKeys.pendingMastodonError)
|
||||||
}
|
}
|
||||||
isPresented = false
|
isPresented = false
|
||||||
}
|
}
|
||||||
|
|
@ -271,8 +268,8 @@ struct AddAccountView: View {
|
||||||
account.mastodonClientSecret = clientSecret
|
account.mastodonClientSecret = clientSecret
|
||||||
|
|
||||||
let accountData = try JSONEncoder().encode(account)
|
let accountData = try JSONEncoder().encode(account)
|
||||||
UserDefaults.standard.removeObject(forKey: Self.pendingMastodonErrorKey)
|
UserDefaults.standard.removeObject(forKey: OAuthKeys.pendingMastodonError)
|
||||||
UserDefaults.standard.set(accountData, forKey: Self.pendingMastodonAccountKey)
|
UserDefaults.standard.set(accountData, forKey: OAuthKeys.pendingMastodonAccount)
|
||||||
|
|
||||||
let authURL = try MastodonClient.authorizeURL(instanceURL: url, clientID: clientID)
|
let authURL = try MastodonClient.authorizeURL(instanceURL: url, clientID: clientID)
|
||||||
NSWorkspace.shared.open(authURL)
|
NSWorkspace.shared.open(authURL)
|
||||||
|
|
@ -282,11 +279,11 @@ struct AddAccountView: View {
|
||||||
// Poll for the account to be added by the URL callback handler
|
// Poll for the account to be added by the URL callback handler
|
||||||
for _ in 0..<120 { // wait up to 2 minutes
|
for _ in 0..<120 { // wait up to 2 minutes
|
||||||
try? await Task.sleep(for: .seconds(1))
|
try? await Task.sleep(for: .seconds(1))
|
||||||
if let oauthError = UserDefaults.standard.string(forKey: Self.pendingMastodonErrorKey) {
|
if let oauthError = UserDefaults.standard.string(forKey: OAuthKeys.pendingMastodonError) {
|
||||||
UserDefaults.standard.removeObject(forKey: Self.pendingMastodonErrorKey)
|
UserDefaults.standard.removeObject(forKey: OAuthKeys.pendingMastodonError)
|
||||||
throw PostingError(message: oauthError)
|
throw PostingError(message: oauthError)
|
||||||
}
|
}
|
||||||
if UserDefaults.standard.data(forKey: Self.pendingMastodonAccountKey) == nil {
|
if UserDefaults.standard.data(forKey: OAuthKeys.pendingMastodonAccount) == nil {
|
||||||
// Callback was handled, account was added
|
// Callback was handled, account was added
|
||||||
waitingForOAuth = false
|
waitingForOAuth = false
|
||||||
isPresented = false
|
isPresented = false
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue