Initial implementation of qStatus macOS menubar app
Native macOS app for posting to Mastodon, WordPress (self-hosted), and Micro.blog. Features: global hotkey (Ctrl+Option+Cmd+T), multiple accounts with selection, image attachments (up to 4, drag-and-drop), floating panel UI, Keychain storage.
This commit is contained in:
commit
c27437b33c
26 changed files with 2019 additions and 0 deletions
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# Xcode
|
||||
*.xcodeproj/
|
||||
!*.xcodeproj/project.pbxproj
|
||||
*.xcodeproj/xcuserdata/
|
||||
*.xcworkspace/xcuserdata/
|
||||
xcuserdata/
|
||||
build/
|
||||
DerivedData/
|
||||
*.moved-aside
|
||||
*.pbxuser
|
||||
*.mode1v3
|
||||
*.mode2v3
|
||||
*.perspectivev3
|
||||
*.hmap
|
||||
*.ipa
|
||||
*.dSYM.zip
|
||||
*.dSYM
|
||||
|
||||
# Swift Package Manager
|
||||
.build/
|
||||
.swiftpm/
|
||||
Packages/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.swp
|
||||
*~
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 qStatus Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
56
README.md
Normal file
56
README.md
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# qStatus
|
||||
|
||||
A fast, minimalistic macOS menubar app for posting statuses to Mastodon, WordPress, and Micro.blog.
|
||||
|
||||
## 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
|
||||
|
||||
## Requirements
|
||||
|
||||
- macOS 15 Sequoia or later
|
||||
|
||||
## Installation
|
||||
|
||||
### Build from Source
|
||||
|
||||
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)
|
||||
|
||||
## 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
|
||||
|
||||
### 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
|
||||
|
||||
### 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
|
||||
|
||||
## Usage
|
||||
|
||||
1. Press **Ctrl+Option+Cmd+T** (or click the menubar icon > New Post)
|
||||
2. Select which account(s) to post to
|
||||
3. Type your status
|
||||
4. Optionally drag-and-drop images (up to 4)
|
||||
5. Press **Cmd+Enter** or click **Post**
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
35
project.yml
Normal file
35
project.yml
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
name: qStatus
|
||||
options:
|
||||
bundleIdPrefix: com.qstatus
|
||||
deploymentTarget:
|
||||
macOS: "15.0"
|
||||
xcodeVersion: "16.0"
|
||||
generateEmptyDirectories: true
|
||||
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: "6.0"
|
||||
MACOSX_DEPLOYMENT_TARGET: "15.0"
|
||||
ENABLE_USER_SCRIPT_SANDBOXING: true
|
||||
|
||||
targets:
|
||||
qStatus:
|
||||
type: application
|
||||
platform: macOS
|
||||
sources:
|
||||
- qStatus
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.qstatus.app
|
||||
PRODUCT_NAME: qStatus
|
||||
INFOPLIST_FILE: qStatus/Resources/Info.plist
|
||||
CODE_SIGN_ENTITLEMENTS: qStatus/Resources/qStatus.entitlements
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
ENABLE_HARDENED_RUNTIME: true
|
||||
COMBINE_HIDPI_IMAGES: true
|
||||
entitlements:
|
||||
path: qStatus/Resources/qStatus.entitlements
|
||||
properties:
|
||||
com.apple.security.app-sandbox: true
|
||||
com.apple.security.network.client: true
|
||||
com.apple.security.files.user-selected.read-only: true
|
||||
231
qStatus/App/AppDelegate.swift
Normal file
231
qStatus/App/AppDelegate.swift
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
import AppKit
|
||||
import Carbon
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
let appState = AppState()
|
||||
let accountStore = AccountStore()
|
||||
var floatingPanel: FloatingPanel?
|
||||
var settingsWindow: NSWindow?
|
||||
private var hotKeyRef: EventHotKeyRef?
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
registerGlobalHotKey()
|
||||
}
|
||||
|
||||
// MARK: - Floating Panel
|
||||
|
||||
func showPanel() {
|
||||
if floatingPanel == nil {
|
||||
let panelContent = InputPanelView(
|
||||
appState: appState,
|
||||
accountStore: accountStore,
|
||||
onPost: { [weak self] in
|
||||
self?.performPost()
|
||||
},
|
||||
onDismiss: { [weak self] in
|
||||
self?.hidePanel()
|
||||
}
|
||||
)
|
||||
floatingPanel = FloatingPanel(contentView: panelContent)
|
||||
}
|
||||
|
||||
floatingPanel?.center()
|
||||
floatingPanel?.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
appState.isPanelVisible = true
|
||||
}
|
||||
|
||||
func hidePanel() {
|
||||
floatingPanel?.orderOut(nil)
|
||||
appState.isPanelVisible = false
|
||||
}
|
||||
|
||||
func togglePanel() {
|
||||
if appState.isPanelVisible {
|
||||
hidePanel()
|
||||
} else {
|
||||
showPanel()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Settings Window
|
||||
|
||||
func showSettings() {
|
||||
if settingsWindow == nil {
|
||||
let settingsView = SettingsView(accountStore: accountStore)
|
||||
let hostingView = NSHostingView(rootView: settingsView)
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 360, height: 300),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.contentView = hostingView
|
||||
window.title = "qStatus Settings"
|
||||
window.center()
|
||||
window.isReleasedWhenClosed = false
|
||||
settingsWindow = window
|
||||
}
|
||||
settingsWindow?.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
// MARK: - Posting
|
||||
|
||||
func performPost() {
|
||||
guard appState.canPost else { return }
|
||||
appState.isSubmitting = true
|
||||
appState.statusMessage = nil
|
||||
|
||||
let selectedAccounts = accountStore.accounts.filter {
|
||||
appState.selectedAccountIDs.contains($0.id)
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
let results = await PostingManager.post(
|
||||
text: appState.inputText,
|
||||
images: appState.attachedImages,
|
||||
accounts: selectedAccounts,
|
||||
accountStore: accountStore
|
||||
)
|
||||
|
||||
var successes: [String] = []
|
||||
var failures: [String] = []
|
||||
|
||||
for (account, result) in results {
|
||||
switch result {
|
||||
case .success:
|
||||
successes.append(account.displayName)
|
||||
case .failure(let error):
|
||||
failures.append("\(account.displayName): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
appState.isSubmitting = false
|
||||
|
||||
if failures.isEmpty {
|
||||
appState.statusMessage = StatusMessage(
|
||||
text: "Posted to \(successes.joined(separator: ", "))",
|
||||
isError: false
|
||||
)
|
||||
// Clear after short delay
|
||||
try? await Task.sleep(for: .seconds(1.5))
|
||||
appState.reset()
|
||||
hidePanel()
|
||||
} else {
|
||||
let msg = failures.joined(separator: "\n")
|
||||
appState.statusMessage = StatusMessage(text: msg, isError: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Global Hotkey
|
||||
|
||||
private func registerGlobalHotKey() {
|
||||
// Ctrl + Option + Cmd + T
|
||||
let modifiers: UInt32 = UInt32(cmdKey | optionKey | controlKey)
|
||||
let keyCode: UInt32 = UInt32(kVK_ANSI_T)
|
||||
|
||||
var hotKeyID = EventHotKeyID()
|
||||
hotKeyID.signature = OSType(0x7153_5448) // 'qSTH'
|
||||
hotKeyID.id = 1
|
||||
|
||||
var eventType = EventTypeSpec()
|
||||
eventType.eventClass = OSType(kEventClassKeyboard)
|
||||
eventType.eventKind = UInt32(kEventHotKeyPressed)
|
||||
|
||||
let selfPtr = Unmanaged.passUnretained(self).toOpaque()
|
||||
|
||||
InstallEventHandler(
|
||||
GetApplicationEventTarget(),
|
||||
{ (_, event, userData) -> OSStatus in
|
||||
guard let userData else { return OSStatus(eventNotHandledErr) }
|
||||
let delegate = Unmanaged<AppDelegate>.fromOpaque(userData).takeUnretainedValue()
|
||||
DispatchQueue.main.async {
|
||||
delegate.togglePanel()
|
||||
}
|
||||
return noErr
|
||||
},
|
||||
1,
|
||||
&eventType,
|
||||
selfPtr,
|
||||
nil
|
||||
)
|
||||
|
||||
RegisterEventHotKey(
|
||||
keyCode,
|
||||
modifiers,
|
||||
hotKeyID,
|
||||
GetApplicationEventTarget(),
|
||||
0,
|
||||
&hotKeyRef
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - URL Handling
|
||||
|
||||
func handleURL(_ url: URL) {
|
||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return }
|
||||
|
||||
switch components.host {
|
||||
case "mastodon-callback":
|
||||
handleMastodonCallback(components)
|
||||
case "wordpress-callback":
|
||||
handleWordPressCallback(components)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func handleMastodonCallback(_ components: URLComponents) {
|
||||
guard let code = components.queryItems?.first(where: { $0.name == "code" })?.value else { return }
|
||||
|
||||
Task {
|
||||
guard let accountData = UserDefaults.standard.data(forKey: "qstatus.pending-mastodon-account"),
|
||||
var account = try? JSONDecoder().decode(Account.self, from: accountData),
|
||||
let clientID = account.mastodonClientID,
|
||||
let clientSecret = account.mastodonClientSecret
|
||||
else { return }
|
||||
|
||||
do {
|
||||
let token = try await MastodonClient.exchangeCode(
|
||||
instanceURL: account.instanceURL,
|
||||
clientID: clientID,
|
||||
clientSecret: clientSecret,
|
||||
code: code
|
||||
)
|
||||
|
||||
let username = try await MastodonClient.verifyCredentials(
|
||||
instanceURL: account.instanceURL,
|
||||
token: token
|
||||
)
|
||||
|
||||
account.username = username
|
||||
account.displayName = "@\(username)@\(account.instanceURL.replacingOccurrences(of: "https://", with: ""))"
|
||||
try accountStore.addAccount(account, token: token)
|
||||
|
||||
UserDefaults.standard.removeObject(forKey: "qstatus.pending-mastodon-account")
|
||||
} catch {
|
||||
print("Mastodon auth error: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleWordPressCallback(_ components: URLComponents) {
|
||||
guard let userLogin = components.queryItems?.first(where: { $0.name == "user_login" })?.value,
|
||||
let password = components.queryItems?.first(where: { $0.name == "password" })?.value,
|
||||
let siteURL = components.queryItems?.first(where: { $0.name == "site_url" })?.value
|
||||
else { return }
|
||||
|
||||
let credentials = "\(userLogin):\(password)"
|
||||
let account = Account(
|
||||
serviceType: .wordpress,
|
||||
displayName: "\(userLogin)@\(siteURL.replacingOccurrences(of: "https://", with: ""))",
|
||||
instanceURL: siteURL,
|
||||
username: userLogin
|
||||
)
|
||||
try? accountStore.addAccount(account, token: credentials)
|
||||
}
|
||||
}
|
||||
39
qStatus/App/AppState.swift
Normal file
39
qStatus/App/AppState.swift
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import SwiftUI
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class AppState {
|
||||
var inputText: String = ""
|
||||
var attachedImages: [ImageAttachment] = []
|
||||
var selectedAccountIDs: Set<UUID> = []
|
||||
var isSubmitting: Bool = false
|
||||
var isPanelVisible: Bool = false
|
||||
var statusMessage: StatusMessage?
|
||||
|
||||
static let maxImages = 4
|
||||
|
||||
var canPost: Bool {
|
||||
!inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
&& !selectedAccountIDs.isEmpty
|
||||
&& !isSubmitting
|
||||
}
|
||||
|
||||
func reset() {
|
||||
inputText = ""
|
||||
attachedImages = []
|
||||
statusMessage = nil
|
||||
}
|
||||
}
|
||||
|
||||
struct ImageAttachment: Identifiable {
|
||||
let id = UUID()
|
||||
let image: NSImage
|
||||
let data: Data
|
||||
let filename: String
|
||||
}
|
||||
|
||||
struct StatusMessage: Identifiable {
|
||||
let id = UUID()
|
||||
let text: String
|
||||
let isError: Bool
|
||||
}
|
||||
20
qStatus/App/QStatusApp.swift
Normal file
20
qStatus/App/QStatusApp.swift
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct QStatusApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||
|
||||
var body: some Scene {
|
||||
MenuBarExtra("qStatus", systemImage: "bubble.left.and.text.bubble.right") {
|
||||
MenuBarView(
|
||||
accountStore: appDelegate.accountStore,
|
||||
onOpenPanel: { appDelegate.showPanel() },
|
||||
onOpenSettings: { appDelegate.showSettings() }
|
||||
)
|
||||
}
|
||||
|
||||
Settings {
|
||||
SettingsView(accountStore: appDelegate.accountStore)
|
||||
}
|
||||
}
|
||||
}
|
||||
56
qStatus/Models/Account.swift
Normal file
56
qStatus/Models/Account.swift
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import Foundation
|
||||
|
||||
enum ServiceType: String, Codable, CaseIterable, Identifiable {
|
||||
case mastodon
|
||||
case wordpress
|
||||
case microblog
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .mastodon: "Mastodon"
|
||||
case .wordpress: "WordPress"
|
||||
case .microblog: "Micro.blog"
|
||||
}
|
||||
}
|
||||
|
||||
var iconName: String {
|
||||
switch self {
|
||||
case .mastodon: "bubble.left.and.text.bubble.right"
|
||||
case .wordpress: "w.square"
|
||||
case .microblog: "pencil.and.outline"
|
||||
}
|
||||
}
|
||||
|
||||
var maxImages: Int { 4 }
|
||||
}
|
||||
|
||||
struct Account: Identifiable, Codable, Hashable {
|
||||
let id: UUID
|
||||
var serviceType: ServiceType
|
||||
var displayName: String
|
||||
var instanceURL: String
|
||||
var username: String
|
||||
|
||||
// Mastodon-specific
|
||||
var mastodonClientID: String?
|
||||
var mastodonClientSecret: String?
|
||||
|
||||
init(
|
||||
serviceType: ServiceType,
|
||||
displayName: String,
|
||||
instanceURL: String,
|
||||
username: String
|
||||
) {
|
||||
self.id = UUID()
|
||||
self.serviceType = serviceType
|
||||
self.displayName = displayName
|
||||
self.instanceURL = instanceURL
|
||||
self.username = username
|
||||
}
|
||||
|
||||
var keychainKey: String {
|
||||
"account-\(id.uuidString)"
|
||||
}
|
||||
}
|
||||
12
qStatus/Models/PostingService.swift
Normal file
12
qStatus/Models/PostingService.swift
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import AppKit
|
||||
|
||||
protocol PostingService: Sendable {
|
||||
var account: Account { get }
|
||||
func uploadMedia(imageData: Data, filename: String, altText: String?) async throws -> String
|
||||
func createPost(text: String, mediaIDs: [String]) async throws -> URL
|
||||
}
|
||||
|
||||
struct PostingError: LocalizedError {
|
||||
let message: String
|
||||
var errorDescription: String? { message }
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.898",
|
||||
"green" : "0.459",
|
||||
"red" : "0.294"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
6
qStatus/Resources/Assets.xcassets/Contents.json
Normal file
6
qStatus/Resources/Assets.xcassets/Contents.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
37
qStatus/Resources/Info.plist
Normal file
37
qStatus/Resources/Info.plist
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>qStatus</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.qstatus.app</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>qstatus</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
12
qStatus/Resources/qStatus.entitlements
Normal file
12
qStatus/Resources/qStatus.entitlements
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-only</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
49
qStatus/Services/AccountStore.swift
Normal file
49
qStatus/Services/AccountStore.swift
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import Foundation
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class AccountStore {
|
||||
private(set) var accounts: [Account] = []
|
||||
|
||||
private static let storageKey = "qstatus.accounts"
|
||||
|
||||
init() {
|
||||
loadAccounts()
|
||||
}
|
||||
|
||||
func addAccount(_ account: Account, token: String) throws {
|
||||
try KeychainManager.saveToken(token, forAccount: account.keychainKey)
|
||||
accounts.append(account)
|
||||
saveAccounts()
|
||||
}
|
||||
|
||||
func removeAccount(_ account: Account) {
|
||||
try? KeychainManager.delete(account: account.keychainKey)
|
||||
accounts.removeAll { $0.id == account.id }
|
||||
saveAccounts()
|
||||
}
|
||||
|
||||
func token(for account: Account) -> String? {
|
||||
try? KeychainManager.loadToken(forAccount: account.keychainKey)
|
||||
}
|
||||
|
||||
func updateAccount(_ account: Account) {
|
||||
if let index = accounts.firstIndex(where: { $0.id == account.id }) {
|
||||
accounts[index] = account
|
||||
saveAccounts()
|
||||
}
|
||||
}
|
||||
|
||||
private func saveAccounts() {
|
||||
if let data = try? JSONEncoder().encode(accounts) {
|
||||
UserDefaults.standard.set(data, forKey: Self.storageKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadAccounts() {
|
||||
guard let data = UserDefaults.standard.data(forKey: Self.storageKey),
|
||||
let decoded = try? JSONDecoder().decode([Account].self, from: data)
|
||||
else { return }
|
||||
accounts = decoded
|
||||
}
|
||||
}
|
||||
93
qStatus/Services/KeychainManager.swift
Normal file
93
qStatus/Services/KeychainManager.swift
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import Foundation
|
||||
import Security
|
||||
|
||||
enum KeychainError: LocalizedError {
|
||||
case itemNotFound
|
||||
case unexpectedStatus(OSStatus)
|
||||
case invalidData
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .itemNotFound: "Credential not found in Keychain"
|
||||
case .unexpectedStatus(let status): "Keychain error: \(status)"
|
||||
case .invalidData: "Invalid data in Keychain"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct KeychainManager {
|
||||
private static let service = "com.qstatus.app"
|
||||
|
||||
static func save(account: String, data: Data) throws {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecValueData as String: data,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
|
||||
]
|
||||
|
||||
let status = SecItemAdd(query as CFDictionary, nil)
|
||||
|
||||
if status == errSecDuplicateItem {
|
||||
let updateQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
let attributes: [String: Any] = [kSecValueData as String: data]
|
||||
let updateStatus = SecItemUpdate(updateQuery as CFDictionary, attributes as CFDictionary)
|
||||
guard updateStatus == errSecSuccess else {
|
||||
throw KeychainError.unexpectedStatus(updateStatus)
|
||||
}
|
||||
} else if status != errSecSuccess {
|
||||
throw KeychainError.unexpectedStatus(status)
|
||||
}
|
||||
}
|
||||
|
||||
static func load(account: String) throws -> Data {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
|
||||
guard status == errSecSuccess else {
|
||||
if status == errSecItemNotFound { throw KeychainError.itemNotFound }
|
||||
throw KeychainError.unexpectedStatus(status)
|
||||
}
|
||||
|
||||
guard let data = result as? Data else { throw KeychainError.invalidData }
|
||||
return data
|
||||
}
|
||||
|
||||
static func delete(account: String) throws {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||
throw KeychainError.unexpectedStatus(status)
|
||||
}
|
||||
}
|
||||
|
||||
static func saveToken(_ token: String, forAccount account: String) throws {
|
||||
guard let data = token.data(using: .utf8) else { return }
|
||||
try save(account: account, data: data)
|
||||
}
|
||||
|
||||
static func loadToken(forAccount account: String) throws -> String {
|
||||
let data = try load(account: account)
|
||||
guard let token = String(data: data, encoding: .utf8) else {
|
||||
throw KeychainError.invalidData
|
||||
}
|
||||
return token
|
||||
}
|
||||
}
|
||||
191
qStatus/Services/MastodonClient.swift
Normal file
191
qStatus/Services/MastodonClient.swift
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import Foundation
|
||||
|
||||
struct MastodonClient: PostingService {
|
||||
let account: Account
|
||||
private let token: String
|
||||
private let baseURL: String
|
||||
|
||||
init(account: Account, token: String) {
|
||||
self.account = account
|
||||
self.token = token
|
||||
self.baseURL = account.instanceURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
}
|
||||
|
||||
func uploadMedia(imageData: Data, filename: String, altText: String?) async throws -> String {
|
||||
let url = URL(string: "\(baseURL)/api/v2/media")!
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
|
||||
var form = MultipartFormData()
|
||||
form.addFile(name: "file", filename: filename, mimeType: mimeTypeFor(filename), data: imageData)
|
||||
if let altText, !altText.isEmpty {
|
||||
form.addField(name: "description", value: altText)
|
||||
}
|
||||
|
||||
request.setValue(form.contentType, forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = form.finalized
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
|
||||
guard (200...202).contains(statusCode) else {
|
||||
let body = String(data: data, encoding: .utf8) ?? "Unknown error"
|
||||
throw PostingError(message: "Mastodon media upload failed (\(statusCode)): \(body)")
|
||||
}
|
||||
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let mediaID = json["id"] as? String
|
||||
else {
|
||||
throw PostingError(message: "Invalid response from Mastodon media upload")
|
||||
}
|
||||
|
||||
return mediaID
|
||||
}
|
||||
|
||||
func createPost(text: String, mediaIDs: [String]) async throws -> URL {
|
||||
let url = URL(string: "\(baseURL)/api/v1/statuses")!
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue(UUID().uuidString, forHTTPHeaderField: "Idempotency-Key")
|
||||
|
||||
var body: [String: Any] = ["status": text, "visibility": "public"]
|
||||
if !mediaIDs.isEmpty {
|
||||
body["media_ids"] = mediaIDs
|
||||
}
|
||||
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
|
||||
guard statusCode == 200 else {
|
||||
let errorBody = String(data: data, encoding: .utf8) ?? "Unknown error"
|
||||
throw PostingError(message: "Mastodon post failed (\(statusCode)): \(errorBody)")
|
||||
}
|
||||
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let urlString = json["url"] as? String,
|
||||
let postURL = URL(string: urlString)
|
||||
else {
|
||||
throw PostingError(message: "Invalid response from Mastodon")
|
||||
}
|
||||
|
||||
return postURL
|
||||
}
|
||||
|
||||
// MARK: - OAuth Registration
|
||||
|
||||
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")!
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let body: [String: String] = [
|
||||
"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 statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
|
||||
guard statusCode == 200 else {
|
||||
throw PostingError(message: "Failed to register app on \(instanceURL)")
|
||||
}
|
||||
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let clientID = json["client_id"] as? String,
|
||||
let clientSecret = json["client_secret"] as? String
|
||||
else {
|
||||
throw PostingError(message: "Invalid registration response")
|
||||
}
|
||||
|
||||
return (clientID, clientSecret)
|
||||
}
|
||||
|
||||
static func authorizeURL(instanceURL: String, clientID: String) -> URL {
|
||||
let base = instanceURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
var components = URLComponents(string: "\(base)/oauth/authorize")!
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "client_id", value: clientID),
|
||||
URLQueryItem(name: "scope", value: "write:statuses write:media"),
|
||||
URLQueryItem(name: "redirect_uri", value: "qstatus://mastodon-callback"),
|
||||
URLQueryItem(name: "response_type", value: "code"),
|
||||
]
|
||||
return components.url!
|
||||
}
|
||||
|
||||
static func exchangeCode(
|
||||
instanceURL: String, clientID: String, clientSecret: String, code: String
|
||||
) async throws -> String {
|
||||
let base = instanceURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
let url = URL(string: "\(base)/oauth/token")!
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let params = [
|
||||
"grant_type=authorization_code",
|
||||
"client_id=\(clientID)",
|
||||
"client_secret=\(clientSecret)",
|
||||
"redirect_uri=qstatus://mastodon-callback",
|
||||
"code=\(code)",
|
||||
].joined(separator: "&")
|
||||
|
||||
request.httpBody = params.data(using: .utf8)
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
|
||||
guard statusCode == 200 else {
|
||||
throw PostingError(message: "Token exchange failed (\(statusCode))")
|
||||
}
|
||||
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let token = json["access_token"] as? String
|
||||
else {
|
||||
throw PostingError(message: "Invalid token response")
|
||||
}
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
static func verifyCredentials(instanceURL: String, token: String) async throws -> String {
|
||||
let base = instanceURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
let url = URL(string: "\(base)/api/v1/accounts/verify_credentials")!
|
||||
var request = URLRequest(url: url)
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
|
||||
guard statusCode == 200 else {
|
||||
throw PostingError(message: "Credential verification failed")
|
||||
}
|
||||
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let username = json["username"] as? String
|
||||
else {
|
||||
throw PostingError(message: "Invalid credentials response")
|
||||
}
|
||||
|
||||
return username
|
||||
}
|
||||
|
||||
private func mimeTypeFor(_ filename: String) -> String {
|
||||
let ext = (filename as NSString).pathExtension.lowercased()
|
||||
switch ext {
|
||||
case "png": return "image/png"
|
||||
case "gif": return "image/gif"
|
||||
case "webp": return "image/webp"
|
||||
default: return "image/jpeg"
|
||||
}
|
||||
}
|
||||
}
|
||||
128
qStatus/Services/MicroblogClient.swift
Normal file
128
qStatus/Services/MicroblogClient.swift
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import Foundation
|
||||
|
||||
struct MicroblogClient: PostingService {
|
||||
let account: Account
|
||||
private let token: String
|
||||
|
||||
private static let micropubURL = "https://micro.blog/micropub"
|
||||
private static let mediaURL = "https://micro.blog/micropub/media"
|
||||
|
||||
init(account: Account, token: String) {
|
||||
self.account = account
|
||||
self.token = token
|
||||
}
|
||||
|
||||
func uploadMedia(imageData: Data, filename: String, altText: String?) async throws -> String {
|
||||
let url = URL(string: Self.mediaURL)!
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
|
||||
var form = MultipartFormData()
|
||||
form.addFile(name: "file", filename: filename, mimeType: mimeTypeFor(filename), data: imageData)
|
||||
|
||||
request.setValue(form.contentType, forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = form.finalized
|
||||
|
||||
let (_, response) = try await URLSession.shared.data(for: request)
|
||||
let httpResponse = response as? HTTPURLResponse
|
||||
let statusCode = httpResponse?.statusCode ?? 0
|
||||
|
||||
guard (200...202).contains(statusCode) else {
|
||||
throw PostingError(message: "Micro.blog media upload failed (\(statusCode))")
|
||||
}
|
||||
|
||||
guard let location = httpResponse?.value(forHTTPHeaderField: "Location") else {
|
||||
throw PostingError(message: "No Location header in Micro.blog media response")
|
||||
}
|
||||
|
||||
return location
|
||||
}
|
||||
|
||||
func createPost(text: String, mediaIDs: [String]) async throws -> URL {
|
||||
// mediaIDs are image URLs for Micro.blog
|
||||
let url = URL(string: Self.micropubURL)!
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
|
||||
if mediaIDs.isEmpty {
|
||||
// Simple form-encoded post
|
||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
let params = "h=entry&content=\(text.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")"
|
||||
request.httpBody = params.data(using: .utf8)
|
||||
} else {
|
||||
// JSON with HTML content including images
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
var htmlContent = "<p>\(text)</p>"
|
||||
for imageURL in mediaIDs {
|
||||
htmlContent += "<img src=\"\(imageURL)\" />"
|
||||
}
|
||||
|
||||
let body: [String: Any] = [
|
||||
"type": ["h-entry"],
|
||||
"properties": [
|
||||
"content": [["html": htmlContent]],
|
||||
],
|
||||
]
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
}
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
let httpResponse = response as? HTTPURLResponse
|
||||
let statusCode = httpResponse?.statusCode ?? 0
|
||||
|
||||
guard (200...202).contains(statusCode) else {
|
||||
let body = String(data: data, encoding: .utf8) ?? ""
|
||||
throw PostingError(message: "Micro.blog post failed (\(statusCode)): \(body)")
|
||||
}
|
||||
|
||||
if let location = httpResponse?.value(forHTTPHeaderField: "Location"),
|
||||
let postURL = URL(string: location) {
|
||||
return postURL
|
||||
}
|
||||
|
||||
// Try to parse URL from response body
|
||||
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let urlString = json["url"] as? String,
|
||||
let postURL = URL(string: urlString) {
|
||||
return postURL
|
||||
}
|
||||
|
||||
return URL(string: "https://micro.blog")!
|
||||
}
|
||||
|
||||
static func verifyToken(_ token: String) async throws -> String {
|
||||
let url = URL(string: "https://micro.blog/micropub?q=config")!
|
||||
var request = URLRequest(url: url)
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
|
||||
guard statusCode == 200 else {
|
||||
throw PostingError(message: "Invalid Micro.blog token")
|
||||
}
|
||||
|
||||
// Try to get destination info
|
||||
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let destinations = json["destination"] as? [[String: Any]],
|
||||
let first = destinations.first,
|
||||
let name = first["name"] as? String {
|
||||
return name
|
||||
}
|
||||
|
||||
return "Micro.blog"
|
||||
}
|
||||
|
||||
private func mimeTypeFor(_ filename: String) -> String {
|
||||
let ext = (filename as NSString).pathExtension.lowercased()
|
||||
switch ext {
|
||||
case "png": return "image/png"
|
||||
case "gif": return "image/gif"
|
||||
case "webp": return "image/webp"
|
||||
default: return "image/jpeg"
|
||||
}
|
||||
}
|
||||
}
|
||||
65
qStatus/Services/PostingManager.swift
Normal file
65
qStatus/Services/PostingManager.swift
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import Foundation
|
||||
|
||||
@MainActor
|
||||
struct PostingManager {
|
||||
|
||||
static func post(
|
||||
text: String,
|
||||
images: [ImageAttachment],
|
||||
accounts: [Account],
|
||||
accountStore: AccountStore
|
||||
) async -> [(Account, Result<URL, Error>)] {
|
||||
var results: [(Account, Result<URL, Error>)] = []
|
||||
|
||||
for account in accounts {
|
||||
guard let token = accountStore.token(for: account) else {
|
||||
results.append((account, .failure(PostingError(message: "No token for \(account.displayName)"))))
|
||||
continue
|
||||
}
|
||||
|
||||
do {
|
||||
let url = try await postToAccount(account: account, token: token, text: text, images: images)
|
||||
results.append((account, .success(url)))
|
||||
} catch {
|
||||
results.append((account, .failure(error)))
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
private static func postToAccount(
|
||||
account: Account, token: String, text: String, images: [ImageAttachment]
|
||||
) async throws -> URL {
|
||||
let imageData = images.map { (data: $0.data, filename: $0.filename) }
|
||||
|
||||
switch account.serviceType {
|
||||
case .mastodon:
|
||||
let client = MastodonClient(account: account, token: token)
|
||||
var mediaIDs: [String] = []
|
||||
for img in imageData {
|
||||
let id = try await client.uploadMedia(imageData: img.data, filename: img.filename, altText: nil)
|
||||
mediaIDs.append(id)
|
||||
}
|
||||
return try await client.createPost(text: text, mediaIDs: mediaIDs)
|
||||
|
||||
case .wordpress:
|
||||
let client = WordPressClient(account: account, token: token)
|
||||
var mediaIDs: [String] = []
|
||||
for img in imageData {
|
||||
let id = try await client.uploadMedia(imageData: img.data, filename: img.filename, altText: nil)
|
||||
mediaIDs.append(id)
|
||||
}
|
||||
return try await client.createPost(text: text, mediaIDs: mediaIDs)
|
||||
|
||||
case .microblog:
|
||||
let client = MicroblogClient(account: account, token: token)
|
||||
var mediaIDs: [String] = []
|
||||
for img in imageData {
|
||||
let id = try await client.uploadMedia(imageData: img.data, filename: img.filename, altText: nil)
|
||||
mediaIDs.append(id)
|
||||
}
|
||||
return try await client.createPost(text: text, mediaIDs: mediaIDs)
|
||||
}
|
||||
}
|
||||
}
|
||||
166
qStatus/Services/WordPressClient.swift
Normal file
166
qStatus/Services/WordPressClient.swift
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import Foundation
|
||||
|
||||
struct WordPressClient: PostingService {
|
||||
let account: Account
|
||||
private let token: String
|
||||
private let baseURL: String
|
||||
|
||||
init(account: Account, token: String) {
|
||||
self.account = account
|
||||
self.token = token // format: "username:application_password"
|
||||
self.baseURL = account.instanceURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
}
|
||||
|
||||
private var authHeader: String {
|
||||
"Basic \(Data(token.utf8).base64EncodedString())"
|
||||
}
|
||||
|
||||
func uploadMedia(imageData: Data, filename: String, altText: String?) async throws -> String {
|
||||
let url = URL(string: "\(baseURL)/wp-json/wp/v2/media")!
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue(authHeader, forHTTPHeaderField: "Authorization")
|
||||
request.setValue(mimeTypeFor(filename), forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("attachment; filename=\"\(filename)\"", forHTTPHeaderField: "Content-Disposition")
|
||||
request.httpBody = imageData
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
|
||||
guard (200...201).contains(statusCode) else {
|
||||
let body = String(data: data, encoding: .utf8) ?? "Unknown error"
|
||||
throw PostingError(message: "WordPress media upload failed (\(statusCode)): \(body)")
|
||||
}
|
||||
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let mediaID = json["id"] as? Int
|
||||
else {
|
||||
throw PostingError(message: "Invalid response from WordPress media upload")
|
||||
}
|
||||
|
||||
// Update alt text if provided
|
||||
if let altText, !altText.isEmpty {
|
||||
try await updateMediaAltText(mediaID: mediaID, altText: altText)
|
||||
}
|
||||
|
||||
guard let sourceURL = json["source_url"] as? String else {
|
||||
throw PostingError(message: "No source_url in WordPress media response")
|
||||
}
|
||||
|
||||
return sourceURL
|
||||
}
|
||||
|
||||
private func updateMediaAltText(mediaID: Int, altText: String) async throws {
|
||||
let url = URL(string: "\(baseURL)/wp-json/wp/v2/media/\(mediaID)")!
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue(authHeader, forHTTPHeaderField: "Authorization")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: ["alt_text": altText])
|
||||
|
||||
let (_, response) = try await URLSession.shared.data(for: request)
|
||||
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
guard statusCode == 200 else {
|
||||
throw PostingError(message: "Failed to update alt text (\(statusCode))")
|
||||
}
|
||||
}
|
||||
|
||||
func createPost(text: String, mediaIDs: [String]) async throws -> URL {
|
||||
// mediaIDs are source_url strings for WordPress
|
||||
let url = URL(string: "\(baseURL)/wp-json/wp/v2/posts")!
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue(authHeader, forHTTPHeaderField: "Authorization")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
var content = text
|
||||
// Append images as HTML
|
||||
for imageURL in mediaIDs {
|
||||
content += "\n<img src=\"\(imageURL)\" />"
|
||||
}
|
||||
|
||||
let body: [String: Any] = [
|
||||
"content": content,
|
||||
"status": "publish",
|
||||
"format": "status",
|
||||
]
|
||||
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
|
||||
guard (200...201).contains(statusCode) else {
|
||||
let errorBody = String(data: data, encoding: .utf8) ?? "Unknown error"
|
||||
throw PostingError(message: "WordPress post failed (\(statusCode)): \(errorBody)")
|
||||
}
|
||||
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let link = json["link"] as? String,
|
||||
let postURL = URL(string: link)
|
||||
else {
|
||||
throw PostingError(message: "Invalid response from WordPress")
|
||||
}
|
||||
|
||||
return postURL
|
||||
}
|
||||
|
||||
// MARK: - Auth Discovery
|
||||
|
||||
static func discoverAuthEndpoint(siteURL: String) async throws -> String {
|
||||
let base = siteURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
let url = URL(string: "\(base)/wp-json/")!
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
|
||||
guard statusCode == 200 else {
|
||||
throw PostingError(message: "Cannot reach WordPress API at \(siteURL)")
|
||||
}
|
||||
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let auth = json["authentication"] as? [String: Any],
|
||||
let appPasswords = auth["application-passwords"] as? [String: Any],
|
||||
let endpoints = appPasswords["endpoints"] as? [String: Any],
|
||||
let authURL = endpoints["authorization"] as? String
|
||||
else {
|
||||
throw PostingError(message: "Application Passwords not available on this site. Ensure WordPress 5.6+.")
|
||||
}
|
||||
|
||||
return authURL
|
||||
}
|
||||
|
||||
static func buildAuthURL(authEndpoint: String) -> URL {
|
||||
var components = URLComponents(string: authEndpoint)!
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "app_name", value: "qStatus"),
|
||||
URLQueryItem(name: "app_id", value: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"),
|
||||
URLQueryItem(name: "success_url", value: "qstatus://wordpress-callback"),
|
||||
URLQueryItem(name: "reject_url", value: "qstatus://wordpress-rejected"),
|
||||
]
|
||||
return components.url!
|
||||
}
|
||||
|
||||
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")!
|
||||
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
|
||||
}
|
||||
|
||||
private func mimeTypeFor(_ filename: String) -> String {
|
||||
let ext = (filename as NSString).pathExtension.lowercased()
|
||||
switch ext {
|
||||
case "png": return "image/png"
|
||||
case "gif": return "image/gif"
|
||||
case "webp": return "image/webp"
|
||||
default: return "image/jpeg"
|
||||
}
|
||||
}
|
||||
}
|
||||
42
qStatus/Utilities/MultipartFormData.swift
Normal file
42
qStatus/Utilities/MultipartFormData.swift
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import Foundation
|
||||
|
||||
struct MultipartFormData {
|
||||
let boundary: String
|
||||
private var body = Data()
|
||||
|
||||
init(boundary: String = UUID().uuidString) {
|
||||
self.boundary = boundary
|
||||
}
|
||||
|
||||
var contentType: String {
|
||||
"multipart/form-data; boundary=\(boundary)"
|
||||
}
|
||||
|
||||
mutating func addField(name: String, value: String) {
|
||||
body.append("--\(boundary)\r\n")
|
||||
body.append("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n")
|
||||
body.append("\(value)\r\n")
|
||||
}
|
||||
|
||||
mutating func addFile(name: String, filename: String, mimeType: String, data: Data) {
|
||||
body.append("--\(boundary)\r\n")
|
||||
body.append("Content-Disposition: form-data; name=\"\(name)\"; filename=\"\(filename)\"\r\n")
|
||||
body.append("Content-Type: \(mimeType)\r\n\r\n")
|
||||
body.append(data)
|
||||
body.append("\r\n")
|
||||
}
|
||||
|
||||
var finalized: Data {
|
||||
var result = body
|
||||
result.append("--\(boundary)--\r\n")
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
extension Data {
|
||||
mutating func append(_ string: String) {
|
||||
if let data = string.data(using: .utf8) {
|
||||
append(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
149
qStatus/Views/ImageAttachmentArea.swift
Normal file
149
qStatus/Views/ImageAttachmentArea.swift
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct ImageAttachmentArea: View {
|
||||
@Binding var attachedImages: [ImageAttachment]
|
||||
@State private var showFilePicker = false
|
||||
@State private var isDropTargeted = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
// Thumbnail grid
|
||||
if !attachedImages.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(attachedImages) { attachment in
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Image(nsImage: attachment.image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 72, height: 72)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
|
||||
Button {
|
||||
attachedImages.removeAll { $0.id == attachment.id }
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(.white)
|
||||
.shadow(color: .black.opacity(0.5), radius: 2)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.offset(x: 4, y: -4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drop zone
|
||||
if attachedImages.count < AppState.maxImages {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.strokeBorder(
|
||||
isDropTargeted ? Color.accentColor : Color.secondary.opacity(0.3),
|
||||
style: StrokeStyle(lineWidth: 1.5, dash: [6])
|
||||
)
|
||||
.frame(height: 44)
|
||||
.background(
|
||||
(isDropTargeted ? Color.accentColor.opacity(0.08) : Color.clear)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
)
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "photo.on.rectangle.angled")
|
||||
.font(.caption)
|
||||
Text("Drop images or")
|
||||
.font(.caption)
|
||||
Button("browse") { showFilePicker = true }
|
||||
.font(.caption)
|
||||
.buttonStyle(.link)
|
||||
Text("(\(attachedImages.count)/\(AppState.maxImages))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.onDrop(of: [.image, .fileURL], isTargeted: $isDropTargeted) { providers in
|
||||
handleDrop(providers)
|
||||
}
|
||||
}
|
||||
}
|
||||
.fileImporter(
|
||||
isPresented: $showFilePicker,
|
||||
allowedContentTypes: [.png, .jpeg, .gif, .webP],
|
||||
allowsMultipleSelection: true
|
||||
) { result in
|
||||
handleFileImport(result)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDrop(_ providers: [NSItemProvider]) -> Bool {
|
||||
let remaining = AppState.maxImages - attachedImages.count
|
||||
for provider in providers.prefix(remaining) {
|
||||
if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) {
|
||||
provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier) { data, _ in
|
||||
guard let data = data as? Data,
|
||||
let url = URL(dataRepresentation: data, relativeTo: nil),
|
||||
let image = NSImage(contentsOf: url),
|
||||
let imageData = imageDataFrom(url: url)
|
||||
else { return }
|
||||
DispatchQueue.main.async {
|
||||
if attachedImages.count < AppState.maxImages {
|
||||
attachedImages.append(
|
||||
ImageAttachment(
|
||||
image: image,
|
||||
data: imageData,
|
||||
filename: url.lastPathComponent
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
|
||||
provider.loadItem(forTypeIdentifier: UTType.image.identifier) { data, _ in
|
||||
guard let data = data as? Data,
|
||||
let image = NSImage(data: data)
|
||||
else { return }
|
||||
DispatchQueue.main.async {
|
||||
if attachedImages.count < AppState.maxImages {
|
||||
attachedImages.append(
|
||||
ImageAttachment(
|
||||
image: image,
|
||||
data: data,
|
||||
filename: "image.jpg"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func handleFileImport(_ result: Result<[URL], Error>) {
|
||||
guard case .success(let urls) = result else { return }
|
||||
let remaining = AppState.maxImages - attachedImages.count
|
||||
|
||||
for url in urls.prefix(remaining) {
|
||||
guard url.startAccessingSecurityScopedResource() else { continue }
|
||||
defer { url.stopAccessingSecurityScopedResource() }
|
||||
|
||||
guard let image = NSImage(contentsOf: url),
|
||||
let data = imageDataFrom(url: url)
|
||||
else { continue }
|
||||
|
||||
attachedImages.append(
|
||||
ImageAttachment(
|
||||
image: image,
|
||||
data: data,
|
||||
filename: url.lastPathComponent
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func imageDataFrom(url: URL) -> Data? {
|
||||
try? Data(contentsOf: url)
|
||||
}
|
||||
}
|
||||
127
qStatus/Views/InputPanelView.swift
Normal file
127
qStatus/Views/InputPanelView.swift
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import SwiftUI
|
||||
|
||||
struct InputPanelView: View {
|
||||
@Bindable var appState: AppState
|
||||
let accountStore: AccountStore
|
||||
let onPost: () -> Void
|
||||
let onDismiss: () -> Void
|
||||
|
||||
@FocusState private var isTextFieldFocused: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
// Account selector
|
||||
AccountSelectorView(
|
||||
accounts: accountStore.accounts,
|
||||
selectedIDs: $appState.selectedAccountIDs
|
||||
)
|
||||
|
||||
// Text input
|
||||
TextEditor(text: $appState.inputText)
|
||||
.font(.system(size: 14))
|
||||
.frame(minHeight: 80, maxHeight: 160)
|
||||
.focused($isTextFieldFocused)
|
||||
.scrollContentBackground(.hidden)
|
||||
.padding(8)
|
||||
.background(Color(nsColor: .controlBackgroundColor))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
// Image attachments
|
||||
ImageAttachmentArea(attachedImages: Bindable(appState).attachedImages)
|
||||
|
||||
// Status message
|
||||
if let status = appState.statusMessage {
|
||||
Text(status.text)
|
||||
.font(.caption)
|
||||
.foregroundStyle(status.isError ? .red : .green)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
// Bottom bar
|
||||
HStack {
|
||||
Text("\(appState.inputText.count)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
|
||||
Spacer()
|
||||
|
||||
if appState.isSubmitting {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
Button("Post") {
|
||||
onPost()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!appState.canPost)
|
||||
.keyboardShortcut(.return, modifiers: .command)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(width: 420)
|
||||
.onAppear {
|
||||
isTextFieldFocused = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AccountSelectorView: View {
|
||||
let accounts: [Account]
|
||||
@Binding var selectedIDs: Set<UUID>
|
||||
|
||||
var body: some View {
|
||||
if accounts.isEmpty {
|
||||
Text("No accounts configured. Open Settings to add one.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.vertical, 4)
|
||||
} else {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(accounts) { account in
|
||||
AccountChip(
|
||||
account: account,
|
||||
isSelected: selectedIDs.contains(account.id)
|
||||
) {
|
||||
if selectedIDs.contains(account.id) {
|
||||
selectedIDs.remove(account.id)
|
||||
} else {
|
||||
selectedIDs.insert(account.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AccountChip: View {
|
||||
let account: Account
|
||||
let isSelected: Bool
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: account.serviceType.iconName)
|
||||
.font(.caption2)
|
||||
Text(account.displayName)
|
||||
.font(.caption)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(isSelected ? Color.accentColor.opacity(0.2) : Color.secondary.opacity(0.1))
|
||||
.foregroundStyle(isSelected ? Color.accentColor : .secondary)
|
||||
.clipShape(Capsule())
|
||||
.overlay(
|
||||
Capsule()
|
||||
.strokeBorder(isSelected ? Color.accentColor : Color.clear, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
47
qStatus/Views/MenuBarView.swift
Normal file
47
qStatus/Views/MenuBarView.swift
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import SwiftUI
|
||||
|
||||
struct MenuBarView: View {
|
||||
let accountStore: AccountStore
|
||||
let onOpenPanel: () -> Void
|
||||
let onOpenSettings: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Button("New Post...") {
|
||||
onOpenPanel()
|
||||
}
|
||||
.keyboardShortcut("n")
|
||||
|
||||
Divider()
|
||||
|
||||
if !accountStore.accounts.isEmpty {
|
||||
ForEach(accountStore.accounts) { account in
|
||||
HStack {
|
||||
Image(systemName: account.serviceType.iconName)
|
||||
.frame(width: 16)
|
||||
Text(account.displayName)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
|
||||
Divider()
|
||||
}
|
||||
|
||||
Button("Settings...") {
|
||||
onOpenSettings()
|
||||
}
|
||||
.keyboardShortcut(",")
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Quit qStatus") {
|
||||
NSApplication.shared.terminate(nil)
|
||||
}
|
||||
.keyboardShortcut("q")
|
||||
}
|
||||
.frame(width: 200)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
294
qStatus/Views/SettingsView.swift
Normal file
294
qStatus/Views/SettingsView.swift
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
let accountStore: AccountStore
|
||||
@State private var showAddAccount = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
HStack {
|
||||
Text("Accounts")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Button {
|
||||
showAddAccount = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
|
||||
Divider()
|
||||
|
||||
// Account list
|
||||
if accountStore.accounts.isEmpty {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "person.crop.circle.badge.plus")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("No accounts yet")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Add a Mastodon, WordPress, or Micro.blog account to get started.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding()
|
||||
} else {
|
||||
List {
|
||||
ForEach(accountStore.accounts) { account in
|
||||
HStack {
|
||||
Image(systemName: account.serviceType.iconName)
|
||||
.frame(width: 20)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(account.displayName)
|
||||
.font(.body)
|
||||
Text(account.serviceType.displayName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button(role: .destructive) {
|
||||
accountStore.removeAccount(account)
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
.font(.caption)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.red.opacity(0.7))
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 360, height: 300)
|
||||
.sheet(isPresented: $showAddAccount) {
|
||||
AddAccountView(accountStore: accountStore, isPresented: $showAddAccount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AddAccountView: View {
|
||||
let accountStore: AccountStore
|
||||
@Binding var isPresented: Bool
|
||||
@State private var selectedService: ServiceType = .mastodon
|
||||
@State private var instanceURL = ""
|
||||
@State private var token = ""
|
||||
@State private var username = ""
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Text("Add Account")
|
||||
.font(.headline)
|
||||
|
||||
// Service picker
|
||||
Picker("Service", selection: $selectedService) {
|
||||
ForEach(ServiceType.allCases) { service in
|
||||
Text(service.displayName).tag(service)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
// Service-specific fields
|
||||
switch selectedService {
|
||||
case .mastodon:
|
||||
mastodonFields
|
||||
case .wordpress:
|
||||
wordpressFields
|
||||
case .microblog:
|
||||
microblogFields
|
||||
}
|
||||
|
||||
if let errorMessage {
|
||||
Text(errorMessage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Button("Cancel") {
|
||||
isPresented = false
|
||||
}
|
||||
.keyboardShortcut(.cancelAction)
|
||||
|
||||
Spacer()
|
||||
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
Button("Add") {
|
||||
Task { await addAccount() }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!canAdd || isLoading)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(width: 360)
|
||||
}
|
||||
|
||||
private var canAdd: Bool {
|
||||
switch selectedService {
|
||||
case .mastodon:
|
||||
!instanceURL.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
case .wordpress:
|
||||
!instanceURL.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
&& !username.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
&& !token.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
case .microblog:
|
||||
!token.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var mastodonFields: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Instance URL")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("https://mastodon.social", text: $instanceURL)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
Text("You'll be redirected to your instance to authorize qStatus.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var wordpressFields: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Site URL")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("https://your-site.com", text: $instanceURL)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Username")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("admin", text: $username)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Application Password")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
SecureField("xxxx xxxx xxxx xxxx", text: $token)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
Text("Generate an Application Password in WordPress: Users > Profile > Application Passwords")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var microblogFields: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("App Token")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
SecureField("Paste your token", text: $token)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
Text("Get your token at micro.blog/account/apps")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
private func addAccount() async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
switch selectedService {
|
||||
case .mastodon:
|
||||
try await addMastodonAccount()
|
||||
case .wordpress:
|
||||
try await addWordPressAccount()
|
||||
case .microblog:
|
||||
try await addMicroblogAccount()
|
||||
}
|
||||
isPresented = false
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
private func addMastodonAccount() async throws {
|
||||
var url = instanceURL.trimmingCharacters(in: .whitespaces)
|
||||
if !url.hasPrefix("https://") && !url.hasPrefix("http://") {
|
||||
url = "https://\(url)"
|
||||
}
|
||||
|
||||
let (clientID, clientSecret) = try await MastodonClient.registerApp(instanceURL: url)
|
||||
|
||||
// Store client credentials temporarily
|
||||
var account = Account(
|
||||
serviceType: .mastodon,
|
||||
displayName: url.replacingOccurrences(of: "https://", with: ""),
|
||||
instanceURL: url,
|
||||
username: ""
|
||||
)
|
||||
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)
|
||||
}
|
||||
|
||||
private func addWordPressAccount() async throws {
|
||||
var url = instanceURL.trimmingCharacters(in: .whitespaces)
|
||||
if !url.hasPrefix("https://") && !url.hasPrefix("http://") {
|
||||
url = "https://\(url)"
|
||||
}
|
||||
|
||||
let user = username.trimmingCharacters(in: .whitespaces)
|
||||
let pass = token.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
let valid = try await WordPressClient.verifyCredentials(
|
||||
siteURL: url, username: user, password: pass
|
||||
)
|
||||
guard valid else {
|
||||
throw PostingError(message: "Invalid credentials. Check username and application password.")
|
||||
}
|
||||
|
||||
let credentials = "\(user):\(pass)"
|
||||
let account = Account(
|
||||
serviceType: .wordpress,
|
||||
displayName: "\(user)@\(url.replacingOccurrences(of: "https://", with: ""))",
|
||||
instanceURL: url,
|
||||
username: user
|
||||
)
|
||||
try accountStore.addAccount(account, token: credentials)
|
||||
}
|
||||
|
||||
private func addMicroblogAccount() async throws {
|
||||
let appToken = token.trimmingCharacters(in: .whitespaces)
|
||||
let blogName = try await MicroblogClient.verifyToken(appToken)
|
||||
|
||||
let account = Account(
|
||||
serviceType: .microblog,
|
||||
displayName: blogName,
|
||||
instanceURL: "https://micro.blog",
|
||||
username: blogName
|
||||
)
|
||||
try accountStore.addAccount(account, token: appToken)
|
||||
}
|
||||
}
|
||||
34
qStatus/Windows/FloatingPanel.swift
Normal file
34
qStatus/Windows/FloatingPanel.swift
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
final class FloatingPanel: NSPanel {
|
||||
|
||||
init(contentView: some View) {
|
||||
super.init(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 440, height: 380),
|
||||
styleMask: [.titled, .closable, .fullSizeContentView, .nonactivatingPanel],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
|
||||
isFloatingPanel = true
|
||||
level = .floating
|
||||
titleVisibility = .hidden
|
||||
titlebarAppearsTransparent = true
|
||||
isMovableByWindowBackground = true
|
||||
isReleasedWhenClosed = false
|
||||
animationBehavior = .utilityWindow
|
||||
hidesOnDeactivate = false
|
||||
collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
||||
backgroundColor = .windowBackgroundColor
|
||||
|
||||
self.contentView = NSHostingView(rootView: contentView)
|
||||
center()
|
||||
}
|
||||
|
||||
override var canBecomeKey: Bool { true }
|
||||
|
||||
override func cancelOperation(_ sender: Any?) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue