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
|
# 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
|
||||||
|
|
|
||||||
90
README.md
90
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -94,15 +94,30 @@ struct AddAccountView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
|
.disabled(waitingForOAuth || isLoading)
|
||||||
|
|
||||||
// Service-specific fields
|
if waitingForOAuth {
|
||||||
switch selectedService {
|
VStack(spacing: 8) {
|
||||||
case .mastodon:
|
ProgressView()
|
||||||
mastodonFields
|
Text("Waiting for authorization in your browser...")
|
||||||
case .wordpress:
|
.font(.caption)
|
||||||
wordpressFields
|
.foregroundStyle(.secondary)
|
||||||
case .microblog:
|
Text("Authorize qStatus on your Mastodon instance, then return here.")
|
||||||
microblogFields
|
.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 {
|
if let errorMessage {
|
||||||
|
|
@ -112,24 +127,30 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
Button("Add") {
|
if !waitingForOAuth {
|
||||||
Task { await addAccount() }
|
Button("Add") {
|
||||||
|
Task { await addAccount() }
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(!canAdd || isLoading)
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.disabled(!canAdd || isLoading)
|
|
||||||
.keyboardShortcut(.defaultAction)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue