qstatus/qStatus/App/AppDelegate.swift
Paweł Orzech 733334df9b
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
2026-03-07 22:22:08 +01:00

270 lines
8.9 KiB
Swift

import AppKit
import Carbon
import SwiftUI
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
let appState = AppState()
let accountStore = AccountStore()
var floatingPanel: FloatingPanel?
var settingsWindow: NSWindow?
private var hotKeyRef: EventHotKeyRef?
func applicationDidFinishLaunching(_ notification: Notification) {
registerGlobalHotKey()
}
func application(_ application: NSApplication, open urls: [URL]) {
for url in urls {
handleURL(url)
}
}
// MARK: - Floating Panel
func showPanel() {
if floatingPanel == nil {
let panelContent = InputPanelView(
appState: appState,
accountStore: accountStore,
onPost: { [weak self] in
self?.performPost()
},
onDismiss: { [weak self] in
self?.hidePanel()
}
)
floatingPanel = FloatingPanel(contentView: panelContent)
}
floatingPanel?.center()
floatingPanel?.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
appState.isPanelVisible = true
}
func hidePanel() {
floatingPanel?.orderOut(nil)
appState.isPanelVisible = false
}
func togglePanel() {
if appState.isPanelVisible {
hidePanel()
} else {
showPanel()
}
}
// MARK: - Settings Window
func showSettings() {
if settingsWindow == nil {
let settingsView = SettingsView(accountStore: accountStore)
let hostingView = NSHostingView(rootView: settingsView)
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 360, height: 300),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
window.contentView = hostingView
window.title = "qStatus Settings"
window.center()
window.isReleasedWhenClosed = false
settingsWindow = window
}
settingsWindow?.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
}
// MARK: - Posting
func performPost() {
guard appState.canPost else { return }
let selectedAccounts = accountStore.accounts.filter {
appState.selectedAccountIDs.contains($0.id)
}
do {
try InputValidator.validatePost(
text: appState.inputText,
images: appState.attachedImages,
accounts: selectedAccounts
)
} catch {
appState.statusMessage = StatusMessage(text: error.localizedDescription, isError: true)
return
}
appState.isSubmitting = true
appState.statusMessage = nil
Task { @MainActor in
let results = await PostingManager.post(
text: appState.inputText,
images: appState.attachedImages,
accounts: selectedAccounts,
accountStore: accountStore
)
var successes: [String] = []
var failures: [String] = []
for (account, result) in results {
switch result {
case .success:
successes.append(account.displayName)
case .failure(let error):
failures.append("\(account.displayName): \(error.localizedDescription)")
}
}
appState.isSubmitting = false
if failures.isEmpty {
appState.statusMessage = StatusMessage(
text: "Posted to \(successes.joined(separator: ", "))",
isError: false
)
// Clear after short delay
try? await Task.sleep(for: .seconds(1.5))
appState.reset()
hidePanel()
} else {
let msg = failures.joined(separator: "\n")
appState.statusMessage = StatusMessage(text: msg, isError: true)
}
}
}
// MARK: - Global Hotkey
private func registerGlobalHotKey() {
// Ctrl + Option + Cmd + T
let modifiers: UInt32 = UInt32(cmdKey | optionKey | controlKey)
let keyCode: UInt32 = UInt32(kVK_ANSI_T)
var hotKeyID = EventHotKeyID()
hotKeyID.signature = OSType(0x7153_5448) // 'qSTH'
hotKeyID.id = 1
var eventType = EventTypeSpec()
eventType.eventClass = OSType(kEventClassKeyboard)
eventType.eventKind = UInt32(kEventHotKeyPressed)
let selfPtr = Unmanaged.passUnretained(self).toOpaque()
InstallEventHandler(
GetApplicationEventTarget(),
{ (_, event, userData) -> OSStatus in
guard let userData else { return OSStatus(eventNotHandledErr) }
let delegate = Unmanaged<AppDelegate>.fromOpaque(userData).takeUnretainedValue()
DispatchQueue.main.async {
delegate.togglePanel()
}
return noErr
},
1,
&eventType,
selfPtr,
nil
)
RegisterEventHotKey(
keyCode,
modifiers,
hotKeyID,
GetApplicationEventTarget(),
0,
&hotKeyRef
)
}
// MARK: - URL Handling
func handleURL(_ url: URL) {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return }
switch components.host {
case "mastodon-callback":
handleMastodonCallback(components)
case "wordpress-callback":
handleWordPressCallback(components)
default:
break
}
}
private func handleMastodonCallback(_ components: URLComponents) {
guard let code = components.queryItems?.first(where: { $0.name == "code" })?.value else { return }
Task {
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
else { return }
do {
let token = try await MastodonClient.exchangeCode(
instanceURL: account.instanceURL,
clientID: clientID,
clientSecret: clientSecret,
code: code
)
let username = try await MastodonClient.verifyCredentials(
instanceURL: account.instanceURL,
token: token
)
account.username = username
account.displayName = "@\(username)@\(account.instanceURL.replacingOccurrences(of: "https://", with: ""))"
try accountStore.addAccount(account, token: token)
UserDefaults.standard.removeObject(forKey: OAuthKeys.pendingMastodonAccount)
UserDefaults.standard.removeObject(forKey: OAuthKeys.pendingMastodonError)
} catch {
UserDefaults.standard.set(
error.localizedDescription,
forKey: OAuthKeys.pendingMastodonError
)
UserDefaults.standard.removeObject(forKey: OAuthKeys.pendingMastodonAccount)
}
}
}
private func handleWordPressCallback(_ components: URLComponents) {
guard let userLogin = components.queryItems?.first(where: { $0.name == "user_login" })?.value,
let password = components.queryItems?.first(where: { $0.name == "password" })?.value,
let siteURL = components.queryItems?.first(where: { $0.name == "site_url" })?.value
else { return }
Task {
do {
let normalizedSiteURL = try URLNormalizer.normalizeBaseURL(
siteURL,
fieldName: "WordPress site URL"
)
let isValid = try await WordPressClient.verifyCredentials(
siteURL: normalizedSiteURL,
username: userLogin,
password: password
)
guard isValid else { return }
let credentials = "\(userLogin):\(password)"
let account = Account(
serviceType: .wordpress,
displayName: "\(userLogin)@\(normalizedSiteURL.replacingOccurrences(of: "https://", with: ""))",
instanceURL: normalizedSiteURL,
username: userLogin
)
try accountStore.addAccount(account, token: credentials)
} catch {
print("WordPress auth callback error: \(error.localizedDescription)")
}
}
}
}