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