Fix OAuth callback handling, improve error messages, polish README

- Use application(_:open:) instead of NSAppleEventManager for URL
  scheme handling (SwiftUI lifecycle compatibility)
- Add waiting UI during Mastodon OAuth flow with cancel support
- Improve error messages for all three services
- Rewrite README with full setup instructions and project overview
- Clean up .gitignore
This commit is contained in:
Paweł Orzech 2026-02-27 23:57:18 +01:00
parent c27437b33c
commit dfe4485fe9
No known key found for this signature in database
7 changed files with 181 additions and 56 deletions

18
.gitignore vendored
View file

@ -1,8 +1,6 @@
# Xcode
*.xcodeproj/
!*.xcodeproj/project.pbxproj
*.xcodeproj/xcuserdata/
*.xcworkspace/xcuserdata/
*.xcworkspace/
xcuserdata/
build/
DerivedData/
@ -15,17 +13,27 @@ DerivedData/
*.ipa
*.dSYM.zip
*.dSYM
*.xccheckout
*.xcscmblueprint
# Swift Package Manager
.build/
.swiftpm/
Packages/
Package.resolved
# macOS
.DS_Store
*.swp
*~
.AppleDouble
.LSOverride
._*
# IDE
.idea/
.vscode/
*.swp
*~
# Archives
*.tar.gz
*.zip

View file

@ -1,56 +1,96 @@
# qStatus
A fast, minimalistic macOS menubar app for posting statuses to Mastodon, WordPress, and Micro.blog.
A fast, native macOS menubar app for posting to **Mastodon**, **WordPress**, and **Micro.blog** with a single keyboard shortcut.
Built with Swift and SwiftUI. No Electron, no web views, no dependencies.
## 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
- **Global hotkey** — press `Ctrl+Option+Cmd+T` from anywhere to open the posting window
- **Multiple accounts** — add accounts from different services and post to one or more at once
- **Image attachments** — drag-and-drop or browse for images, up to 4 per post
- **Menubar app** — lives in your menubar, no Dock icon, always ready
- **Lightweight** — native Swift/SwiftUI, fast startup, minimal memory footprint
## Supported Services
| Service | Auth Method | Media Upload |
|---------|------------|--------------|
| **Mastodon** | OAuth2 (any instance) | up to 4 images |
| **WordPress** (self-hosted) | Application Passwords | up to 4 images |
| **Micro.blog** | App Token | up to 4 images |
## Requirements
- macOS 15 Sequoia or later
- [XcodeGen](https://github.com/yonaskolb/XcodeGen) (for building from source)
## Installation
## Build from Source
### Build from Source
```bash
brew install xcodegen
git clone https://github.com/pawelorzech/qstatus.git
cd qstatus
xcodegen generate
open qStatus.xcodeproj
```
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)
Then hit `Cmd+R` in Xcode to build and run.
## 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
1. Click the menubar icon → **Settings**
2. Click **+**, select **Mastodon**
3. Enter your instance URL (e.g. `mastodon.social`)
4. Authorize qStatus in your browser
### 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
1. In WordPress admin: **Users → Profile → Application Passwords**
2. Create a new password named "qStatus"
3. In qStatus Settings, enter 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
3. Paste it in qStatus Settings
## Usage
1. Press **Ctrl+Option+Cmd+T** (or click the menubar icon > New Post)
2. Select which account(s) to post to
1. Press **Ctrl+Option+Cmd+T** (or click menubar icon → **New Post**)
2. Select target account(s) using the chips at the top
3. Type your status
4. Optionally drag-and-drop images (up to 4)
4. Drag-and-drop images if needed (up to 4)
5. Press **Cmd+Enter** or click **Post**
## Project Structure
```
qStatus/
├── project.yml # XcodeGen project spec
├── qStatus/
│ ├── App/ # App entry point, AppDelegate, state
│ ├── Models/ # Account model, PostingService protocol
│ ├── Views/ # SwiftUI views (input panel, settings, menubar)
│ ├── Windows/ # NSPanel floating window
│ ├── Services/ # API clients (Mastodon, WordPress, Micro.blog)
│ ├── Utilities/ # Multipart form data helper
│ └── Resources/ # Info.plist, entitlements, assets
```
## Tech Stack
- **Swift 6** / **SwiftUI** with `@Observable` (macOS 15+)
- **MenuBarExtra** for menubar presence
- **NSPanel** for the floating input window
- **Carbon RegisterEventHotKey** for global keyboard shortcut
- **Keychain** (native Security framework) for credential storage
- **URLSession** async/await for all networking
- Zero third-party dependencies
## License
MIT

View file

