diff --git a/Shared/Account/AccountView.swift b/Shared/Account/AccountView.swift index fe22f19..0b54f0e 100644 --- a/Shared/Account/AccountView.swift +++ b/Shared/Account/AccountView.swift @@ -1,40 +1,40 @@ import SwiftUI struct AccountView: View { @EnvironmentObject var model: WriteFreelyModel @EnvironmentObject var errorHandling: ErrorHandling var body: some View { if model.account.isLoggedIn { HStack { Spacer() AccountLogoutView() .withErrorHandling() Spacer() } .padding() } else { AccountLoginView() .withErrorHandling() .padding(.top) } EmptyView() .onChange(of: model.hasError) { value in if value { if let error = model.currentError { self.errorHandling.handle(error: error) } else { - self.errorHandling.handle(error: AppError.genericError("")) + self.errorHandling.handle(error: AppError.genericError()) } model.hasError = false } } } } struct AccountLogin_Previews: PreviewProvider { static var previews: some View { AccountView() .environmentObject(WriteFreelyModel()) } } diff --git a/Shared/ErrorHandling/ErrorConstants.swift b/Shared/ErrorHandling/ErrorConstants.swift index 676f299..b412fdb 100644 --- a/Shared/ErrorHandling/ErrorConstants.swift +++ b/Shared/ErrorHandling/ErrorConstants.swift @@ -1,169 +1,169 @@ import Foundation // MARK: - Network Errors enum NetworkError: Error { case noConnectionError } extension NetworkError: LocalizedError { public var errorDescription: String? { switch self { case .noConnectionError: return NSLocalizedString( "There is no internet connection at the moment. Please reconnect or try again later.", comment: "" ) } } } // MARK: - Keychain Errors enum KeychainError: Error { case couldNotStoreAccessToken case couldNotPurgeAccessToken case couldNotFetchAccessToken } extension KeychainError: LocalizedError { public var errorDescription: String? { switch self { case .couldNotStoreAccessToken: return NSLocalizedString("There was a problem storing your access token in the Keychain.", comment: "") case .couldNotPurgeAccessToken: return NSLocalizedString("Something went wrong purging the token from the Keychain.", comment: "") case .couldNotFetchAccessToken: return NSLocalizedString("Something went wrong fetching the token from the Keychain.", comment: "") } } } // MARK: - Account Errors enum AccountError: Error { case invalidPassword case usernameNotFound case serverNotFound case invalidServerURL case unknownLoginError case genericAuthError } extension AccountError: LocalizedError { public var errorDescription: String? { switch self { case .serverNotFound: return NSLocalizedString( "The server could not be found. Please check the information you've entered and try again.", comment: "" ) case .invalidPassword: return NSLocalizedString( "Invalid password. Please check that you've entered your password correctly and try logging in again.", comment: "" ) case .usernameNotFound: return NSLocalizedString( "Username not found. Did you use your email address by mistake?", comment: "" ) case .invalidServerURL: return NSLocalizedString( "Please enter a valid instance domain name. It should look like \"https://example.com\" or \"write.as\".", // swiftlint:disable:this line_length comment: "" ) case .genericAuthError: return NSLocalizedString("Something went wrong, please try logging in again.", comment: "") case .unknownLoginError: return NSLocalizedString("An unknown error occurred while trying to login.", comment: "") } } } // MARK: - User Defaults Errors enum UserDefaultsError: Error { case couldNotMigrateStandardDefaults } extension UserDefaultsError: LocalizedError { public var errorDescription: String? { switch self { case .couldNotMigrateStandardDefaults: return NSLocalizedString("Could not migrate user defaults to group container", comment: "") } } } // MARK: - Local Store Errors enum LocalStoreError: Error { case couldNotSaveContext case couldNotFetchCollections - case couldNotFetchPosts(String) + case couldNotFetchPosts(String = "") case couldNotPurgePublishedPosts case couldNotPurgeCollections case couldNotLoadStore(String) case couldNotMigrateStore(String) case couldNotDeleteStoreAfterMigration(String) - case genericError(String) + case genericError(String = "") } extension LocalStoreError: LocalizedError { public var errorDescription: String? { switch self { case .couldNotSaveContext: return NSLocalizedString("Error saving context", comment: "") case .couldNotFetchCollections: return NSLocalizedString("Failed to fetch blogs from local store.", comment: "") case .couldNotFetchPosts(let postFilter): if postFilter.isEmpty { return NSLocalizedString("Failed to fetch posts from local store.", comment: "") } else { return NSLocalizedString("Failed to fetch \(postFilter) posts from local store.", comment: "") } case .couldNotPurgePublishedPosts: return NSLocalizedString("Failed to purge published posts from local store.", comment: "") case .couldNotPurgeCollections: return NSLocalizedString("Failed to purge cached collections", comment: "") case .couldNotLoadStore(let errorDescription): return NSLocalizedString("Something went wrong loading local store: \(errorDescription)", comment: "") case .couldNotMigrateStore(let errorDescription): return NSLocalizedString("Something went wrong migrating local store: \(errorDescription)", comment: "") case .couldNotDeleteStoreAfterMigration(let errorDescription): return NSLocalizedString("Something went wrong deleting old store: \(errorDescription)", comment: "") case .genericError(let customContent): if customContent.isEmpty { return NSLocalizedString("Something went wrong accessing device storage", comment: "") } else { return NSLocalizedString(customContent, comment: "") } } } } // MARK: - Application Errors enum AppError: Error { case couldNotGetLoggedInClient case couldNotGetPostId - case genericError(String) + case genericError(String = "") } extension AppError: LocalizedError { public var errorDescription: String? { switch self { case .couldNotGetLoggedInClient: return NSLocalizedString("Something went wrong trying to access the WriteFreely client.", comment: "") case .couldNotGetPostId: return NSLocalizedString("Something went wrong trying to get the post's unique ID.", comment: "") case .genericError(let customContent): if customContent.isEmpty { return NSLocalizedString("Something went wrong", comment: "") } else { return NSLocalizedString(customContent, comment: "") } } } } diff --git a/Shared/Extensions/WriteFreelyModel+Keychain.swift b/Shared/Extensions/WriteFreelyModel+Keychain.swift index 4984675..f6555aa 100644 --- a/Shared/Extensions/WriteFreelyModel+Keychain.swift +++ b/Shared/Extensions/WriteFreelyModel+Keychain.swift @@ -1,55 +1,55 @@ import Foundation extension WriteFreelyModel { func saveTokenToKeychain(_ token: String, username: String?, server: String) throws { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecValueData as String: token.data(using: .utf8)!, kSecAttrAccount as String: username ?? "anonymous", kSecAttrService as String: server ] let status = SecItemAdd(query as CFDictionary, nil) guard status == errSecDuplicateItem || status == errSecSuccess else { throw KeychainError.couldNotStoreAccessToken } } func purgeTokenFromKeychain(username: String?, server: String) throws { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: username ?? "anonymous", kSecAttrService as String: server ] let status = SecItemDelete(query as CFDictionary) guard status == errSecSuccess || status == errSecItemNotFound else { throw KeychainError.couldNotPurgeAccessToken } } func fetchTokenFromKeychain(username: String?, server: String) throws -> String? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: username ?? "anonymous", kSecAttrService as String: server, kSecMatchLimit as String: kSecMatchLimitOne, kSecReturnAttributes as String: true, kSecReturnData as String: true ] var secItem: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &secItem) guard status != errSecItemNotFound else { - return nil + throw KeychainError.couldNotFetchAccessToken } guard status == errSecSuccess else { throw KeychainError.couldNotFetchAccessToken } guard let existingSecItem = secItem as? [String: Any], let tokenData = existingSecItem[kSecValueData as String] as? Data, let token = String(data: tokenData, encoding: .utf8) else { - return nil + throw KeychainError.couldNotFetchAccessToken } return token } } diff --git a/Shared/Models/WriteFreelyModel.swift b/Shared/Models/WriteFreelyModel.swift index 63b9695..617385f 100644 --- a/Shared/Models/WriteFreelyModel.swift +++ b/Shared/Models/WriteFreelyModel.swift @@ -1,101 +1,102 @@ import Foundation import WriteFreely import Security import Network // MARK: - WriteFreelyModel final class WriteFreelyModel: ObservableObject { // MARK: - Models @Published var account = AccountModel() @Published var preferences = PreferencesModel() @Published var posts = PostListModel() @Published var editor = PostEditorModel() // MARK: - Error handling @Published var hasError: Bool = false var currentError: Error? { didSet { #if DEBUG print("⚠️ currentError -> didSet \(currentError?.localizedDescription ?? "nil")") print(" > hasError was: \(self.hasError)") #endif DispatchQueue.main.async { #if DEBUG print(" > self.currentError != nil: \(self.currentError != nil)") #endif self.hasError = self.currentError != nil #if DEBUG print(" > hasError is now: \(self.hasError)") #endif } } } // MARK: - State @Published var isLoggingIn: Bool = false @Published var isProcessingRequest: Bool = false @Published var hasNetworkConnection: Bool = true @Published var selectedPost: WFAPost? @Published var selectedCollection: WFACollection? @Published var showAllPosts: Bool = true @Published var isPresentingDeleteAlert: Bool = false @Published var postToDelete: WFAPost? #if os(iOS) @Published var isPresentingSettingsView: Bool = false #endif static var shared = WriteFreelyModel() // swiftlint:disable line_length let helpURL = URL(string: "https://discuss.write.as/c/help/5")! let howToURL = URL(string: "https://discuss.write.as/t/using-the-writefreely-ios-app/1946")! let reviewURL = URL(string: "https://apps.apple.com/app/id1531530896?action=write-review")! let licensesURL = URL(string: "https://github.com/writeas/writefreely-swiftui-multiplatform/tree/main/Shared/Resources/Licenses")! // swiftlint:enable line_length internal var client: WFClient? private let defaults = UserDefaults.shared private let monitor = NWPathMonitor() private let queue = DispatchQueue(label: "NetworkMonitor") internal var postToUpdate: WFAPost? init() { DispatchQueue.main.async { self.preferences.appearance = self.defaults.integer(forKey: WFDefaults.colorSchemeIntegerKey) self.preferences.font = self.defaults.integer(forKey: WFDefaults.defaultFontIntegerKey) self.account.restoreState() if self.account.isLoggedIn { guard let serverURL = URL(string: self.account.server) else { self.currentError = AccountError.invalidServerURL return } do { guard let token = try self.fetchTokenFromKeychain( username: self.account.username, server: self.account.server ) else { self.currentError = KeychainError.couldNotFetchAccessToken return } self.account.login(WFUser(token: token, username: self.account.username)) self.client = WFClient(for: serverURL) self.client?.user = self.account.user self.fetchUserCollections() self.fetchUserPosts() } catch { self.currentError = KeychainError.couldNotFetchAccessToken + return } } } monitor.pathUpdateHandler = { path in DispatchQueue.main.async { self.hasNetworkConnection = path.status == .satisfied } } monitor.start(queue: queue) } } diff --git a/Shared/Navigation/ContentView.swift b/Shared/Navigation/ContentView.swift index ca13ff5..f57a541 100644 --- a/Shared/Navigation/ContentView.swift +++ b/Shared/Navigation/ContentView.swift @@ -1,88 +1,86 @@ import SwiftUI struct ContentView: View { @EnvironmentObject var model: WriteFreelyModel @EnvironmentObject var errorHandling: ErrorHandling var body: some View { NavigationView { #if os(macOS) CollectionListView() .withErrorHandling() .toolbar { Button( action: { NSApp.keyWindow?.contentViewController?.tryToPerform( #selector(NSSplitViewController.toggleSidebar(_:)), with: nil ) }, label: { Image(systemName: "sidebar.left") } ) .help("Toggle the sidebar's visibility.") Spacer() Button(action: { withAnimation { // Un-set the currently selected post self.model.selectedPost = nil } // Create the new-post managed object let managedPost = model.editor.generateNewLocalPost(withFont: model.preferences.font) withAnimation { DispatchQueue.main.asyncAfter(deadline: .now()) { // Load the new post in the editor self.model.selectedPost = managedPost } } }, label: { Image(systemName: "square.and.pencil") }) .help("Create a new local draft.") } #else CollectionListView() .withErrorHandling() #endif #if os(macOS) ZStack { PostListView(selectedCollection: model.selectedCollection, showAllPosts: model.showAllPosts) .withErrorHandling() if model.isProcessingRequest { ZStack { Color(NSColor.controlBackgroundColor).opacity(0.75) ProgressView() } } } #else PostListView(selectedCollection: model.selectedCollection, showAllPosts: model.showAllPosts) .withErrorHandling() #endif Text("Select a post, or create a new local draft.") .foregroundColor(.secondary) - - EmptyView() - .onChange(of: model.hasError) { value in - if value { - if let error = model.currentError { - self.errorHandling.handle(error: error) - } else { - self.errorHandling.handle(error: AppError.genericError("")) - } - model.hasError = false - } - } } .environmentObject(model) + .onChange(of: model.hasError) { value in + if value { + if let error = model.currentError { + self.errorHandling.handle(error: error) + } else { + self.errorHandling.handle(error: AppError.genericError()) + } + model.hasError = false + } + } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { let context = LocalStorageManager.standard.container.viewContext let model = WriteFreelyModel() return ContentView() .environment(\.managedObjectContext, context) .environmentObject(model) } } diff --git a/Shared/PostList/PostListView.swift b/Shared/PostList/PostListView.swift index 16e1f45..c1bb662 100644 --- a/Shared/PostList/PostListView.swift +++ b/Shared/PostList/PostListView.swift @@ -1,186 +1,186 @@ import SwiftUI import Combine struct PostListView: View { @EnvironmentObject var model: WriteFreelyModel @EnvironmentObject var errorHandling: ErrorHandling @Environment(\.managedObjectContext) var managedObjectContext @State private var postCount: Int = 0 @State private var filteredListViewId: Int = 0 var selectedCollection: WFACollection? var showAllPosts: Bool #if os(iOS) private var frameHeight: CGFloat { var height: CGFloat = 50 let bottom = UIApplication.shared.windows.first?.safeAreaInsets.bottom ?? 0 height += bottom return height } #endif var body: some View { #if os(iOS) ZStack(alignment: .bottom) { PostListFilteredView( collection: selectedCollection, showAllPosts: showAllPosts, postCount: $postCount ) .id(self.filteredListViewId) .navigationTitle( showAllPosts ? "All Posts" : selectedCollection?.title ?? ( model.account.server == "https://write.as" ? "Anonymous" : "Drafts" ) ) .toolbar { ToolbarItem(placement: .primaryAction) { // We have to add a Spacer as a sibling view to the Button in some kind of Stack, so that any // a11y modifiers are applied as expected: bug report filed as FB8956392. ZStack { Spacer() Button(action: { let managedPost = model.editor.generateNewLocalPost(withFont: model.preferences.font) withAnimation { self.model.showAllPosts = false self.model.selectedPost = managedPost } }, label: { ZStack { Image("does.not.exist") .accessibilityHidden(true) Image(systemName: "square.and.pencil") .accessibilityHidden(true) .imageScale(.large) // These modifiers compensate for the resizing .padding(.vertical, 12) // done to the Image (and the button tap target) .padding(.leading, 12) // by the SwiftUI layout system from adding a .padding(.trailing, 8) // Spacer in this ZStack (FB8956392). } .frame(maxWidth: .infinity, maxHeight: .infinity) }) .accessibilityLabel(Text("Compose")) .accessibilityHint(Text("Compose a new local draft")) } } } VStack { HStack(spacing: 0) { Button(action: { model.isPresentingSettingsView = true }, label: { Image(systemName: "gear") .padding(.vertical, 4) .padding(.horizontal, 8) }) .accessibilityLabel(Text("Settings")) .accessibilityHint(Text("Open the Settings sheet")) .sheet( isPresented: $model.isPresentingSettingsView, onDismiss: { model.isPresentingSettingsView = false }, content: { SettingsView() .environmentObject(model) } ) Spacer() Text(postCount == 1 ? "\(postCount) post" : "\(postCount) posts") .foregroundColor(.secondary) Spacer() if model.isProcessingRequest { ProgressView() .padding(.vertical, 4) .padding(.horizontal, 8) } else { Button(action: { DispatchQueue.main.async { model.fetchUserCollections() model.fetchUserPosts() } }, label: { Image(systemName: "arrow.clockwise") .padding(.vertical, 4) .padding(.horizontal, 8) }) .accessibilityLabel(Text("Refresh Posts")) .accessibilityHint(Text("Fetch changes from the server")) .disabled(!model.account.isLoggedIn) } } .padding(.top, 8) .padding(.horizontal, 8) Spacer() } .frame(height: frameHeight) .background(Color(UIColor.systemGray5)) .overlay(Divider(), alignment: .top) .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in // We use this to invalidate and refresh the view, so that new posts created outside of the app (e.g., // in the action extension) show up. withAnimation { self.filteredListViewId += 1 } } } .ignoresSafeArea(.all, edges: .bottom) .onAppear { model.selectedCollection = selectedCollection model.showAllPosts = showAllPosts } .onChange(of: model.hasError) { value in if value { if let error = model.currentError { self.errorHandling.handle(error: error) } else { - self.errorHandling.handle(error: AppError.genericError("")) + self.errorHandling.handle(error: AppError.genericError()) } model.hasError = false } } #else PostListFilteredView( collection: selectedCollection, showAllPosts: showAllPosts, postCount: $postCount ) .toolbar { ToolbarItemGroup(placement: .primaryAction) { if model.selectedPost != nil { ActivePostToolbarView(activePost: model.selectedPost!) } } } .navigationTitle( showAllPosts ? "All Posts" : selectedCollection?.title ?? ( model.account.server == "https://write.as" ? "Anonymous" : "Drafts" ) ) .onAppear { model.selectedCollection = selectedCollection model.showAllPosts = showAllPosts } .onChange(of: model.hasError) { value in if value { if let error = model.currentError { self.errorHandling.handle(error: error) } else { - self.errorHandling.handle(error: AppError.genericError) + self.errorHandling.handle(error: AppError.genericError()) } model.hasError = false } } #endif } } struct PostListView_Previews: PreviewProvider { static var previews: some View { let context = LocalStorageManager.standard.container.viewContext let model = WriteFreelyModel() return PostListView(showAllPosts: true) .environment(\.managedObjectContext, context) .environmentObject(model) } }