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:
Paweł Orzech 2026-03-07 22:22:08 +01:00
parent 79f24ea1a4
commit 733334df9b
No known key found for this signature in database
8 changed files with 54 additions and 84 deletions

View file

@ -4,9 +4,6 @@ import SwiftUI
@MainActor
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 accountStore = AccountStore()
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 }
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),
let clientID = account.mastodonClientID,
let clientSecret = account.mastodonClientSecret
@ -226,14 +223,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
account.displayName = "@\(username)@\(account.instanceURL.replacingOccurrences(of: "https://", with: ""))"
try accountStore.addAccount(account, token: token)
UserDefaults.standard.removeObject(forKey: Self.pendingMastodonAccountKey)
UserDefaults.standard.removeObject(forKey: Self.pendingMastodonErrorKey)
UserDefaults.standard.removeObject(forKey: OAuthKeys.pendingMastodonAccount)
UserDefaults.standard.removeObject(forKey: OAuthKeys.pendingMastodonError)
} catch {
UserDefaults.standard.set(
error.localizedDescription,
forKey: Self.pendingMastodonErrorKey
forKey: OAuthKeys.pendingMastodonError
)
UserDefaults.standard.removeObject(forKey: Self.pendingMastodonAccountKey)
UserDefaults.standard.removeObject(forKey: OAuthKeys.pendingMastodonAccount)
}
}
}

View file

@ -10,3 +10,18 @@ struct PostingError: LocalizedError {
let message: String
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"
}
}

View file

@ -3,27 +3,25 @@ import Foundation
struct MastodonClient: PostingService {
let account: Account
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.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 {
let url = try URLNormalizer.endpointURL(
baseURL: rawBaseURL,
path: "/api/v2/media",
fieldName: "Mastodon instance URL"
)
let url = try URLNormalizer.endpointURL(base: baseURL, path: "/api/v2/media")
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.timeoutInterval = 30
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
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 {
form.addField(name: "description", value: altText)
}
@ -52,11 +50,7 @@ struct MastodonClient: PostingService {
}
func createPost(text: String, mediaIDs: [String]) async throws -> URL {
let url = try URLNormalizer.endpointURL(
baseURL: rawBaseURL,
path: "/api/v1/statuses",
fieldName: "Mastodon instance URL"
)
let url = try URLNormalizer.endpointURL(base: baseURL, path: "/api/v1/statuses")
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.timeoutInterval = 30
@ -226,14 +220,4 @@ struct MastodonClient: PostingService {
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"
}
}
}

View file

@ -22,7 +22,7 @@ struct MicroblogClient: PostingService {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
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.httpBody = form.finalized
@ -139,14 +139,4 @@ struct MicroblogClient: PostingService {
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"
}
}
}

View file

@ -35,7 +35,7 @@ struct PostingManager {
switch account.serviceType {
case .mastodon:
let client = MastodonClient(account: account, token: token)
let client = try MastodonClient(account: account, token: token)
var mediaIDs: [String] = []
for img in imageData {
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)
case .wordpress:
let client = WordPressClient(account: account, token: token)
let client = try WordPressClient(account: account, token: token)
var mediaIDs: [String] = []
for img in imageData {
let id = try await client.uploadMedia(imageData: img.data, filename: img.filename, altText: nil)

View file

@ -3,12 +3,14 @@ import Foundation
struct WordPressClient: PostingService {
let account: Account
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.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 {
@ -16,16 +18,12 @@ struct WordPressClient: PostingService {
}
func uploadMedia(imageData: Data, filename: String, altText: String?) async throws -> String {
let url = try URLNormalizer.endpointURL(
baseURL: rawBaseURL,
path: "/wp-json/wp/v2/media",
fieldName: "WordPress site URL"
)
let url = try URLNormalizer.endpointURL(base: baseURL, path: "/wp-json/wp/v2/media")
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.timeoutInterval = 30
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.httpBody = imageData
@ -59,11 +57,7 @@ struct WordPressClient: PostingService {
}
private func updateMediaAltText(mediaID: Int, altText: String) async throws {
let url = try URLNormalizer.endpointURL(
baseURL: rawBaseURL,
path: "/wp-json/wp/v2/media/\(mediaID)",
fieldName: "WordPress site URL"
)
let url = try URLNormalizer.endpointURL(base: baseURL, path: "/wp-json/wp/v2/media/\(mediaID)")
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.timeoutInterval = 30
@ -83,11 +77,7 @@ struct WordPressClient: PostingService {
func createPost(text: String, mediaIDs: [String]) async throws -> URL {
// mediaIDs are source_url strings for WordPress
let url = try URLNormalizer.endpointURL(
baseURL: rawBaseURL,
path: "/wp-json/wp/v2/posts",
fieldName: "WordPress site URL"
)
let url = try URLNormalizer.endpointURL(base: baseURL, path: "/wp-json/wp/v2/posts")
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.timeoutInterval = 30
@ -204,14 +194,4 @@ struct WordPressClient: PostingService {
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"
}
}
}

View file

@ -39,4 +39,11 @@ enum URLNormalizer {
}
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
}
}

View file

@ -73,9 +73,6 @@ struct SettingsView: View {
}
struct AddAccountView: View {
private static let pendingMastodonAccountKey = "qstatus.pending-mastodon-account"
private static let pendingMastodonErrorKey = "qstatus.pending-mastodon-account-error"
let accountStore: AccountStore
@Binding var isPresented: Bool
@State private var selectedService: ServiceType = .mastodon
@ -133,8 +130,8 @@ struct AddAccountView: View {
Button(waitingForOAuth ? "Cancel Auth" : "Cancel") {
if waitingForOAuth {
waitingForOAuth = false
UserDefaults.standard.removeObject(forKey: Self.pendingMastodonAccountKey)
UserDefaults.standard.removeObject(forKey: Self.pendingMastodonErrorKey)
UserDefaults.standard.removeObject(forKey: OAuthKeys.pendingMastodonAccount)
UserDefaults.standard.removeObject(forKey: OAuthKeys.pendingMastodonError)
}
isPresented = false
}
@ -271,8 +268,8 @@ struct AddAccountView: View {
account.mastodonClientSecret = clientSecret
let accountData = try JSONEncoder().encode(account)
UserDefaults.standard.removeObject(forKey: Self.pendingMastodonErrorKey)
UserDefaults.standard.set(accountData, forKey: Self.pendingMastodonAccountKey)
UserDefaults.standard.removeObject(forKey: OAuthKeys.pendingMastodonError)
UserDefaults.standard.set(accountData, forKey: OAuthKeys.pendingMastodonAccount)
let authURL = try MastodonClient.authorizeURL(instanceURL: url, clientID: clientID)
NSWorkspace.shared.open(authURL)
@ -282,11 +279,11 @@ struct AddAccountView: View {
// Poll for the account to be added by the URL callback handler
for _ in 0..<120 { // wait up to 2 minutes
try? await Task.sleep(for: .seconds(1))
if let oauthError = UserDefaults.standard.string(forKey: Self.pendingMastodonErrorKey) {
UserDefaults.standard.removeObject(forKey: Self.pendingMastodonErrorKey)
if let oauthError = UserDefaults.standard.string(forKey: OAuthKeys.pendingMastodonError) {
UserDefaults.standard.removeObject(forKey: OAuthKeys.pendingMastodonError)
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
waitingForOAuth = false
isPresented = false