@ -14,6 +14,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
registerGlobalHotKey()
}
func application(_ application: NSApplication, open urls: [URL]) {
for url in urls {
handleURL(url)
}
}
// MARK: - Floating Panel
func showPanel() {

View file

@ -80,7 +80,9 @@ struct MastodonClient: PostingService {
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")!
guard let url = URL(string: "\(base)/api/v1/apps") else {
throw PostingError(message: "Invalid instance URL: \(instanceURL)")
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
@ -89,11 +91,16 @@ struct MastodonClient: PostingService {
"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 data: Data
let response: URLResponse
do {
(data, response) = try await URLSession.shared.data(for: request)
} catch {
throw PostingError(message: "Cannot connect to \(instanceURL). Check the URL and try again.")
}
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
guard statusCode == 200 else {

View file

@ -98,11 +98,17 @@ struct MicroblogClient: PostingService {
var request = URLRequest(url: url)
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
let data: Data
let response: URLResponse
do {
(data, response) = try await URLSession.shared.data(for: request)
} catch {
throw PostingError(message: "Cannot connect to Micro.blog. Check your internet connection.")
}
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
guard statusCode == 200 else {
throw PostingError(message: "Invalid Micro.blog token")
throw PostingError(message: "Invalid Micro.blog token (HTTP \(statusCode)). Generate a new one at micro.blog/account/apps")
}
// Try to get destination info

View file

@ -145,13 +145,32 @@ struct WordPressClient: PostingService {
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")!
guard let url = URL(string: "\(base)/wp-json/wp/v2/users/me") else {
throw PostingError(message: "Invalid site URL: \(siteURL)")
}
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
let (data, response): (Data, URLResponse)
do {
(data, response) = try await URLSession.shared.data(for: request)
} catch {
throw PostingError(message: "Cannot connect to \(siteURL). Check the URL and try again.")
}
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
switch statusCode {
case 200:
return true
case 401, 403:
return false
case 404:
throw PostingError(message: "WordPress REST API not found at \(siteURL). Check the URL.")
default:
let body = String(data: data, encoding: .utf8) ?? ""
throw PostingError(message: "Unexpected response (\(statusCode)): \(body)")
}
}
private func mimeTypeFor(_ filename: String) -> String {

View file

@ -94,15 +94,30 @@ struct AddAccountView: View {
}
}
.pickerStyle(.segmented)
.disabled(waitingForOAuth || isLoading)
// Service-specific fields
switch selectedService {
case .mastodon:
mastodonFields
case .wordpress:
wordpressFields
case .microblog:
microblogFields
if waitingForOAuth {
VStack(spacing: 8) {
ProgressView()
Text("Waiting for authorization in your browser...")
.font(.caption)
.foregroundStyle(.secondary)
Text("Authorize qStatus on your Mastodon instance, then return here.")
.font(.caption)
.foregroundStyle(.tertiary)
.multilineTextAlignment(.center)
}
.padding(.vertical, 8)
} else {
// Service-specific fields
switch selectedService {
case .mastodon:
mastodonFields
case .wordpress:
wordpressFields
case .microblog:
microblogFields
}
}
if let errorMessage {
@ -112,24 +127,30 @@ struct AddAccountView: View {
}
HStack {
Button("Cancel") {
Button(waitingForOAuth ? "Cancel Auth" : "Cancel") {
if waitingForOAuth {
waitingForOAuth = false
UserDefaults.standard.removeObject(forKey: "qstatus.pending-mastodon-account")
}
isPresented = false
}
.keyboardShortcut(.cancelAction)
Spacer()
if isLoading {
if isLoading && !waitingForOAuth {
ProgressView()
.controlSize(.small)
}
Button("Add") {
Task { await addAccount() }
if !waitingForOAuth {
Button("Add") {
Task { await addAccount() }
}
.buttonStyle(.borderedProminent)
.disabled(!canAdd || isLoading)
.keyboardShortcut(.defaultAction)
}
.buttonStyle(.borderedProminent)
.disabled(!canAdd || isLoading)
.keyboardShortcut(.defaultAction)
}
}
.padding()
@ -205,6 +226,8 @@ struct AddAccountView: View {
.foregroundStyle(.secondary)
}
@State private var waitingForOAuth = false
private func addAccount() async {
isLoading = true
errorMessage = nil
@ -213,6 +236,9 @@ struct AddAccountView: View {
switch selectedService {
case .mastodon:
try await addMastodonAccount()
// Don't close sheet - waiting for OAuth callback
isLoading = false
return
case .wordpress:
try await addWordPressAccount()
case .microblog:
@ -234,7 +260,6 @@ struct AddAccountView: View {
let (clientID, clientSecret) = try await MastodonClient.registerApp(instanceURL: url)
// Store client credentials temporarily
var account = Account(
serviceType: .mastodon,
displayName: url.replacingOccurrences(of: "https://", with: ""),
@ -244,13 +269,27 @@ struct AddAccountView: View {
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)
waitingForOAuth = true
// Poll for the account to be added by the URL callback handler
for _ in 0..<120 { // wait up to 2 minutes
try? await Task.sleep(for: .seconds(1))
if UserDefaults.standard.data(forKey: "qstatus.pending-mastodon-account") == nil {
// Callback was handled, account was added
waitingForOAuth = false
isPresented = false
return
}
}
waitingForOAuth = false
throw PostingError(message: "Authorization timed out. Please try again.")
}
private func addWordPressAccount() async throws {