- 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
270 lines
8.9 KiB
Swift
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)")
|
|
}
|
|
}
|
|
}
|
|
}
|