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

View file

@ -1,56 +1,96 @@
# qStatus # 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 ## Features
- **Global hotkey** (Ctrl+Option+Cmd+T) to instantly open the posting window - **Global hotkey** — press `Ctrl+Option+Cmd+T` from anywhere to open the posting window
- **Multiple accounts** -- post to Mastodon, WordPress (self-hosted), and Micro.blog - **Multiple accounts** — add accounts from different services and post to one or more at once
- **Image attachments** -- drag-and-drop or browse, up to 4 images - **Image attachments** — drag-and-drop or browse for images, up to 4 per post
- **Multi-post** -- select one or more accounts and post simultaneously - **Menubar app** — lives in your menubar, no Dock icon, always ready
- **Menubar app** -- lives in your menubar, no Dock icon - **Lightweight** — native Swift/SwiftUI, fast startup, minimal memory footprint
- **Lightweight** -- native Swift/SwiftUI, no Electron, no web views
## 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 ## Requirements
- macOS 15 Sequoia or later - 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` Then hit `Cmd+R` in Xcode to build and run.
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 ## Setting Up Accounts
### Mastodon ### Mastodon
1. Open Settings from the menubar icon
2. Click **+** and select **Mastodon** 1. Click the menubar icon → **Settings**
3. Enter your instance URL (e.g., `mastodon.social`) 2. Click **+**, select **Mastodon**
4. You'll be redirected to your instance to authorize qStatus 3. Enter your instance URL (e.g. `mastodon.social`)
4. Authorize qStatus in your browser
### WordPress (Self-Hosted) ### WordPress (Self-Hosted)
1. In your WordPress admin, go to **Users > Profile > Application Passwords**
2. Create a new application password for "qStatus" 1. In WordPress admin: **Users → Profile → Application Passwords**
3. In qStatus Settings, add a WordPress account with your site URL, username, and the application password 2. Create a new password named "qStatus"
3. In qStatus Settings, enter your site URL, username, and the application password
### Micro.blog ### Micro.blog
1. Go to [micro.blog/account/apps](https://micro.blog/account/apps) 1. Go to [micro.blog/account/apps](https://micro.blog/account/apps)
2. Generate a new app token 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 ## Usage
1. Press **Ctrl+Option+Cmd+T** (or click the menubar icon > New Post) 1. Press **Ctrl+Option+Cmd+T** (or click menubar icon → **New Post**)
2. Select which account(s) to post to 2. Select target account(s) using the chips at the top
3. Type your status 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** 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 ## License
MIT MIT

View file

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

View file

@ -80,7 +80,9 @@ struct MastodonClient: PostingService {
static func registerApp(instanceURL: String) async throws -> (clientID: String, clientSecret: String) { static func registerApp(instanceURL: String) async throws -> (clientID: String, clientSecret: String) {
let base = instanceURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) 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) var request = URLRequest(url: url)
request.httpMethod = "POST" request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Content-Type")
@ -89,11 +91,16 @@ struct MastodonClient: PostingService {
"client_name": "qStatus", "client_name": "qStatus",
"redirect_uris": "qstatus://mastodon-callback", "redirect_uris": "qstatus://mastodon-callback",
"scopes": "write:statuses write:media", "scopes": "write:statuses write:media",
"website": "https://github.com/nicedishy/qstatus",
] ]
request.httpBody = try JSONSerialization.data(withJSONObject: body) 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 let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
guard statusCode == 200 else { guard statusCode == 200 else {

View file

@ -98,11 +98,17 @@ struct MicroblogClient: PostingService {
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") 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 let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
guard statusCode == 200 else { 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 // 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 { static func verifyCredentials(siteURL: String, username: String, password: String) async throws -> Bool {
let base = siteURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) 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) var request = URLRequest(url: url)
let creds = "\(username):\(password)" let creds = "\(username):\(password)"
request.setValue("Basic \(Data(creds.utf8).base64EncodedString())", forHTTPHeaderField: "Authorization") request.setValue("Basic \(Data(creds.utf8).base64EncodedString())", forHTTPHeaderField: "Authorization")
let (_, response) = try await URLSession.shared.data(for: request) let (data, response): (Data, URLResponse)
return (response as? HTTPURLResponse)?.statusCode == 200 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 { private func mimeTypeFor(_ filename: String) -> String {

View file

@ -94,7 +94,21 @@ struct AddAccountView: View {
} }
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
.disabled(waitingForOAuth || isLoading)
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 // Service-specific fields
switch selectedService { switch selectedService {
case .mastodon: case .mastodon:
@ -104,6 +118,7 @@ struct AddAccountView: View {
case .microblog: case .microblog:
microblogFields microblogFields
} }
}
if let errorMessage { if let errorMessage {
Text(errorMessage) Text(errorMessage)
@ -112,18 +127,23 @@ struct AddAccountView: View {
} }
HStack { HStack {
Button("Cancel") { Button(waitingForOAuth ? "Cancel Auth" : "Cancel") {
if waitingForOAuth {
waitingForOAuth = false
UserDefaults.standard.removeObject(forKey: "qstatus.pending-mastodon-account")
}
isPresented = false isPresented = false
} }
.keyboardShortcut(.cancelAction) .keyboardShortcut(.cancelAction)
Spacer() Spacer()
if isLoading { if isLoading && !waitingForOAuth {
ProgressView() ProgressView()
.controlSize(.small) .controlSize(.small)
} }
if !waitingForOAuth {
Button("Add") { Button("Add") {
Task { await addAccount() } Task { await addAccount() }
} }
@ -132,6 +152,7 @@ struct AddAccountView: View {
.keyboardShortcut(.defaultAction) .keyboardShortcut(.defaultAction)
} }
} }
}
.padding() .padding()
.frame(width: 360) .frame(width: 360)
} }
@ -205,6 +226,8 @@ struct AddAccountView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@State private var waitingForOAuth = false
private func addAccount() async { private func addAccount() async {
isLoading = true isLoading = true
errorMessage = nil errorMessage = nil
@ -213,6 +236,9 @@ struct AddAccountView: View {
switch selectedService { switch selectedService {
case .mastodon: case .mastodon:
try await addMastodonAccount() try await addMastodonAccount()
// Don't close sheet - waiting for OAuth callback
isLoading = false
return
case .wordpress: case .wordpress:
try await addWordPressAccount() try await addWordPressAccount()
case .microblog: case .microblog:
@ -234,7 +260,6 @@ struct AddAccountView: View {
let (clientID, clientSecret) = try await MastodonClient.registerApp(instanceURL: url) let (clientID, clientSecret) = try await MastodonClient.registerApp(instanceURL: url)
// Store client credentials temporarily
var account = Account( var account = Account(
serviceType: .mastodon, serviceType: .mastodon,
displayName: url.replacingOccurrences(of: "https://", with: ""), displayName: url.replacingOccurrences(of: "https://", with: ""),
@ -244,13 +269,27 @@ struct AddAccountView: View {
account.mastodonClientID = clientID account.mastodonClientID = clientID
account.mastodonClientSecret = clientSecret account.mastodonClientSecret = clientSecret
// Save account without token - will complete after OAuth
let accountData = try JSONEncoder().encode(account) let accountData = try JSONEncoder().encode(account)
UserDefaults.standard.set(accountData, forKey: "qstatus.pending-mastodon-account") UserDefaults.standard.set(accountData, forKey: "qstatus.pending-mastodon-account")
// Open browser for authorization
let authURL = MastodonClient.authorizeURL(instanceURL: url, clientID: clientID) let authURL = MastodonClient.authorizeURL(instanceURL: url, clientID: clientID)
NSWorkspace.shared.open(authURL) 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 { private func addWordPressAccount() async throws {