qstatus/qStatus/Views/SettingsView.swift
Paweł Orzech c27437b33c
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.
2026-02-27 23:40:51 +01:00

294 lines
9.9 KiB
Swift

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)
}
}