Commit 7cc4459477
Unsigned
Layout: unified · split
Hutch.xcodeproj/project.pbxproj +8 −8
| @@ -491,7 +491,7 @@ | ||
| 491 | 491 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 492 | 492 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 493 | 493 | CODE_SIGN_STYLE = Automatic; |
| 494 | CURRENT_PROJECT_VERSION = 14; | |
| 494 | CURRENT_PROJECT_VERSION = 15; | |
| 495 | 495 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 496 | 496 | ENABLE_PREVIEWS = YES; |
| 497 | 497 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -508,7 +508,7 @@ | ||
| 508 | 508 | "$(inherited)", |
| 509 | 509 | "@executable_path/Frameworks", |
| 510 | 510 | ); |
| 511 | MARKETING_VERSION = 2.5.2; | |
| 511 | MARKETING_VERSION = 2.6.0; | |
| 512 | 512 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 513 | 513 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 514 | 514 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -528,7 +528,7 @@ | ||
| 528 | 528 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 529 | 529 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 530 | 530 | CODE_SIGN_STYLE = Automatic; |
| 531 | CURRENT_PROJECT_VERSION = 14; | |
| 531 | CURRENT_PROJECT_VERSION = 15; | |
| 532 | 532 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 533 | 533 | ENABLE_PREVIEWS = YES; |
| 534 | 534 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -545,7 +545,7 @@ | ||
| 545 | 545 | "$(inherited)", |
| 546 | 546 | "@executable_path/Frameworks", |
| 547 | 547 | ); |
| 548 | MARKETING_VERSION = 2.5.2; | |
| 548 | MARKETING_VERSION = 2.6.0; | |
| 549 | 549 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 550 | 550 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 551 | 551 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -608,7 +608,7 @@ | ||
| 608 | 608 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 609 | 609 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 610 | 610 | CODE_SIGN_STYLE = Automatic; |
| 611 | CURRENT_PROJECT_VERSION = 14; | |
| 611 | CURRENT_PROJECT_VERSION = 15; | |
| 612 | 612 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 613 | 613 | GENERATE_INFOPLIST_FILE = NO; |
| 614 | 614 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -618,7 +618,7 @@ | ||
| 618 | 618 | "@executable_path/Frameworks", |
| 619 | 619 | "@executable_path/../../Frameworks", |
| 620 | 620 | ); |
| 621 | MARKETING_VERSION = 2.5.2; | |
| 621 | MARKETING_VERSION = 2.6.0; | |
| 622 | 622 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 623 | 623 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 624 | 624 | SKIP_INSTALL = YES; |
| @@ -637,7 +637,7 @@ | ||
| 637 | 637 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 638 | 638 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 639 | 639 | CODE_SIGN_STYLE = Automatic; |
| 640 | CURRENT_PROJECT_VERSION = 14; | |
| 640 | CURRENT_PROJECT_VERSION = 15; | |
| 641 | 641 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 642 | 642 | GENERATE_INFOPLIST_FILE = NO; |
| 643 | 643 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -647,7 +647,7 @@ | ||
| 647 | 647 | "@executable_path/Frameworks", |
| 648 | 648 | "@executable_path/../../Frameworks", |
| 649 | 649 | ); |
| 650 | MARKETING_VERSION = 2.5.2; | |
| 650 | MARKETING_VERSION = 2.6.0; | |
| 651 | 651 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 652 | 652 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 653 | 653 | SKIP_INSTALL = YES; |
Hutch/App/AppState.swift +93 −7
| @@ -39,6 +39,14 @@ final class AppState { | ||
| 39 | 39 | authPhase == .authenticated && currentUser != nil |
| 40 | 40 | } |
| 41 | 41 | |
| 42 | // MARK: - Multi-account | |
| 43 | ||
| 44 | /// All stored accounts. Loaded from Keychain; kept in sync on add/remove/switch. | |
| 45 | private(set) var accounts: [AccountEntry] = [] | |
| 46 | ||
| 47 | /// The ID of the account currently in use. Persisted in UserDefaults. | |
| 48 | private(set) var activeAccountID: String = "" | |
| 49 | ||
| 42 | 50 | var selectedTab: Tab = .home |
| 43 | 51 | |
| 44 | 52 | // MARK: - Current user (populated after successful validation) |
| @@ -68,18 +76,40 @@ final class AppState { | ||
| 68 | 76 | /// Called once at app launch. If a token exists in Keychain, validates it |
| 69 | 77 | /// silently. On failure, clears the token and falls through to unauthenticated. |
| 70 | 78 | func validateOnLaunch() async { |
| 71 | guard client.hasToken else { | |
| 79 | var storedAccounts = KeychainHelper.loadAccounts() | |
| 80 | ||
| 81 | if storedAccounts.isEmpty, let legacyToken = KeychainHelper.loadToken() { | |
| 82 | client.setToken(legacyToken) | |
| 83 | if let user = try? await fetchMe() { | |
| 84 | let entry = AccountEntry(id: UUID().uuidString, username: user.username, token: legacyToken) | |
| 85 | storedAccounts = [entry] | |
| 86 | try? KeychainHelper.saveAccounts(storedAccounts) | |
| 87 | try? KeychainHelper.deleteToken() | |
| 88 | } else { | |
| 89 | try? KeychainHelper.deleteToken() | |
| 90 | client.setToken(nil) | |
| 91 | authPhase = .unauthenticated | |
| 92 | return | |
| 93 | } | |
| 94 | } | |
| 95 | ||
| 96 | guard !storedAccounts.isEmpty else { | |
| 72 | 97 | authPhase = .unauthenticated |
| 73 | 98 | return |
| 74 | 99 | } |
| 75 | 100 | |
| 101 | let savedID = UserDefaults.standard.string(forKey: AppStorageKeys.activeAccountID) ?? "" | |
| 102 | let target = storedAccounts.first(where: { $0.id == savedID }) ?? storedAccounts[0] | |
| 103 | ||
| 104 | client.setToken(target.token) | |
| 76 | 105 | do { |
| 77 | 106 | let user = try await fetchMe() |
| 107 | accounts = storedAccounts | |
| 108 | activeAccountID = target.id | |
| 78 | 109 | currentUser = user |
| 79 | 110 | authPhase = .authenticated |
| 80 | 111 | await refreshNeedsAttentionSnapshot() |
| 81 | 112 | } catch { |
| 82 | try? KeychainHelper.deleteToken() | |
| 83 | 113 | client.setToken(nil) |
| 84 | 114 | currentUser = nil |
| 85 | 115 | authPhase = .unauthenticated |
| @@ -92,22 +122,71 @@ final class AppState { | ||
| 92 | 122 | /// Validate a new token by querying meta.sr.ht, then persist it. |
| 93 | 123 | /// Throws on network/GraphQL errors so the caller can display the message. |
| 94 | 124 | func connect(with token: String) async throws { |
| 95 | // Temporarily set the token so the client can use it for the request. | |
| 96 | 125 | client.setToken(token) |
| 97 | ||
| 98 | 126 | do { |
| 99 | 127 | let user = try await fetchMe() |
| 100 | try KeychainHelper.saveToken(token) | |
| 128 | let entry = AccountEntry(id: UUID().uuidString, username: user.username, token: token) | |
| 129 | accounts.append(entry) | |
| 130 | activeAccountID = entry.id | |
| 131 | UserDefaults.standard.set(entry.id, forKey: AppStorageKeys.activeAccountID) | |
| 132 | try KeychainHelper.saveAccounts(accounts) | |
| 101 | 133 | currentUser = user |
| 102 | 134 | authPhase = .authenticated |
| 103 | 135 | await refreshNeedsAttentionSnapshot() |
| 104 | 136 | } catch { |
| 105 | // Roll back — don't leave an invalid token in the client. | |
| 106 | 137 | client.setToken(nil) |
| 107 | 138 | throw error |
| 108 | 139 | } |
| 109 | 140 | } |
| 110 | 141 | |
| 142 | /// Validate a new token, add it as an account, and switch to it immediately. | |
| 143 | func addAccount(token: String) async throws { | |
| 144 | let tempClient = SRHTClient(token: token) | |
| 145 | let user = try await fetchMe(using: tempClient) | |
| 146 | let entry = AccountEntry(id: UUID().uuidString, username: user.username, token: token) | |
| 147 | accounts.append(entry) | |
| 148 | try KeychainHelper.saveAccounts(accounts) | |
| 149 | try await switchAccount(to: entry.id) | |
| 150 | } | |
| 151 | ||
| 152 | /// Switch the active account and fully refresh the app. | |
| 153 | func switchAccount(to id: String) async throws { | |
| 154 | guard let entry = accounts.first(where: { $0.id == id }) else { return } | |
| 155 | ||
| 156 | client.responseCache.clear() | |
| 157 | currentUser = nil | |
| 158 | pendingDeepLink = nil | |
| 159 | pendingTabNavigation = nil | |
| 160 | deepLinkError = nil | |
| 161 | selectedTab = .home | |
| 162 | ||
| 163 | authPhase = .unauthenticated | |
| 164 | ||
| 165 | client.setToken(entry.token) | |
| 166 | activeAccountID = entry.id | |
| 167 | UserDefaults.standard.set(entry.id, forKey: AppStorageKeys.activeAccountID) | |
| 168 | ||
| 169 | let user = try await fetchMe() | |
| 170 | currentUser = user | |
| 171 | authPhase = .authenticated | |
| 172 | await refreshNeedsAttentionSnapshot() | |
| 173 | } | |
| 174 | ||
| 175 | /// Remove a stored account. Switches to another account if the removed account | |
| 176 | /// was active; signs out fully if it was the last account. | |
| 177 | func removeAccount(id: String) async { | |
| 178 | accounts.removeAll { $0.id == id } | |
| 179 | try? KeychainHelper.saveAccounts(accounts) | |
| 180 | ||
| 181 | guard id == activeAccountID else { return } | |
| 182 | ||
| 183 | if let next = accounts.first { | |
| 184 | try? await switchAccount(to: next.id) | |
| 185 | } else { | |
| 186 | await signOut() | |
| 187 | } | |
| 188 | } | |
| 189 | ||
| 111 | 190 | func signOut() async { |
| 112 | 191 | clearSessionState() |
| 113 | 192 | URLCache.shared.removeAllCachedResponses() |
| @@ -215,7 +294,11 @@ final class AppState { | ||
| 215 | 294 | } |
| 216 | 295 | |
| 217 | 296 | private func fetchMe() async throws -> User { |
| 218 | let result = try await client.execute( | |
| 297 | try await fetchMe(using: client) | |
| 298 | } | |
| 299 | ||
| 300 | private func fetchMe(using srhtClient: SRHTClient) async throws -> User { | |
| 301 | let result = try await srhtClient.execute( | |
| 219 | 302 | service: .meta, |
| 220 | 303 | query: Self.meQuery, |
| 221 | 304 | responseType: MeResponse.self |
| @@ -268,6 +351,9 @@ final class AppState { | ||
| 268 | 351 | try? KeychainHelper.deleteAll() |
| 269 | 352 | client.setToken(nil) |
| 270 | 353 | client.responseCache.clear() |
| 354 | accounts = [] | |
| 355 | activeAccountID = "" | |
| 356 | UserDefaults.standard.removeObject(forKey: AppStorageKeys.activeAccountID) | |
| 271 | 357 | currentUser = nil |
| 272 | 358 | pendingDeepLink = nil |
| 273 | 359 | pendingTabNavigation = nil |
Hutch/App/AppStorageKeys.swift +1
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | // Shared UserDefaults key constants |
| 2 | 2 | enum AppStorageKeys { |
| 3 | 3 | static let swipeActionsEnabled = "swipeActionsEnabled" |
| 4 | static let activeAccountID = "activeAccountID" | |
| 4 | 5 | } |
Hutch/Extensions/KeychainHelper.swift +40
| @@ -5,6 +5,7 @@ enum KeychainHelper: Sendable { | ||
| 5 | 5 | |
| 6 | 6 | private static let service = "net.cleberg.Hutch" |
| 7 | 7 | private static let tokenAccount = "srht-access-token" |
| 8 | private static let accountsAccount = "srht-accounts" | |
| 8 | 9 | |
| 9 | 10 | // MARK: - Save |
| 10 | 11 | |
| @@ -57,6 +58,45 @@ enum KeychainHelper: Sendable { | ||
| 57 | 58 | return token |
| 58 | 59 | } |
| 59 | 60 | |
| 61 | // MARK: - Multi-account list | |
| 62 | ||
| 63 | static func saveAccounts(_ accounts: [AccountEntry]) throws { | |
| 64 | let data = try JSONEncoder().encode(accounts) | |
| 65 | ||
| 66 | let deleteQuery: [String: Any] = [ | |
| 67 | kSecClass as String: kSecClassGenericPassword, | |
| 68 | kSecAttrService as String: service, | |
| 69 | kSecAttrAccount as String: accountsAccount | |
| 70 | ] | |
| 71 | SecItemDelete(deleteQuery as CFDictionary) | |
| 72 | ||
| 73 | let addQuery: [String: Any] = [ | |
| 74 | kSecClass as String: kSecClassGenericPassword, | |
| 75 | kSecAttrService as String: service, | |
| 76 | kSecAttrAccount as String: accountsAccount, | |
| 77 | kSecValueData as String: data, | |
| 78 | kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly | |
| 79 | ] | |
| 80 | let status = SecItemAdd(addQuery as CFDictionary, nil) | |
| 81 | guard status == errSecSuccess else { | |
| 82 | throw KeychainError.saveFailed(status) | |
| 83 | } | |
| 84 | } | |
| 85 | ||
| 86 | static func loadAccounts() -> [AccountEntry] { | |
| 87 | let query: [String: Any] = [ | |
| 88 | kSecClass as String: kSecClassGenericPassword, | |
| 89 | kSecAttrService as String: service, | |
| 90 | kSecAttrAccount as String: accountsAccount, | |
| 91 | kSecReturnData as String: true, | |
| 92 | kSecMatchLimit as String: kSecMatchLimitOne | |
| 93 | ] | |
| 94 | var result: AnyObject? | |
| 95 | guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, | |
| 96 | let data = result as? Data else { return [] } | |
| 97 | return (try? JSONDecoder().decode([AccountEntry].self, from: data)) ?? [] | |
| 98 | } | |
| 99 | ||
| 60 | 100 | // MARK: - Delete |
| 61 | 101 | |
| 62 | 102 | static func deleteToken() throws { |
Hutch/Models/AccountEntry.swift added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// A stored sr.ht account (token + resolved username). | |
| 4 | struct AccountEntry: Codable, Identifiable, Equatable, Sendable { | |
| 5 | let id: String | |
| 6 | var username: String | |
| 7 | var token: String | |
| 8 | } | |
Hutch/Views/More/AccountSwitcherView.swift added +92
| @@ -0,0 +1,92 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | struct AccountSwitcherView: View { | |
| 4 | @Environment(AppState.self) private var appState | |
| 5 | @Environment(\.dismiss) private var dismiss | |
| 6 | ||
| 7 | @State private var showAddAccount = false | |
| 8 | @State private var isSwitching = false | |
| 9 | @State private var switchError: String? | |
| 10 | ||
| 11 | var body: some View { | |
| 12 | NavigationStack { | |
| 13 | List { | |
| 14 | Section { | |
| 15 | ForEach(appState.accounts) { account in | |
| 16 | Button { | |
| 17 | guard account.id != appState.activeAccountID else { return } | |
| 18 | switchTo(account) | |
| 19 | } label: { | |
| 20 | HStack { | |
| 21 | Text(account.username) | |
| 22 | .foregroundStyle(.primary) | |
| 23 | Spacer() | |
| 24 | if account.id == appState.activeAccountID { | |
| 25 | Image(systemName: "checkmark") | |
| 26 | .foregroundStyle(.tint) | |
| 27 | } | |
| 28 | } | |
| 29 | } | |
| 30 | .disabled(isSwitching) | |
| 31 | } | |
| 32 | .onDelete { indexSet in | |
| 33 | for index in indexSet { | |
| 34 | let account = appState.accounts[index] | |
| 35 | Task { await appState.removeAccount(id: account.id) } | |
| 36 | } | |
| 37 | } | |
| 38 | } | |
| 39 | ||
| 40 | Section { | |
| 41 | Button { | |
| 42 | showAddAccount = true | |
| 43 | } label: { | |
| 44 | Label("Add Account", systemImage: "plus.circle") | |
| 45 | } | |
| 46 | .disabled(isSwitching) | |
| 47 | } | |
| 48 | } | |
| 49 | .navigationTitle("Accounts") | |
| 50 | .navigationBarTitleDisplayMode(.inline) | |
| 51 | .toolbar { | |
| 52 | ToolbarItem(placement: .confirmationAction) { | |
| 53 | Button("Done") { dismiss() } | |
| 54 | } | |
| 55 | } | |
| 56 | .overlay { | |
| 57 | if isSwitching { | |
| 58 | ZStack { | |
| 59 | Color.black.opacity(0.25).ignoresSafeArea() | |
| 60 | ProgressView("Switching…") | |
| 61 | .padding() | |
| 62 | .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) | |
| 63 | } | |
| 64 | } | |
| 65 | } | |
| 66 | .alert("Switch Failed", isPresented: Binding( | |
| 67 | get: { switchError != nil }, | |
| 68 | set: { if !$0 { switchError = nil } } | |
| 69 | )) { | |
| 70 | Button("OK") { switchError = nil } | |
| 71 | } message: { | |
| 72 | Text(switchError ?? "") | |
| 73 | } | |
| 74 | .sheet(isPresented: $showAddAccount) { | |
| 75 | AddAccountView() | |
| 76 | } | |
| 77 | } | |
| 78 | } | |
| 79 | ||
| 80 | private func switchTo(_ account: AccountEntry) { | |
| 81 | isSwitching = true | |
| 82 | Task { | |
| 83 | do { | |
| 84 | try await appState.switchAccount(to: account.id) | |
| 85 | dismiss() | |
| 86 | } catch { | |
| 87 | switchError = error.localizedDescription | |
| 88 | } | |
| 89 | isSwitching = false | |
| 90 | } | |
| 91 | } | |
| 92 | } | |
Hutch/Views/More/AddAccountView.swift added +59
| @@ -0,0 +1,59 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | struct AddAccountView: View { | |
| 4 | @Environment(AppState.self) private var appState | |
| 5 | @Environment(\.dismiss) private var dismiss | |
| 6 | ||
| 7 | @State private var token = "" | |
| 8 | @State private var isConnecting = false | |
| 9 | @State private var errorMessage: String? | |
| 10 | ||
| 11 | var body: some View { | |
| 12 | NavigationStack { | |
| 13 | Form { | |
| 14 | Section { | |
| 15 | SecureField("Personal Access Token", text: $token) | |
| 16 | .autocorrectionDisabled() | |
| 17 | .textInputAutocapitalization(.never) | |
| 18 | } footer: { | |
| 19 | Text("Generate a token at meta.sr.ht → OAuth2 clients.") | |
| 20 | } | |
| 21 | ||
| 22 | if let errorMessage { | |
| 23 | Section { | |
| 24 | Text(errorMessage) | |
| 25 | .foregroundStyle(.red) | |
| 26 | } | |
| 27 | } | |
| 28 | } | |
| 29 | .navigationTitle("Add Account") | |
| 30 | .navigationBarTitleDisplayMode(.inline) | |
| 31 | .toolbar { | |
| 32 | ToolbarItem(placement: .cancellationAction) { | |
| 33 | Button("Cancel") { dismiss() } | |
| 34 | .disabled(isConnecting) | |
| 35 | } | |
| 36 | ToolbarItem(placement: .confirmationAction) { | |
| 37 | Button("Connect") { connect() } | |
| 38 | .disabled(token.trimmingCharacters(in: .whitespaces).isEmpty || isConnecting) | |
| 39 | } | |
| 40 | } | |
| 41 | .interactiveDismissDisabled(isConnecting) | |
| 42 | } | |
| 43 | } | |
| 44 | ||
| 45 | private func connect() { | |
| 46 | isConnecting = true | |
| 47 | errorMessage = nil | |
| 48 | let trimmed = token.trimmingCharacters(in: .whitespaces) | |
| 49 | Task { | |
| 50 | do { | |
| 51 | try await appState.addAccount(token: trimmed) | |
| 52 | dismiss() | |
| 53 | } catch { | |
| 54 | errorMessage = error.localizedDescription | |
| 55 | } | |
| 56 | isConnecting = false | |
| 57 | } | |
| 58 | } | |
| 59 | } | |
Hutch/Views/More/MoreView.swift +16
| @@ -1,12 +1,16 @@ | ||
| 1 | 1 | import SwiftUI |
| 2 | 2 | |
| 3 | 3 | struct MoreView: View { |
| 4 | @Environment(AppState.self) private var appState | |
| 5 | ||
| 4 | 6 | private let unsupportedLinks: [(title: String, url: URL)] = [ |
| 5 | 7 | ("chat.sr.ht", URL(string: "https://chat.sr.ht")!), |
| 6 | 8 | ("man.sr.ht", URL(string: "https://man.sr.ht")!), |
| 7 | 9 | ("srht.site", URL(string: "https://srht.site")!) |
| 8 | 10 | ] |
| 9 | 11 | |
| 12 | @State private var showAccountSwitcher = false | |
| 13 | ||
| 10 | 14 | var body: some View { |
| 11 | 15 | List { |
| 12 | 16 | Section { |
| @@ -36,5 +40,17 @@ struct MoreView: View { | ||
| 36 | 40 | } |
| 37 | 41 | } |
| 38 | 42 | .navigationTitle("More") |
| 43 | .toolbar { | |
| 44 | ToolbarItem(placement: .topBarTrailing) { | |
| 45 | Button { | |
| 46 | showAccountSwitcher = true | |
| 47 | } label: { | |
| 48 | Image(systemName: "person.crop.circle") | |
| 49 | } | |
| 50 | } | |
| 51 | } | |
| 52 | .sheet(isPresented: $showAccountSwitcher) { | |
| 53 | AccountSwitcherView() | |
| 54 | } | |
| 39 | 55 | } |
| 40 | 56 | } |