From c27437b33c33ac66eb37b06379fdd7f43bd2d79b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Fri, 27 Feb 2026 23:40:51 +0100 Subject: [PATCH] Initial implementation of qStatus macOS menubar app 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. --- .gitignore | 31 ++ LICENSE | 21 ++ README.md | 56 ++++ project.yml | 35 +++ qStatus/App/AppDelegate.swift | 231 ++++++++++++++ qStatus/App/AppState.swift | 39 +++ qStatus/App/QStatusApp.swift | 20 ++ qStatus/Models/Account.swift | 56 ++++ qStatus/Models/PostingService.swift | 12 + .../AccentColor.colorset/Contents.json | 20 ++ .../AppIcon.appiconset/Contents.json | 58 ++++ .../Resources/Assets.xcassets/Contents.json | 6 + qStatus/Resources/Info.plist | 37 +++ qStatus/Resources/qStatus.entitlements | 12 + qStatus/Services/AccountStore.swift | 49 +++ qStatus/Services/KeychainManager.swift | 93 ++++++ qStatus/Services/MastodonClient.swift | 191 ++++++++++++ qStatus/Services/MicroblogClient.swift | 128 ++++++++ qStatus/Services/PostingManager.swift | 65 ++++ qStatus/Services/WordPressClient.swift | 166 ++++++++++ qStatus/Utilities/MultipartFormData.swift | 42 +++ qStatus/Views/ImageAttachmentArea.swift | 149 +++++++++ qStatus/Views/InputPanelView.swift | 127 ++++++++ qStatus/Views/MenuBarView.swift | 47 +++ qStatus/Views/SettingsView.swift | 294 ++++++++++++++++++ qStatus/Windows/FloatingPanel.swift | 34 ++ 26 files changed, 2019 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 project.yml create mode 100644 qStatus/App/AppDelegate.swift create mode 100644 qStatus/App/AppState.swift create mode 100644 qStatus/App/QStatusApp.swift create mode 100644 qStatus/Models/Account.swift create mode 100644 qStatus/Models/PostingService.swift create mode 100644 qStatus/Resources/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 qStatus/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 qStatus/Resources/Assets.xcassets/Contents.json create mode 100644 qStatus/Resources/Info.plist create mode 100644 qStatus/Resources/qStatus.entitlements create mode 100644 qStatus/Services/AccountStore.swift create mode 100644 qStatus/Services/KeychainManager.swift create mode 100644 qStatus/Services/MastodonClient.swift create mode 100644 qStatus/Services/MicroblogClient.swift create mode 100644 qStatus/Services/PostingManager.swift create mode 100644 qStatus/Services/WordPressClient.swift create mode 100644 qStatus/Utilities/MultipartFormData.swift create mode 100644 qStatus/Views/ImageAttachmentArea.swift create mode 100644 qStatus/Views/InputPanelView.swift create mode 100644 qStatus/Views/MenuBarView.swift create mode 100644 qStatus/Views/SettingsView.swift create mode 100644 qStatus/Windows/FloatingPanel.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e59f128 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Xcode +*.xcodeproj/ +!*.xcodeproj/project.pbxproj +*.xcodeproj/xcuserdata/ +*.xcworkspace/xcuserdata/ +xcuserdata/ +build/ +DerivedData/ +*.moved-aside +*.pbxuser +*.mode1v3 +*.mode2v3 +*.perspectivev3 +*.hmap +*.ipa +*.dSYM.zip +*.dSYM + +# Swift Package Manager +.build/ +.swiftpm/ +Packages/ + +# macOS +.DS_Store +*.swp +*~ + +# IDE +.idea/ +.vscode/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d4bd55a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 qStatus Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..df789f7 --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# qStatus + +A fast, minimalistic macOS menubar app for posting statuses to Mastodon, WordPress, and Micro.blog. + +## Features + +- **Global hotkey** (Ctrl+Option+Cmd+T) to instantly open the posting window +- **Multiple accounts** -- post to Mastodon, WordPress (self-hosted), and Micro.blog +- **Image attachments** -- drag-and-drop or browse, up to 4 images +- **Multi-post** -- select one or more accounts and post simultaneously +- **Menubar app** -- lives in your menubar, no Dock icon +- **Lightweight** -- native Swift/SwiftUI, no Electron, no web views + +## Requirements + +- macOS 15 Sequoia or later + +## Installation + +### Build from Source + +1. Install [XcodeGen](https://github.com/yonaskolb/XcodeGen): `brew install xcodegen` +2. Clone this repo +3. Run `xcodegen generate` in the project root +4. Open `qStatus.xcodeproj` in Xcode +5. Build and run (Cmd+R) + +## Setting Up Accounts + +### Mastodon +1. Open Settings from the menubar icon +2. Click **+** and select **Mastodon** +3. Enter your instance URL (e.g., `mastodon.social`) +4. You'll be redirected to your instance to authorize qStatus + +### WordPress (Self-Hosted) +1. In your WordPress admin, go to **Users > Profile > Application Passwords** +2. Create a new application password for "qStatus" +3. In qStatus Settings, add a WordPress account with your site URL, username, and the application password + +### Micro.blog +1. Go to [micro.blog/account/apps](https://micro.blog/account/apps) +2. Generate a new app token +3. In qStatus Settings, add a Micro.blog account with the token + +## Usage + +1. Press **Ctrl+Option+Cmd+T** (or click the menubar icon > New Post) +2. Select which account(s) to post to +3. Type your status +4. Optionally drag-and-drop images (up to 4) +5. Press **Cmd+Enter** or click **Post** + +## License + +MIT diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..735c503 --- /dev/null +++ b/project.yml @@ -0,0 +1,35 @@ +name: qStatus +options: + bundleIdPrefix: com.qstatus + deploymentTarget: + macOS: "15.0" + xcodeVersion: "16.0" + generateEmptyDirectories: true + +settings: + base: + SWIFT_VERSION: "6.0" + MACOSX_DEPLOYMENT_TARGET: "15.0" + ENABLE_USER_SCRIPT_SANDBOXING: true + +targets: + qStatus: + type: application + platform: macOS + sources: + - qStatus + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.qstatus.app + PRODUCT_NAME: qStatus + INFOPLIST_FILE: qStatus/Resources/Info.plist + CODE_SIGN_ENTITLEMENTS: qStatus/Resources/qStatus.entitlements + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + ENABLE_HARDENED_RUNTIME: true + COMBINE_HIDPI_IMAGES: true + entitlements: + path: qStatus/Resources/qStatus.entitlements + properties: + com.apple.security.app-sandbox: true + com.apple.security.network.client: true + com.apple.security.files.user-selected.read-only: true diff --git a/qStatus/App/AppDelegate.swift b/qStatus/App/AppDelegate.swift new file mode 100644 index 0000000..71c130f --- /dev/null +++ b/qStatus/App/AppDelegate.swift @@ -0,0 +1,231 @@ +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() + } + + // 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 } + appState.isSubmitting = true + appState.statusMessage = nil + + let selectedAccounts = accountStore.accounts.filter { + appState.selectedAccountIDs.contains($0.id) + } + + 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.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: "qstatus.pending-mastodon-account"), + 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: "qstatus.pending-mastodon-account") + } catch { + print("Mastodon auth error: \(error)") + } + } + } + + 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 } + + let credentials = "\(userLogin):\(password)" + let account = Account( + serviceType: .wordpress, + displayName: "\(userLogin)@\(siteURL.replacingOccurrences(of: "https://", with: ""))", + instanceURL: siteURL, + username: userLogin + ) + try? accountStore.addAccount(account, token: credentials) + } +} diff --git a/qStatus/App/AppState.swift b/qStatus/App/AppState.swift new file mode 100644 index 0000000..debb990 --- /dev/null +++ b/qStatus/App/AppState.swift @@ -0,0 +1,39 @@ +import SwiftUI + +@Observable +@MainActor +final class AppState { + var inputText: String = "" + var attachedImages: [ImageAttachment] = [] + var selectedAccountIDs: Set = [] + var isSubmitting: Bool = false + var isPanelVisible: Bool = false + var statusMessage: StatusMessage? + + static let maxImages = 4 + + var canPost: Bool { + !inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !selectedAccountIDs.isEmpty + && !isSubmitting + } + + func reset() { + inputText = "" + attachedImages = [] + statusMessage = nil + } +} + +struct ImageAttachment: Identifiable { + let id = UUID() + let image: NSImage + let data: Data + let filename: String +} + +struct StatusMessage: Identifiable { + let id = UUID() + let text: String + let isError: Bool +} diff --git a/qStatus/App/QStatusApp.swift b/qStatus/App/QStatusApp.swift new file mode 100644 index 0000000..ae9d5cb --- /dev/null +++ b/qStatus/App/QStatusApp.swift @@ -0,0 +1,20 @@ +import SwiftUI + +@main +struct QStatusApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + + var body: some Scene { + MenuBarExtra("qStatus", systemImage: "bubble.left.and.text.bubble.right") { + MenuBarView( + accountStore: appDelegate.accountStore, + onOpenPanel: { appDelegate.showPanel() }, + onOpenSettings: { appDelegate.showSettings() } + ) + } + + Settings { + SettingsView(accountStore: appDelegate.accountStore) + } + } +} diff --git a/qStatus/Models/Account.swift b/qStatus/Models/Account.swift new file mode 100644 index 0000000..79bf68d --- /dev/null +++ b/qStatus/Models/Account.swift @@ -0,0 +1,56 @@ +import Foundation + +enum ServiceType: String, Codable, CaseIterable, Identifiable { + case mastodon + case wordpress + case microblog + + var id: String { rawValue } + + var displayName: String { + switch self { + case .mastodon: "Mastodon" + case .wordpress: "WordPress" + case .microblog: "Micro.blog" + } + } + + var iconName: String { + switch self { + case .mastodon: "bubble.left.and.text.bubble.right" + case .wordpress: "w.square" + case .microblog: "pencil.and.outline" + } + } + + var maxImages: Int { 4 } +} + +struct Account: Identifiable, Codable, Hashable { + let id: UUID + var serviceType: ServiceType + var displayName: String + var instanceURL: String + var username: String + + // Mastodon-specific + var mastodonClientID: String? + var mastodonClientSecret: String? + + init( + serviceType: ServiceType, + displayName: String, + instanceURL: String, + username: String + ) { + self.id = UUID() + self.serviceType = serviceType + self.displayName = displayName + self.instanceURL = instanceURL + self.username = username + } + + var keychainKey: String { + "account-\(id.uuidString)" + } +} diff --git a/qStatus/Models/PostingService.swift b/qStatus/Models/PostingService.swift new file mode 100644 index 0000000..ecff779 --- /dev/null +++ b/qStatus/Models/PostingService.swift @@ -0,0 +1,12 @@ +import AppKit + +protocol PostingService: Sendable { + var account: Account { get } + func uploadMedia(imageData: Data, filename: String, altText: String?) async throws -> String + func createPost(text: String, mediaIDs: [String]) async throws -> URL +} + +struct PostingError: LocalizedError { + let message: String + var errorDescription: String? { message } +} diff --git a/qStatus/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/qStatus/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..5724479 --- /dev/null +++ b/qStatus/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.898", + "green" : "0.459", + "red" : "0.294" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/qStatus/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/qStatus/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..3f00db4 --- /dev/null +++ b/qStatus/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,58 @@ +{ + "images" : [ + { + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/qStatus/Resources/Assets.xcassets/Contents.json b/qStatus/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/qStatus/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/qStatus/Resources/Info.plist b/qStatus/Resources/Info.plist new file mode 100644 index 0000000..c9d27b1 --- /dev/null +++ b/qStatus/Resources/Info.plist @@ -0,0 +1,37 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + qStatus + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + LSUIElement + + CFBundleURLTypes + + + CFBundleURLName + com.qstatus.app + CFBundleURLSchemes + + qstatus + + + + + diff --git a/qStatus/Resources/qStatus.entitlements b/qStatus/Resources/qStatus.entitlements new file mode 100644 index 0000000..84a9a28 --- /dev/null +++ b/qStatus/Resources/qStatus.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + com.apple.security.files.user-selected.read-only + + + diff --git a/qStatus/Services/AccountStore.swift b/qStatus/Services/AccountStore.swift new file mode 100644 index 0000000..c7b0d7b --- /dev/null +++ b/qStatus/Services/AccountStore.swift @@ -0,0 +1,49 @@ +import Foundation + +@Observable +@MainActor +final class AccountStore { + private(set) var accounts: [Account] = [] + + private static let storageKey = "qstatus.accounts" + + init() { + loadAccounts() + } + + func addAccount(_ account: Account, token: String) throws { + try KeychainManager.saveToken(token, forAccount: account.keychainKey) + accounts.append(account) + saveAccounts() + } + + func removeAccount(_ account: Account) { + try? KeychainManager.delete(account: account.keychainKey) + accounts.removeAll { $0.id == account.id } + saveAccounts() + } + + func token(for account: Account) -> String? { + try? KeychainManager.loadToken(forAccount: account.keychainKey) + } + + func updateAccount(_ account: Account) { + if let index = accounts.firstIndex(where: { $0.id == account.id }) { + accounts[index] = account + saveAccounts() + } + } + + private func saveAccounts() { + if let data = try? JSONEncoder().encode(accounts) { + UserDefaults.standard.set(data, forKey: Self.storageKey) + } + } + + private func loadAccounts() { + guard let data = UserDefaults.standard.data(forKey: Self.storageKey), + let decoded = try? JSONDecoder().decode([Account].self, from: data) + else { return } + accounts = decoded + } +} diff --git a/qStatus/Services/KeychainManager.swift b/qStatus/Services/KeychainManager.swift new file mode 100644 index 0000000..bf599b9 --- /dev/null +++ b/qStatus/Services/KeychainManager.swift @@ -0,0 +1,93 @@ +import Foundation +import Security + +enum KeychainError: LocalizedError { + case itemNotFound + case unexpectedStatus(OSStatus) + case invalidData + + var errorDescription: String? { + switch self { + case .itemNotFound: "Credential not found in Keychain" + case .unexpectedStatus(let status): "Keychain error: \(status)" + case .invalidData: "Invalid data in Keychain" + } + } +} + +struct KeychainManager { + private static let service = "com.qstatus.app" + + static func save(account: String, data: Data) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, + ] + + let status = SecItemAdd(query as CFDictionary, nil) + + if status == errSecDuplicateItem { + let updateQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + let attributes: [String: Any] = [kSecValueData as String: data] + let updateStatus = SecItemUpdate(updateQuery as CFDictionary, attributes as CFDictionary) + guard updateStatus == errSecSuccess else { + throw KeychainError.unexpectedStatus(updateStatus) + } + } else if status != errSecSuccess { + throw KeychainError.unexpectedStatus(status) + } + } + + static func load(account: String) throws -> Data { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess else { + if status == errSecItemNotFound { throw KeychainError.itemNotFound } + throw KeychainError.unexpectedStatus(status) + } + + guard let data = result as? Data else { throw KeychainError.invalidData } + return data + } + + static func delete(account: String) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeychainError.unexpectedStatus(status) + } + } + + static func saveToken(_ token: String, forAccount account: String) throws { + guard let data = token.data(using: .utf8) else { return } + try save(account: account, data: data) + } + + static func loadToken(forAccount account: String) throws -> String { + let data = try load(account: account) + guard let token = String(data: data, encoding: .utf8) else { + throw KeychainError.invalidData + } + return token + } +} diff --git a/qStatus/Services/MastodonClient.swift b/qStatus/Services/MastodonClient.swift new file mode 100644 index 0000000..3a534e1 --- /dev/null +++ b/qStatus/Services/MastodonClient.swift @@ -0,0 +1,191 @@ +import Foundation + +struct MastodonClient: PostingService { + let account: Account + private let token: String + private let baseURL: String + + init(account: Account, token: String) { + self.account = account + self.token = token + self.baseURL = account.instanceURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + } + + func uploadMedia(imageData: Data, filename: String, altText: String?) async throws -> String { + let url = URL(string: "\(baseURL)/api/v2/media")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + + var form = MultipartFormData() + form.addFile(name: "file", filename: filename, mimeType: mimeTypeFor(filename), data: imageData) + if let altText, !altText.isEmpty { + form.addField(name: "description", value: altText) + } + + request.setValue(form.contentType, forHTTPHeaderField: "Content-Type") + request.httpBody = form.finalized + + let (data, response) = try await URLSession.shared.data(for: request) + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 + + guard (200...202).contains(statusCode) else { + let body = String(data: data, encoding: .utf8) ?? "Unknown error" + throw PostingError(message: "Mastodon media upload failed (\(statusCode)): \(body)") + } + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let mediaID = json["id"] as? String + else { + throw PostingError(message: "Invalid response from Mastodon media upload") + } + + return mediaID + } + + func createPost(text: String, mediaIDs: [String]) async throws -> URL { + let url = URL(string: "\(baseURL)/api/v1/statuses")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(UUID().uuidString, forHTTPHeaderField: "Idempotency-Key") + + var body: [String: Any] = ["status": text, "visibility": "public"] + if !mediaIDs.isEmpty { + body["media_ids"] = mediaIDs + } + + 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 statusCode == 200 else { + let errorBody = String(data: data, encoding: .utf8) ?? "Unknown error" + throw PostingError(message: "Mastodon post failed (\(statusCode)): \(errorBody)") + } + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let urlString = json["url"] as? String, + let postURL = URL(string: urlString) + else { + throw PostingError(message: "Invalid response from Mastodon") + } + + return postURL + } + + // MARK: - OAuth Registration + + static func registerApp(instanceURL: String) async throws -> (clientID: String, clientSecret: String) { + let base = instanceURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let url = URL(string: "\(base)/api/v1/apps")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let body: [String: String] = [ + "client_name": "qStatus", + "redirect_uris": "qstatus://mastodon-callback", + "scopes": "write:statuses write:media", + "website": "https://github.com/nicedishy/qstatus", + ] + 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 statusCode == 200 else { + throw PostingError(message: "Failed to register app on \(instanceURL)") + } + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let clientID = json["client_id"] as? String, + let clientSecret = json["client_secret"] as? String + else { + throw PostingError(message: "Invalid registration response") + } + + return (clientID, clientSecret) + } + + static func authorizeURL(instanceURL: String, clientID: String) -> URL { + let base = instanceURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + var components = URLComponents(string: "\(base)/oauth/authorize")! + components.queryItems = [ + URLQueryItem(name: "client_id", value: clientID), + URLQueryItem(name: "scope", value: "write:statuses write:media"), + URLQueryItem(name: "redirect_uri", value: "qstatus://mastodon-callback"), + URLQueryItem(name: "response_type", value: "code"), + ] + return components.url! + } + + static func exchangeCode( + instanceURL: String, clientID: String, clientSecret: String, code: String + ) async throws -> String { + let base = instanceURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let url = URL(string: "\(base)/oauth/token")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + + let params = [ + "grant_type=authorization_code", + "client_id=\(clientID)", + "client_secret=\(clientSecret)", + "redirect_uri=qstatus://mastodon-callback", + "code=\(code)", + ].joined(separator: "&") + + request.httpBody = params.data(using: .utf8) + + let (data, response) = try await URLSession.shared.data(for: request) + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 + + guard statusCode == 200 else { + throw PostingError(message: "Token exchange failed (\(statusCode))") + } + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let token = json["access_token"] as? String + else { + throw PostingError(message: "Invalid token response") + } + + return token + } + + static func verifyCredentials(instanceURL: String, token: String) async throws -> String { + let base = instanceURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let url = URL(string: "\(base)/api/v1/accounts/verify_credentials")! + var request = URLRequest(url: url) + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + + let (data, response) = try await URLSession.shared.data(for: request) + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 + + guard statusCode == 200 else { + throw PostingError(message: "Credential verification failed") + } + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let username = json["username"] as? String + else { + throw PostingError(message: "Invalid credentials response") + } + + 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" + } + } +} diff --git a/qStatus/Services/MicroblogClient.swift b/qStatus/Services/MicroblogClient.swift new file mode 100644 index 0000000..630630a --- /dev/null +++ b/qStatus/Services/MicroblogClient.swift @@ -0,0 +1,128 @@ +import Foundation + +struct MicroblogClient: PostingService { + let account: Account + private let token: String + + private static let micropubURL = "https://micro.blog/micropub" + private static let mediaURL = "https://micro.blog/micropub/media" + + init(account: Account, token: String) { + self.account = account + self.token = token + } + + func uploadMedia(imageData: Data, filename: String, altText: String?) async throws -> String { + let url = URL(string: Self.mediaURL)! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + + var form = MultipartFormData() + form.addFile(name: "file", filename: filename, mimeType: mimeTypeFor(filename), data: imageData) + + request.setValue(form.contentType, forHTTPHeaderField: "Content-Type") + request.httpBody = form.finalized + + let (_, response) = try await URLSession.shared.data(for: request) + let httpResponse = response as? HTTPURLResponse + let statusCode = httpResponse?.statusCode ?? 0 + + guard (200...202).contains(statusCode) else { + throw PostingError(message: "Micro.blog media upload failed (\(statusCode))") + } + + guard let location = httpResponse?.value(forHTTPHeaderField: "Location") else { + throw PostingError(message: "No Location header in Micro.blog media response") + } + + return location + } + + func createPost(text: String, mediaIDs: [String]) async throws -> URL { + // mediaIDs are image URLs for Micro.blog + let url = URL(string: Self.micropubURL)! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + + if mediaIDs.isEmpty { + // Simple form-encoded post + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + let params = "h=entry&content=\(text.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")" + request.httpBody = params.data(using: .utf8) + } else { + // JSON with HTML content including images + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + var htmlContent = "

\(text)

" + for imageURL in mediaIDs { + htmlContent += "" + } + + let body: [String: Any] = [ + "type": ["h-entry"], + "properties": [ + "content": [["html": htmlContent]], + ], + ] + request.httpBody = try JSONSerialization.data(withJSONObject: body) + } + + let (data, response) = try await URLSession.shared.data(for: request) + let httpResponse = response as? HTTPURLResponse + let statusCode = httpResponse?.statusCode ?? 0 + + guard (200...202).contains(statusCode) else { + let body = String(data: data, encoding: .utf8) ?? "" + throw PostingError(message: "Micro.blog post failed (\(statusCode)): \(body)") + } + + if let location = httpResponse?.value(forHTTPHeaderField: "Location"), + let postURL = URL(string: location) { + return postURL + } + + // Try to parse URL from response body + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let urlString = json["url"] as? String, + let postURL = URL(string: urlString) { + return postURL + } + + return URL(string: "https://micro.blog")! + } + + static func verifyToken(_ token: String) async throws -> String { + let url = URL(string: "https://micro.blog/micropub?q=config")! + var request = URLRequest(url: url) + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + + let (data, response) = try await URLSession.shared.data(for: request) + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 + + guard statusCode == 200 else { + throw PostingError(message: "Invalid Micro.blog token") + } + + // Try to get destination info + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let destinations = json["destination"] as? [[String: Any]], + let first = destinations.first, + let name = first["name"] as? String { + return name + } + + 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" + } + } +} diff --git a/qStatus/Services/PostingManager.swift b/qStatus/Services/PostingManager.swift new file mode 100644 index 0000000..4cf59ef --- /dev/null +++ b/qStatus/Services/PostingManager.swift @@ -0,0 +1,65 @@ +import Foundation + +@MainActor +struct PostingManager { + + static func post( + text: String, + images: [ImageAttachment], + accounts: [Account], + accountStore: AccountStore + ) async -> [(Account, Result)] { + var results: [(Account, Result)] = [] + + for account in accounts { + guard let token = accountStore.token(for: account) else { + results.append((account, .failure(PostingError(message: "No token for \(account.displayName)")))) + continue + } + + do { + let url = try await postToAccount(account: account, token: token, text: text, images: images) + results.append((account, .success(url))) + } catch { + results.append((account, .failure(error))) + } + } + + return results + } + + private static func postToAccount( + account: Account, token: String, text: String, images: [ImageAttachment] + ) async throws -> URL { + let imageData = images.map { (data: $0.data, filename: $0.filename) } + + switch account.serviceType { + case .mastodon: + let client = 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) + mediaIDs.append(id) + } + return try await client.createPost(text: text, mediaIDs: mediaIDs) + + case .wordpress: + let client = 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) + mediaIDs.append(id) + } + return try await client.createPost(text: text, mediaIDs: mediaIDs) + + case .microblog: + let client = MicroblogClient(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) + mediaIDs.append(id) + } + return try await client.createPost(text: text, mediaIDs: mediaIDs) + } + } +} diff --git a/qStatus/Services/WordPressClient.swift b/qStatus/Services/WordPressClient.swift new file mode 100644 index 0000000..9ac405e --- /dev/null +++ b/qStatus/Services/WordPressClient.swift @@ -0,0 +1,166 @@ +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" + } + + 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" + } + } +} diff --git a/qStatus/Utilities/MultipartFormData.swift b/qStatus/Utilities/MultipartFormData.swift new file mode 100644 index 0000000..b7e0fe9 --- /dev/null +++ b/qStatus/Utilities/MultipartFormData.swift @@ -0,0 +1,42 @@ +import Foundation + +struct MultipartFormData { + let boundary: String + private var body = Data() + + init(boundary: String = UUID().uuidString) { + self.boundary = boundary + } + + var contentType: String { + "multipart/form-data; boundary=\(boundary)" + } + + mutating func addField(name: String, value: String) { + body.append("--\(boundary)\r\n") + body.append("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n") + body.append("\(value)\r\n") + } + + mutating func addFile(name: String, filename: String, mimeType: String, data: Data) { + body.append("--\(boundary)\r\n") + body.append("Content-Disposition: form-data; name=\"\(name)\"; filename=\"\(filename)\"\r\n") + body.append("Content-Type: \(mimeType)\r\n\r\n") + body.append(data) + body.append("\r\n") + } + + var finalized: Data { + var result = body + result.append("--\(boundary)--\r\n") + return result + } +} + +extension Data { + mutating func append(_ string: String) { + if let data = string.data(using: .utf8) { + append(data) + } + } +} diff --git a/qStatus/Views/ImageAttachmentArea.swift b/qStatus/Views/ImageAttachmentArea.swift new file mode 100644 index 0000000..d8f3ea8 --- /dev/null +++ b/qStatus/Views/ImageAttachmentArea.swift @@ -0,0 +1,149 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct ImageAttachmentArea: View { + @Binding var attachedImages: [ImageAttachment] + @State private var showFilePicker = false + @State private var isDropTargeted = false + + var body: some View { + VStack(spacing: 8) { + // Thumbnail grid + if !attachedImages.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(attachedImages) { attachment in + ZStack(alignment: .topTrailing) { + Image(nsImage: attachment.image) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 72, height: 72) + .clipShape(RoundedRectangle(cornerRadius: 6)) + + Button { + attachedImages.removeAll { $0.id == attachment.id } + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 16)) + .foregroundStyle(.white) + .shadow(color: .black.opacity(0.5), radius: 2) + } + .buttonStyle(.plain) + .offset(x: 4, y: -4) + } + } + } + } + } + + // Drop zone + if attachedImages.count < AppState.maxImages { + ZStack { + RoundedRectangle(cornerRadius: 8) + .strokeBorder( + isDropTargeted ? Color.accentColor : Color.secondary.opacity(0.3), + style: StrokeStyle(lineWidth: 1.5, dash: [6]) + ) + .frame(height: 44) + .background( + (isDropTargeted ? Color.accentColor.opacity(0.08) : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: 8)) + ) + + HStack(spacing: 4) { + Image(systemName: "photo.on.rectangle.angled") + .font(.caption) + Text("Drop images or") + .font(.caption) + Button("browse") { showFilePicker = true } + .font(.caption) + .buttonStyle(.link) + Text("(\(attachedImages.count)/\(AppState.maxImages))") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .onDrop(of: [.image, .fileURL], isTargeted: $isDropTargeted) { providers in + handleDrop(providers) + } + } + } + .fileImporter( + isPresented: $showFilePicker, + allowedContentTypes: [.png, .jpeg, .gif, .webP], + allowsMultipleSelection: true + ) { result in + handleFileImport(result) + } + } + + private func handleDrop(_ providers: [NSItemProvider]) -> Bool { + let remaining = AppState.maxImages - attachedImages.count + for provider in providers.prefix(remaining) { + if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { + provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier) { data, _ in + guard let data = data as? Data, + let url = URL(dataRepresentation: data, relativeTo: nil), + let image = NSImage(contentsOf: url), + let imageData = imageDataFrom(url: url) + else { return } + DispatchQueue.main.async { + if attachedImages.count < AppState.maxImages { + attachedImages.append( + ImageAttachment( + image: image, + data: imageData, + filename: url.lastPathComponent + ) + ) + } + } + } + } else if provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) { + provider.loadItem(forTypeIdentifier: UTType.image.identifier) { data, _ in + guard let data = data as? Data, + let image = NSImage(data: data) + else { return } + DispatchQueue.main.async { + if attachedImages.count < AppState.maxImages { + attachedImages.append( + ImageAttachment( + image: image, + data: data, + filename: "image.jpg" + ) + ) + } + } + } + } + } + return true + } + + private func handleFileImport(_ result: Result<[URL], Error>) { + guard case .success(let urls) = result else { return } + let remaining = AppState.maxImages - attachedImages.count + + for url in urls.prefix(remaining) { + guard url.startAccessingSecurityScopedResource() else { continue } + defer { url.stopAccessingSecurityScopedResource() } + + guard let image = NSImage(contentsOf: url), + let data = imageDataFrom(url: url) + else { continue } + + attachedImages.append( + ImageAttachment( + image: image, + data: data, + filename: url.lastPathComponent + ) + ) + } + } + + private func imageDataFrom(url: URL) -> Data? { + try? Data(contentsOf: url) + } +} diff --git a/qStatus/Views/InputPanelView.swift b/qStatus/Views/InputPanelView.swift new file mode 100644 index 0000000..f002298 --- /dev/null +++ b/qStatus/Views/InputPanelView.swift @@ -0,0 +1,127 @@ +import SwiftUI + +struct InputPanelView: View { + @Bindable var appState: AppState + let accountStore: AccountStore + let onPost: () -> Void + let onDismiss: () -> Void + + @FocusState private var isTextFieldFocused: Bool + + var body: some View { + VStack(spacing: 12) { + // Account selector + AccountSelectorView( + accounts: accountStore.accounts, + selectedIDs: $appState.selectedAccountIDs + ) + + // Text input + TextEditor(text: $appState.inputText) + .font(.system(size: 14)) + .frame(minHeight: 80, maxHeight: 160) + .focused($isTextFieldFocused) + .scrollContentBackground(.hidden) + .padding(8) + .background(Color(nsColor: .controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + // Image attachments + ImageAttachmentArea(attachedImages: Bindable(appState).attachedImages) + + // Status message + if let status = appState.statusMessage { + Text(status.text) + .font(.caption) + .foregroundStyle(status.isError ? .red : .green) + .frame(maxWidth: .infinity, alignment: .leading) + } + + // Bottom bar + HStack { + Text("\(appState.inputText.count)") + .font(.caption) + .foregroundStyle(.secondary) + .monospacedDigit() + + Spacer() + + if appState.isSubmitting { + ProgressView() + .controlSize(.small) + } + + Button("Post") { + onPost() + } + .buttonStyle(.borderedProminent) + .disabled(!appState.canPost) + .keyboardShortcut(.return, modifiers: .command) + } + } + .padding() + .frame(width: 420) + .onAppear { + isTextFieldFocused = true + } + } +} + +struct AccountSelectorView: View { + let accounts: [Account] + @Binding var selectedIDs: Set + + var body: some View { + if accounts.isEmpty { + Text("No accounts configured. Open Settings to add one.") + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 4) + } else { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(accounts) { account in + AccountChip( + account: account, + isSelected: selectedIDs.contains(account.id) + ) { + if selectedIDs.contains(account.id) { + selectedIDs.remove(account.id) + } else { + selectedIDs.insert(account.id) + } + } + } + } + } + } + } +} + +struct AccountChip: View { + let account: Account + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 4) { + Image(systemName: account.serviceType.iconName) + .font(.caption2) + Text(account.displayName) + .font(.caption) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(isSelected ? Color.accentColor.opacity(0.2) : Color.secondary.opacity(0.1)) + .foregroundStyle(isSelected ? Color.accentColor : .secondary) + .clipShape(Capsule()) + .overlay( + Capsule() + .strokeBorder(isSelected ? Color.accentColor : Color.clear, lineWidth: 1) + ) + } + .buttonStyle(.plain) + } +} diff --git a/qStatus/Views/MenuBarView.swift b/qStatus/Views/MenuBarView.swift new file mode 100644 index 0000000..4cfe510 --- /dev/null +++ b/qStatus/Views/MenuBarView.swift @@ -0,0 +1,47 @@ +import SwiftUI + +struct MenuBarView: View { + let accountStore: AccountStore + let onOpenPanel: () -> Void + let onOpenSettings: () -> Void + + var body: some View { + VStack(spacing: 0) { + Button("New Post...") { + onOpenPanel() + } + .keyboardShortcut("n") + + Divider() + + if !accountStore.accounts.isEmpty { + ForEach(accountStore.accounts) { account in + HStack { + Image(systemName: account.serviceType.iconName) + .frame(width: 16) + Text(account.displayName) + .lineLimit(1) + } + .padding(.horizontal, 8) + .padding(.vertical, 2) + } + + Divider() + } + + Button("Settings...") { + onOpenSettings() + } + .keyboardShortcut(",") + + Divider() + + Button("Quit qStatus") { + NSApplication.shared.terminate(nil) + } + .keyboardShortcut("q") + } + .frame(width: 200) + .padding(.vertical, 4) + } +} diff --git a/qStatus/Views/SettingsView.swift b/qStatus/Views/SettingsView.swift new file mode 100644 index 0000000..2a500c6 --- /dev/null +++ b/qStatus/Views/SettingsView.swift @@ -0,0 +1,294 @@ +import SwiftUI + +struct SettingsView: View { + let accountStore: AccountStore + @State private var showAddAccount = false + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + Text("Accounts") + .font(.headline) + Spacer() + Button { + showAddAccount = true + } label: { + Image(systemName: "plus") + } + } + .padding() + + Divider() + + // Account list + if accountStore.accounts.isEmpty { + VStack(spacing: 8) { + Image(systemName: "person.crop.circle.badge.plus") + .font(.largeTitle) + .foregroundStyle(.secondary) + Text("No accounts yet") + .font(.subheadline) + .foregroundStyle(.secondary) + Text("Add a Mastodon, WordPress, or Micro.blog account to get started.") + .font(.caption) + .foregroundStyle(.tertiary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } else { + List { + ForEach(accountStore.accounts) { account in + HStack { + Image(systemName: account.serviceType.iconName) + .frame(width: 20) + VStack(alignment: .leading, spacing: 2) { + Text(account.displayName) + .font(.body) + Text(account.serviceType.displayName) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Button(role: .destructive) { + accountStore.removeAccount(account) + } label: { + Image(systemName: "trash") + .font(.caption) + } + .buttonStyle(.plain) + .foregroundStyle(.red.opacity(0.7)) + } + .padding(.vertical, 2) + } + } + } + } + .frame(width: 360, height: 300) + .sheet(isPresented: $showAddAccount) { + AddAccountView(accountStore: accountStore, isPresented: $showAddAccount) + } + } +} + +struct AddAccountView: View { + let accountStore: AccountStore + @Binding var isPresented: Bool + @State private var selectedService: ServiceType = .mastodon + @State private var instanceURL = "" + @State private var token = "" + @State private var username = "" + @State private var isLoading = false + @State private var errorMessage: String? + + var body: some View { + VStack(spacing: 16) { + Text("Add Account") + .font(.headline) + + // Service picker + Picker("Service", selection: $selectedService) { + ForEach(ServiceType.allCases) { service in + Text(service.displayName).tag(service) + } + } + .pickerStyle(.segmented) + + // Service-specific fields + switch selectedService { + case .mastodon: + mastodonFields + case .wordpress: + wordpressFields + case .microblog: + microblogFields + } + + if let errorMessage { + Text(errorMessage) + .font(.caption) + .foregroundStyle(.red) + } + + HStack { + Button("Cancel") { + isPresented = false + } + .keyboardShortcut(.cancelAction) + + Spacer() + + if isLoading { + ProgressView() + .controlSize(.small) + } + + Button("Add") { + Task { await addAccount() } + } + .buttonStyle(.borderedProminent) + .disabled(!canAdd || isLoading) + .keyboardShortcut(.defaultAction) + } + } + .padding() + .frame(width: 360) + } + + private var canAdd: Bool { + switch selectedService { + case .mastodon: + !instanceURL.trimmingCharacters(in: .whitespaces).isEmpty + case .wordpress: + !instanceURL.trimmingCharacters(in: .whitespaces).isEmpty + && !username.trimmingCharacters(in: .whitespaces).isEmpty + && !token.trimmingCharacters(in: .whitespaces).isEmpty + case .microblog: + !token.trimmingCharacters(in: .whitespaces).isEmpty + } + } + + @ViewBuilder + private var mastodonFields: some View { + VStack(alignment: .leading, spacing: 4) { + Text("Instance URL") + .font(.caption) + .foregroundStyle(.secondary) + TextField("https://mastodon.social", text: $instanceURL) + .textFieldStyle(.roundedBorder) + } + Text("You'll be redirected to your instance to authorize qStatus.") + .font(.caption) + .foregroundStyle(.secondary) + } + + @ViewBuilder + private var wordpressFields: some View { + VStack(alignment: .leading, spacing: 4) { + Text("Site URL") + .font(.caption) + .foregroundStyle(.secondary) + TextField("https://your-site.com", text: $instanceURL) + .textFieldStyle(.roundedBorder) + } + VStack(alignment: .leading, spacing: 4) { + Text("Username") + .font(.caption) + .foregroundStyle(.secondary) + TextField("admin", text: $username) + .textFieldStyle(.roundedBorder) + } + VStack(alignment: .leading, spacing: 4) { + Text("Application Password") + .font(.caption) + .foregroundStyle(.secondary) + SecureField("xxxx xxxx xxxx xxxx", text: $token) + .textFieldStyle(.roundedBorder) + } + Text("Generate an Application Password in WordPress: Users > Profile > Application Passwords") + .font(.caption) + .foregroundStyle(.secondary) + } + + @ViewBuilder + private var microblogFields: some View { + VStack(alignment: .leading, spacing: 4) { + Text("App Token") + .font(.caption) + .foregroundStyle(.secondary) + SecureField("Paste your token", text: $token) + .textFieldStyle(.roundedBorder) + } + Text("Get your token at micro.blog/account/apps") + .font(.caption) + .foregroundStyle(.secondary) + } + + private func addAccount() async { + isLoading = true + errorMessage = nil + + do { + switch selectedService { + case .mastodon: + try await addMastodonAccount() + case .wordpress: + try await addWordPressAccount() + case .microblog: + try await addMicroblogAccount() + } + isPresented = false + } catch { + errorMessage = error.localizedDescription + } + + isLoading = false + } + + private func addMastodonAccount() async throws { + var url = instanceURL.trimmingCharacters(in: .whitespaces) + if !url.hasPrefix("https://") && !url.hasPrefix("http://") { + url = "https://\(url)" + } + + let (clientID, clientSecret) = try await MastodonClient.registerApp(instanceURL: url) + + // Store client credentials temporarily + var account = Account( + serviceType: .mastodon, + displayName: url.replacingOccurrences(of: "https://", with: ""), + instanceURL: url, + username: "" + ) + account.mastodonClientID = clientID + account.mastodonClientSecret = clientSecret + + // Save account without token - will complete after OAuth + let accountData = try JSONEncoder().encode(account) + UserDefaults.standard.set(accountData, forKey: "qstatus.pending-mastodon-account") + + // Open browser for authorization + let authURL = MastodonClient.authorizeURL(instanceURL: url, clientID: clientID) + NSWorkspace.shared.open(authURL) + } + + private func addWordPressAccount() async throws { + var url = instanceURL.trimmingCharacters(in: .whitespaces) + if !url.hasPrefix("https://") && !url.hasPrefix("http://") { + url = "https://\(url)" + } + + let user = username.trimmingCharacters(in: .whitespaces) + let pass = token.trimmingCharacters(in: .whitespaces) + + let valid = try await WordPressClient.verifyCredentials( + siteURL: url, username: user, password: pass + ) + guard valid else { + throw PostingError(message: "Invalid credentials. Check username and application password.") + } + + let credentials = "\(user):\(pass)" + let account = Account( + serviceType: .wordpress, + displayName: "\(user)@\(url.replacingOccurrences(of: "https://", with: ""))", + instanceURL: url, + username: user + ) + try accountStore.addAccount(account, token: credentials) + } + + private func addMicroblogAccount() async throws { + let appToken = token.trimmingCharacters(in: .whitespaces) + let blogName = try await MicroblogClient.verifyToken(appToken) + + let account = Account( + serviceType: .microblog, + displayName: blogName, + instanceURL: "https://micro.blog", + username: blogName + ) + try accountStore.addAccount(account, token: appToken) + } +} diff --git a/qStatus/Windows/FloatingPanel.swift b/qStatus/Windows/FloatingPanel.swift new file mode 100644 index 0000000..e35a1df --- /dev/null +++ b/qStatus/Windows/FloatingPanel.swift @@ -0,0 +1,34 @@ +import AppKit +import SwiftUI + +final class FloatingPanel: NSPanel { + + init(contentView: some View) { + super.init( + contentRect: NSRect(x: 0, y: 0, width: 440, height: 380), + styleMask: [.titled, .closable, .fullSizeContentView, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + isFloatingPanel = true + level = .floating + titleVisibility = .hidden + titlebarAppearsTransparent = true + isMovableByWindowBackground = true + isReleasedWhenClosed = false + animationBehavior = .utilityWindow + hidesOnDeactivate = false + collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + backgroundColor = .windowBackgroundColor + + self.contentView = NSHostingView(rootView: contentView) + center() + } + + override var canBecomeKey: Bool { true } + + override func cancelOperation(_ sender: Any?) { + close() + } +}