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.
This commit is contained in:
Paweł Orzech 2026-02-27 23:40:51 +01:00
commit c27437b33c
No known key found for this signature in database
26 changed files with 2019 additions and 0 deletions

31
.gitignore vendored Normal file
View file

@ -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/

21
LICENSE Normal file
View file

@ -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.

56
README.md Normal file
View file

@ -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

35
project.yml Normal file
View file

@ -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

View file

@ -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<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: "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)
}
}

View file

@ -0,0 +1,39 @@
import SwiftUI
@Observable
@MainActor
final class AppState {
var inputText: String = ""
var attachedImages: [ImageAttachment] = []
var selectedAccountIDs: Set<UUID> = []
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
}

View file

@ -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)
}
}
}

View file

@ -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)"
}
}

View file

@ -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 }
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>qStatus</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>LSUIElement</key>
<true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.qstatus.app</string>
<key>CFBundleURLSchemes</key>
<array>
<string>qstatus</string>
</array>
</dict>
</array>
</dict>
</plist>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
</dict>
</plist>

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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"
}
}
}

View file

@ -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 = "<p>\(text)</p>"
for imageURL in mediaIDs {
htmlContent += "<img src=\"\(imageURL)\" />"
}
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"
}
}
}

View file

@ -0,0 +1,65 @@
import Foundation
@MainActor
struct PostingManager {
static func post(
text: String,
images: [ImageAttachment],
accounts: [Account],
accountStore: AccountStore
) async -> [(Account, Result<URL, Error>)] {
var results: [(Account, Result<URL, Error>)] = []
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)
}
}
}

View file

@ -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<img src=\"\(imageURL)\" />"
}
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"
}
}
}

View file

@ -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)
}
}
}

View file

@ -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)
}
}

View file

@ -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<UUID>
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)
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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()
}
}