diff --git a/.gitignore b/.gitignore index e59f128..9fd30ad 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index df789f7..704fcea 100644 --- a/README.md +++ b/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 diff --git a/qStatus/App/AppDelegate.swift b/qStatus/App/AppDelegate.swift index 71c130f..86f2540 100644 --- a/qStatus/App/AppDelegate.swift +++ b/qStatus/App/AppDelegate.swift @@ -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() { diff --git a/qStatus/Services/MastodonClient.swift b/qStatus/Services/MastodonClient.swift index 3a534e1..dc1ac93 100644 --- a/qStatus/Services/MastodonClient.swift +++ b/qStatus/Services/MastodonClient.swift @@ -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 { diff --git a/qStatus/Services/MicroblogClient.swift b/qStatus/Services/MicroblogClient.swift index 630630a..1bbb754 100644 --- a/qStatus/Services/MicroblogClient.swift +++ b/qStatus/Services/MicroblogClient.swift @@ -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 diff --git a/qStatus/Services/WordPressClient.swift b/qStatus/Services/WordPressClient.swift index 9ac405e..742e23b 100644 --- a/qStatus/Services/WordPressClient.swift +++ b/qStatus/Services/WordPressClient.swift @@ -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 { diff --git a/qStatus/Views/SettingsView.swift b/qStatus/Views/SettingsView.swift index 2a500c6..637978b 100644 --- a/qStatus/Views/SettingsView.swift +++ b/qStatus/Views/SettingsView.swift @@ -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 {