summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Hutch.xcodeproj/project.pbxproj16
-rw-r--r--Hutch/App/AppState.swift100
-rw-r--r--Hutch/App/AppStorageKeys.swift1
-rw-r--r--Hutch/Extensions/KeychainHelper.swift40
-rw-r--r--Hutch/Models/AccountEntry.swift8
-rw-r--r--Hutch/Views/More/AccountSwitcherView.swift92
-rw-r--r--Hutch/Views/More/AddAccountView.swift59
-rw-r--r--Hutch/Views/More/MoreView.swift16
8 files changed, 317 insertions, 15 deletions
diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index 7251561..1edd877 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -491,7 +491,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 14;
+ CURRENT_PROJECT_VERSION = 15;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -508,7 +508,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 2.5.2;
+ MARKETING_VERSION = 2.6.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -528,7 +528,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 14;
+ CURRENT_PROJECT_VERSION = 15;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -545,7 +545,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 2.5.2;
+ MARKETING_VERSION = 2.6.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -608,7 +608,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 14;
+ CURRENT_PROJECT_VERSION = 15;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -618,7 +618,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 2.5.2;
+ MARKETING_VERSION = 2.6.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -637,7 +637,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 14;
+ CURRENT_PROJECT_VERSION = 15;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -647,7 +647,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 2.5.2;
+ MARKETING_VERSION = 2.6.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
diff --git a/Hutch/App/AppState.swift b/Hutch/App/AppState.swift
index 9788c85..8613dc8 100644
--- a/Hutch/App/AppState.swift
+++ b/Hutch/App/AppState.swift
@@ -39,6 +39,14 @@ final class AppState {
authPhase == .authenticated && currentUser != nil
}
+ // MARK: - Multi-account
+
+ /// All stored accounts. Loaded from Keychain; kept in sync on add/remove/switch.
+ private(set) var accounts: [AccountEntry] = []
+
+ /// The ID of the account currently in use. Persisted in UserDefaults.
+ private(set) var activeAccountID: String = ""
+
var selectedTab: Tab = .home
// MARK: - Current user (populated after successful validation)
@@ -68,18 +76,40 @@ final class AppState {
/// Called once at app launch. If a token exists in Keychain, validates it
/// silently. On failure, clears the token and falls through to unauthenticated.
func validateOnLaunch() async {
- guard client.hasToken else {
+ var storedAccounts = KeychainHelper.loadAccounts()
+
+ if storedAccounts.isEmpty, let legacyToken = KeychainHelper.loadToken() {
+ client.setToken(legacyToken)
+ if let user = try? await fetchMe() {
+ let entry = AccountEntry(id: UUID().uuidString, username: user.username, token: legacyToken)
+ storedAccounts = [entry]
+ try? KeychainHelper.saveAccounts(storedAccounts)
+ try? KeychainHelper.deleteToken()
+ } else {
+ try? KeychainHelper.deleteToken()
+ client.setToken(nil)
+ authPhase = .unauthenticated
+ return
+ }
+ }
+
+ guard !storedAccounts.isEmpty else {
authPhase = .unauthenticated
return
}
+ let savedID = UserDefaults.standard.string(forKey: AppStorageKeys.activeAccountID) ?? ""
+ let target = storedAccounts.first(where: { $0.id == savedID }) ?? storedAccounts[0]
+
+ client.setToken(target.token)
do {
let user = try await fetchMe()
+ accounts = storedAccounts
+ activeAccountID = target.id
currentUser = user
authPhase = .authenticated
await refreshNeedsAttentionSnapshot()
} catch {
- try? KeychainHelper.deleteToken()
client.setToken(nil)
currentUser = nil
authPhase = .unauthenticated
@@ -92,22 +122,71 @@ final class AppState {
/// Validate a new token by querying meta.sr.ht, then persist it.
/// Throws on network/GraphQL errors so the caller can display the message.
func connect(with token: String) async throws {
- // Temporarily set the token so the client can use it for the request.
client.setToken(token)
-
do {
let user = try await fetchMe()
- try KeychainHelper.saveToken(token)
+ let entry = AccountEntry(id: UUID().uuidString, username: user.username, token: token)
+ accounts.append(entry)
+ activeAccountID = entry.id
+ UserDefaults.standard.set(entry.id, forKey: AppStorageKeys.activeAccountID)
+ try KeychainHelper.saveAccounts(accounts)
currentUser = user
authPhase = .authenticated
await refreshNeedsAttentionSnapshot()
} catch {
- // Roll back — don't leave an invalid token in the client.
client.setToken(nil)
throw error
}
}
+ /// Validate a new token, add it as an account, and switch to it immediately.
+ func addAccount(token: String) async throws {
+ let tempClient = SRHTClient(token: token)
+ let user = try await fetchMe(using: tempClient)
+ let entry = AccountEntry(id: UUID().uuidString, username: user.username, token: token)
+ accounts.append(entry)
+ try KeychainHelper.saveAccounts(accounts)
+ try await switchAccount(to: entry.id)
+ }
+
+ /// Switch the active account and fully refresh the app.
+ func switchAccount(to id: String) async throws {
+ guard let entry = accounts.first(where: { $0.id == id }) else { return }
+
+ client.responseCache.clear()
+ currentUser = nil
+ pendingDeepLink = nil
+ pendingTabNavigation = nil
+ deepLinkError = nil
+ selectedTab = .home
+
+ authPhase = .unauthenticated
+
+ client.setToken(entry.token)
+ activeAccountID = entry.id
+ UserDefaults.standard.set(entry.id, forKey: AppStorageKeys.activeAccountID)
+
+ let user = try await fetchMe()
+ currentUser = user
+ authPhase = .authenticated
+ await refreshNeedsAttentionSnapshot()
+ }
+
+ /// Remove a stored account. Switches to another account if the removed account
+ /// was active; signs out fully if it was the last account.
+ func removeAccount(id: String) async {
+ accounts.removeAll { $0.id == id }
+ try? KeychainHelper.saveAccounts(accounts)
+
+ guard id == activeAccountID else { return }
+
+ if let next = accounts.first {
+ try? await switchAccount(to: next.id)
+ } else {
+ await signOut()
+ }
+ }
+
func signOut() async {
clearSessionState()
URLCache.shared.removeAllCachedResponses()
@@ -215,7 +294,11 @@ final class AppState {
}
private func fetchMe() async throws -> User {
- let result = try await client.execute(
+ try await fetchMe(using: client)
+ }
+
+ private func fetchMe(using srhtClient: SRHTClient) async throws -> User {
+ let result = try await srhtClient.execute(
service: .meta,
query: Self.meQuery,
responseType: MeResponse.self
@@ -268,6 +351,9 @@ final class AppState {
try? KeychainHelper.deleteAll()
client.setToken(nil)
client.responseCache.clear()
+ accounts = []
+ activeAccountID = ""
+ UserDefaults.standard.removeObject(forKey: AppStorageKeys.activeAccountID)
currentUser = nil
pendingDeepLink = nil
pendingTabNavigation = nil
diff --git a/Hutch/App/AppStorageKeys.swift b/Hutch/App/AppStorageKeys.swift
index 5c74de4..708b4ce 100644
--- a/Hutch/App/AppStorageKeys.swift
+++ b/Hutch/App/AppStorageKeys.swift
@@ -1,4 +1,5 @@
// Shared UserDefaults key constants
enum AppStorageKeys {
static let swipeActionsEnabled = "swipeActionsEnabled"
+ static let activeAccountID = "activeAccountID"
}
diff --git a/Hutch/Extensions/KeychainHelper.swift b/Hutch/Extensions/KeychainHelper.swift
index 01f8e5a..d53c80e 100644
--- a/Hutch/Extensions/KeychainHelper.swift
+++ b/Hutch/Extensions/KeychainHelper.swift
@@ -5,6 +5,7 @@ enum KeychainHelper: Sendable {
private static let service = "net.cleberg.Hutch"
private static let tokenAccount = "srht-access-token"
+ private static let accountsAccount = "srht-accounts"
// MARK: - Save
@@ -57,6 +58,45 @@ enum KeychainHelper: Sendable {
return token
}
+ // MARK: - Multi-account list
+
+ static func saveAccounts(_ accounts: [AccountEntry]) throws {
+ let data = try JSONEncoder().encode(accounts)
+
+ let deleteQuery: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: accountsAccount
+ ]
+ SecItemDelete(deleteQuery as CFDictionary)
+
+ let addQuery: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: accountsAccount,
+ kSecValueData as String: data,
+ kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
+ ]
+ let status = SecItemAdd(addQuery as CFDictionary, nil)
+ guard status == errSecSuccess else {
+ throw KeychainError.saveFailed(status)
+ }
+ }
+
+ static func loadAccounts() -> [AccountEntry] {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: accountsAccount,
+ kSecReturnData as String: true,
+ kSecMatchLimit as String: kSecMatchLimitOne
+ ]
+ var result: AnyObject?
+ guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
+ let data = result as? Data else { return [] }
+ return (try? JSONDecoder().decode([AccountEntry].self, from: data)) ?? []
+ }
+
// MARK: - Delete
static func deleteToken() throws {
diff --git a/Hutch/Models/AccountEntry.swift b/Hutch/Models/AccountEntry.swift
new file mode 100644
index 0000000..551cd19
--- /dev/null
+++ b/Hutch/Models/AccountEntry.swift
@@ -0,0 +1,8 @@
+import Foundation
+
+/// A stored sr.ht account (token + resolved username).
+struct AccountEntry: Codable, Identifiable, Equatable, Sendable {
+ let id: String
+ var username: String
+ var token: String
+}
diff --git a/Hutch/Views/More/AccountSwitcherView.swift b/Hutch/Views/More/AccountSwitcherView.swift
new file mode 100644
index 0000000..6fe3c28
--- /dev/null
+++ b/Hutch/Views/More/AccountSwitcherView.swift
@@ -0,0 +1,92 @@
+import SwiftUI
+
+struct AccountSwitcherView: View {
+ @Environment(AppState.self) private var appState
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var showAddAccount = false
+ @State private var isSwitching = false
+ @State private var switchError: String?
+
+ var body: some View {
+ NavigationStack {
+ List {
+ Section {
+ ForEach(appState.accounts) { account in
+ Button {
+ guard account.id != appState.activeAccountID else { return }
+ switchTo(account)
+ } label: {
+ HStack {
+ Text(account.username)
+ .foregroundStyle(.primary)
+ Spacer()
+ if account.id == appState.activeAccountID {
+ Image(systemName: "checkmark")
+ .foregroundStyle(.tint)
+ }
+ }
+ }
+ .disabled(isSwitching)
+ }
+ .onDelete { indexSet in
+ for index in indexSet {
+ let account = appState.accounts[index]
+ Task { await appState.removeAccount(id: account.id) }
+ }
+ }
+ }
+
+ Section {
+ Button {
+ showAddAccount = true
+ } label: {
+ Label("Add Account", systemImage: "plus.circle")
+ }
+ .disabled(isSwitching)
+ }
+ }
+ .navigationTitle("Accounts")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Done") { dismiss() }
+ }
+ }
+ .overlay {
+ if isSwitching {
+ ZStack {
+ Color.black.opacity(0.25).ignoresSafeArea()
+ ProgressView("Switching…")
+ .padding()
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
+ }
+ }
+ }
+ .alert("Switch Failed", isPresented: Binding(
+ get: { switchError != nil },
+ set: { if !$0 { switchError = nil } }
+ )) {
+ Button("OK") { switchError = nil }
+ } message: {
+ Text(switchError ?? "")
+ }
+ .sheet(isPresented: $showAddAccount) {
+ AddAccountView()
+ }
+ }
+ }
+
+ private func switchTo(_ account: AccountEntry) {
+ isSwitching = true
+ Task {
+ do {
+ try await appState.switchAccount(to: account.id)
+ dismiss()
+ } catch {
+ switchError = error.localizedDescription
+ }
+ isSwitching = false
+ }
+ }
+}
diff --git a/Hutch/Views/More/AddAccountView.swift b/Hutch/Views/More/AddAccountView.swift
new file mode 100644
index 0000000..cfaa2b2
--- /dev/null
+++ b/Hutch/Views/More/AddAccountView.swift
@@ -0,0 +1,59 @@
+import SwiftUI
+
+struct AddAccountView: View {
+ @Environment(AppState.self) private var appState
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var token = ""
+ @State private var isConnecting = false
+ @State private var errorMessage: String?
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section {
+ SecureField("Personal Access Token", text: $token)
+ .autocorrectionDisabled()
+ .textInputAutocapitalization(.never)
+ } footer: {
+ Text("Generate a token at meta.sr.ht → OAuth2 clients.")
+ }
+
+ if let errorMessage {
+ Section {
+ Text(errorMessage)
+ .foregroundStyle(.red)
+ }
+ }
+ }
+ .navigationTitle("Add Account")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { dismiss() }
+ .disabled(isConnecting)
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Connect") { connect() }
+ .disabled(token.trimmingCharacters(in: .whitespaces).isEmpty || isConnecting)
+ }
+ }
+ .interactiveDismissDisabled(isConnecting)
+ }
+ }
+
+ private func connect() {
+ isConnecting = true
+ errorMessage = nil
+ let trimmed = token.trimmingCharacters(in: .whitespaces)
+ Task {
+ do {
+ try await appState.addAccount(token: trimmed)
+ dismiss()
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ isConnecting = false
+ }
+ }
+}
diff --git a/Hutch/Views/More/MoreView.swift b/Hutch/Views/More/MoreView.swift
index b7c5576..c981432 100644
--- a/Hutch/Views/More/MoreView.swift
+++ b/Hutch/Views/More/MoreView.swift
@@ -1,12 +1,16 @@
import SwiftUI
struct MoreView: View {
+ @Environment(AppState.self) private var appState
+
private let unsupportedLinks: [(title: String, url: URL)] = [
("chat.sr.ht", URL(string: "https://chat.sr.ht")!),
("man.sr.ht", URL(string: "https://man.sr.ht")!),
("srht.site", URL(string: "https://srht.site")!)
]
+ @State private var showAccountSwitcher = false
+
var body: some View {
List {
Section {
@@ -36,5 +40,17 @@ struct MoreView: View {
}
}
.navigationTitle("More")
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button {
+ showAccountSwitcher = true
+ } label: {
+ Image(systemName: "person.crop.circle")
+ }
+ }
+ }
+ .sheet(isPresented: $showAccountSwitcher) {
+ AccountSwitcherView()
+ }
}
}