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:
parent
c27437b33c
commit
dfe4485fe9
7 changed files with 181 additions and 56 deletions
18
.gitignore
vendored
18
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
90
README.md
90
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -94,7 +94,21 @@ struct AddAccountView: View {
|
|||
}
|
||||
}
|
||||
.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
|
||||
switch selectedService {
|
||||
case .mastodon:
|
||||
|
|
@ -104,6 +118,7 @@ struct AddAccountView: View {
|
|||
case .microblog:
|
||||
microblogFields
|
||||
}
|
||||
}
|
||||
|
||||
if let errorMessage {
|
||||
Text(errorMessage)
|
||||
|
|
@ -112,18 +127,23 @@ 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)
|
||||
}
|
||||
|
||||
if !waitingForOAuth {
|
||||
Button("Add") {
|
||||
Task { await addAccount() }
|
||||
}
|
||||
|
|
@ -132,6 +152,7 @@ struct AddAccountView: View {
|
|||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(width: 360)
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue