diff --git a/Mac/Preferences/Accounts/AccountsDetailViewController.swift b/Mac/Preferences/Accounts/AccountsDetailViewController.swift index 98a20da6e8..a6e888a421 100644 --- a/Mac/Preferences/Accounts/AccountsDetailViewController.swift +++ b/Mac/Preferences/Accounts/AccountsDetailViewController.swift @@ -32,7 +32,7 @@ final class AccountsDetailViewController: NSViewController, NSTextFieldDelegate return true } switch account.type { - case .onMyMac, .cloudKit, .feedly: + case .onMyMac, .cloudKit, .feedly, .inkwell: return true default: return false diff --git a/Mac/Preferences/Accounts/AccountsPreferencesViewController.swift b/Mac/Preferences/Accounts/AccountsPreferencesViewController.swift index 113c67c0f6..6945b88394 100644 --- a/Mac/Preferences/Accounts/AccountsPreferencesViewController.swift +++ b/Mac/Preferences/Accounts/AccountsPreferencesViewController.swift @@ -119,7 +119,7 @@ extension AccountsPreferencesViewController: NSTableViewDelegate { cell.textField?.stringValue = account.nameForDisplay cell.imageView?.image = account.smallIcon?.image - if account.type == .feedbin { + if account.type == .feedbin || account.type == .inkwell { cell.isImageTemplateCapable = false } @@ -189,7 +189,14 @@ extension AccountsPreferencesViewController: AccountsPreferencesAddAccountDelega let addAccount = OAuthAccountAuthorizationOperation(accountType: .feedly) addAccount.delegate = self addAccount.presentationAnchor = window - runAwaitingFeedlyLoginAlertModal(forLifetimeOf: addAccount) + runAwaitingOAuthLoginAlertModal(forLifetimeOf: addAccount, accountType: .feedly) + MainThreadOperationQueue.shared.add(addAccount) + + case .inkwell: + let addAccount = OAuthAccountAuthorizationOperation(accountType: .inkwell) + addAccount.delegate = self + addAccount.presentationAnchor = window + runAwaitingOAuthLoginAlertModal(forLifetimeOf: addAccount, accountType: .inkwell) MainThreadOperationQueue.shared.add(addAccount) case .newsBlur: @@ -199,14 +206,17 @@ extension AccountsPreferencesViewController: AccountsPreferencesAddAccountDelega } } - private func runAwaitingFeedlyLoginAlertModal(forLifetimeOf operation: OAuthAccountAuthorizationOperation) { + private func runAwaitingOAuthLoginAlertModal(forLifetimeOf operation: OAuthAccountAuthorizationOperation, accountType: AccountType) { + let serviceName = accountType.localizedAccountName() let alert = NSAlert() alert.alertStyle = .informational - alert.messageText = NSLocalizedString("Waiting for access to Feedly", - comment: "Alert title when adding a Feedly account and waiting for authorization from the user.") + let messageFormat = NSLocalizedString("Waiting for access to %@", + comment: "Alert title when adding an OAuth account and waiting for authorization from the user.") + alert.messageText = String(format: messageFormat, serviceName) - alert.informativeText = NSLocalizedString("A web browser will open the Feedly login for you to authorize access.", - comment: "Alert informative text when adding a Feedly account and waiting for authorization from the user.") + let informativeFormat = NSLocalizedString("A web browser will open the %@ login for you to authorize access.", + comment: "Alert informative text when adding an OAuth account and waiting for authorization from the user.") + alert.informativeText = String(format: informativeFormat, serviceName) alert.addButton(withTitle: NSLocalizedString("Cancel", comment: "Cancel")) diff --git a/Mac/Preferences/Accounts/AddAccountsView.swift b/Mac/Preferences/Accounts/AddAccountsView.swift index b4991896d0..ad16f1aee1 100644 --- a/Mac/Preferences/Accounts/AddAccountsView.swift +++ b/Mac/Preferences/Accounts/AddAccountsView.swift @@ -55,9 +55,9 @@ enum AddAccountSections: Int, CaseIterable { return [.cloudKit] case .web: if AppDefaults.shared.isDeveloperBuild { - return [.bazQux, .feedbin, .feedly, .inoreader, .newsBlur, .theOldReader].filter({ $0.isDeveloperRestricted == false }) + return [.bazQux, .feedbin, .feedly, .inkwell, .inoreader, .newsBlur, .theOldReader].filter({ $0.isDeveloperRestricted == false }) } else { - return [.bazQux, .feedbin, .feedly, .inoreader, .newsBlur, .theOldReader] + return [.bazQux, .feedbin, .feedly, .inkwell, .inoreader, .newsBlur, .theOldReader] } case .selfhosted: return [.freshRSS] diff --git a/Mac/Resources/Assets.xcassets/accountInkwell.imageset/Contents.json b/Mac/Resources/Assets.xcassets/accountInkwell.imageset/Contents.json new file mode 100644 index 0000000000..6b6eefe0d7 --- /dev/null +++ b/Mac/Resources/Assets.xcassets/accountInkwell.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "inkwell_icon_48.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "original" + } +} diff --git a/Mac/Resources/Assets.xcassets/accountInkwell.imageset/inkwell_icon_48.png b/Mac/Resources/Assets.xcassets/accountInkwell.imageset/inkwell_icon_48.png new file mode 100644 index 0000000000..fa7452e2a1 Binary files /dev/null and b/Mac/Resources/Assets.xcassets/accountInkwell.imageset/inkwell_icon_48.png differ diff --git a/Mac/Resources/NetNewsWire.sdef b/Mac/Resources/NetNewsWire.sdef index bd5706e592..10b7a392fc 100644 --- a/Mac/Resources/NetNewsWire.sdef +++ b/Mac/Resources/NetNewsWire.sdef @@ -84,6 +84,7 @@ + @@ -264,4 +265,3 @@ - diff --git a/Mac/Scripting/Account+Scriptability.swift b/Mac/Scripting/Account+Scriptability.swift index 11116ad842..7dffa06df0 100644 --- a/Mac/Scripting/Account+Scriptability.swift +++ b/Mac/Scripting/Account+Scriptability.swift @@ -220,6 +220,8 @@ import RSCore osType = "Fdly" case .feedbin: osType = "Fdbn" + case .inkwell: + osType = "Mcro" case .newsBlur: osType = "NBlr" case .freshRSS: diff --git a/Modules/Account/Sources/Account/Account.swift b/Modules/Account/Sources/Account/Account.swift index 5bd915808a..e29de927a6 100644 --- a/Modules/Account/Sources/Account/Account.swift +++ b/Modules/Account/Sources/Account/Account.swift @@ -43,6 +43,7 @@ nonisolated public enum AccountType: Int, Codable, Sendable { case inoreader = 21 case bazQux = 22 case theOldReader = 23 + case inkwell = 24 public var isDeveloperRestricted: Bool { return self == .cloudKit || self == .feedbin || self == .feedly || self == .inoreader @@ -271,6 +272,8 @@ public enum FetchType { self.delegate = FeedbinAccountDelegate(dataFolder: dataFolder, transport: transport) case .feedly: self.delegate = FeedlyAccountDelegate(dataFolder: dataFolder, transport: transport, api: FeedlyAccountDelegate.environment) + case .inkwell: + self.delegate = InkwellAccountDelegate(dataFolder: dataFolder, transport: transport) case .newsBlur: self.delegate = NewsBlurAccountDelegate(dataFolder: dataFolder, transport: transport) case .freshRSS: @@ -302,6 +305,8 @@ public enum FetchType { defaultName = NSLocalizedString("Feedbin", comment: "Feedbin") case .newsBlur: defaultName = NSLocalizedString("NewsBlur", comment: "NewsBlur") + case .inkwell: + defaultName = NSLocalizedString("Inkwell", comment: "Inkwell") case .freshRSS: defaultName = NSLocalizedString("FreshRSS", comment: "FreshRSS") case .inoreader: @@ -383,6 +388,8 @@ public enum FetchType { return try await NewsBlurAccountDelegate.validateCredentials(transport: transport, credentials: credentials, endpoint: endpoint) case .freshRSS, .inoreader, .bazQux, .theOldReader: return try await ReaderAPIAccountDelegate.validateCredentials(transport: transport, credentials: credentials, endpoint: endpoint) + case .inkwell: + return try await InkwellAccountDelegate.validateCredentials(transport: transport, credentials: credentials, endpoint: endpoint) default: return nil } @@ -392,6 +399,8 @@ public enum FetchType { switch type { case .feedly: return FeedlyAccountDelegate.environment.oauthAuthorizationClient + case .inkwell: + return .inkwellClient default: fatalError("\(type) is not a client for OAuth authorization code granting.") } @@ -402,6 +411,8 @@ public enum FetchType { switch type { case .feedly: grantingType = FeedlyAccountDelegate.self + case .inkwell: + grantingType = InkwellAccountDelegate.self default: fatalError("\(type) does not support OAuth authorization code granting.") } @@ -419,6 +430,8 @@ public enum FetchType { switch accountType { case .feedly: grantingType = FeedlyAccountDelegate.self + case .inkwell: + grantingType = InkwellAccountDelegate.self default: fatalError("\(accountType) does not support OAuth authorization code granting.") } diff --git a/Modules/Account/Sources/Account/Feedly/OAuthAccountAuthorizationOperation.swift b/Modules/Account/Sources/Account/Feedly/OAuthAccountAuthorizationOperation.swift index 2be655e5e1..a258e8efe7 100644 --- a/Modules/Account/Sources/Account/Feedly/OAuthAccountAuthorizationOperation.swift +++ b/Modules/Account/Sources/Account/Feedly/OAuthAccountAuthorizationOperation.swift @@ -20,7 +20,7 @@ public enum OAuthAccountAuthorizationOperationError: LocalizedError, Sendable { case duplicateAccount public var errorDescription: String? { - return NSLocalizedString("There is already a Feedly account with that username created.", comment: "Duplicate Error") + return NSLocalizedString("There is already an account with that username created.", comment: "Duplicate Error") } } @@ -196,13 +196,13 @@ private extension OAuthAccountAuthorizationOperation { func saveAccount(for grant: OAuthAuthorizationGrant) { Self.logger.debug("OAuthAccountAuthorizationOperation: saveAccount") - guard !AccountManager.shared.duplicateServiceAccount(type: .feedly, username: grant.accessToken.username) else { + guard !AccountManager.shared.duplicateServiceAccount(type: accountType, username: grant.accessToken.username) else { self.error = OAuthAccountAuthorizationOperationError.duplicateAccount didComplete() return } - let account = AccountManager.shared.createAccount(type: .feedly) + let account = AccountManager.shared.createAccount(type: accountType) do { // Store the refresh token first because it sends this token to the account delegate. @@ -221,4 +221,3 @@ private extension OAuthAccountAuthorizationOperation { didComplete() } } - diff --git a/Modules/Account/Sources/Account/Feedly/OAuthAuthorizationClient+Feedly.swift b/Modules/Account/Sources/Account/Feedly/OAuthAuthorizationClient+Feedly.swift index 0e8de4bc41..8b178be415 100644 --- a/Modules/Account/Sources/Account/Feedly/OAuthAuthorizationClient+Feedly.swift +++ b/Modules/Account/Sources/Account/Feedly/OAuthAuthorizationClient+Feedly.swift @@ -33,4 +33,11 @@ nonisolated extension OAuthAuthorizationClient { state: nil, secret: "4ZfZ5DvqmJ8vKgMj") } + + static var inkwellClient: OAuthAuthorizationClient { + return OAuthAuthorizationClient(id: "https://netnewswire.com/", + redirectUri: "netnewswire://auth/inkwell", + state: nil, + secret: "") + } } diff --git a/Modules/Account/Sources/Account/Inkwell/Inkwell.swift b/Modules/Account/Sources/Account/Inkwell/Inkwell.swift new file mode 100644 index 0000000000..044544538a --- /dev/null +++ b/Modules/Account/Sources/Account/Inkwell/Inkwell.swift @@ -0,0 +1,14 @@ +// +// Inkwell.swift +// Account +// +// Created by Manton Reece on 3/11/26. +// + +import Foundation +import os.log + +struct Inkwell { + // Convention with this logger is to put "Inkwell: " at the beginning of each message. + static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "Inkwell") +} diff --git a/Modules/Account/Sources/Account/Inkwell/InkwellAPICaller.swift b/Modules/Account/Sources/Account/Inkwell/InkwellAPICaller.swift new file mode 100644 index 0000000000..49c30e172c --- /dev/null +++ b/Modules/Account/Sources/Account/Inkwell/InkwellAPICaller.swift @@ -0,0 +1,501 @@ +// +// InkwellAPICaller.swift +// Account +// +// Created by Manton Reece on 3/11/26. +// + +import Foundation +import RSWeb +import Secrets + +enum InkwellAccountError: LocalizedError, Sendable { + case inkwellNotEnabled + + var errorDescription: String? { + switch self { + case .inkwellNotEnabled: + return NSLocalizedString("This Micro.blog account does not have Inkwell enabled.", comment: "Inkwell unavailable") + } + } + + var recoverySuggestion: String? { + switch self { + case .inkwellNotEnabled: + return NSLocalizedString("Enable Inkwell for this Micro.blog account and try again.", comment: "Inkwell unavailable suggestion") + } + } +} + +@MainActor protocol InkwellAPICallerDelegate: AnyObject { + func inkwellAPICaller(_ caller: InkwellAPICaller, store credentials: Credentials) throws +} + +struct InkwellOAuthAccessTokenRequest: Sendable { + let code: String + let clientId: String + let redirectUri: String + let grantType = "authorization_code" + + init(authorizationResponse: OAuthAuthorizationResponse, client: OAuthAuthorizationClient) { + self.code = authorizationResponse.code + self.clientId = client.id + self.redirectUri = client.redirectUri + } + + var formData: Data? { + var components = URLComponents() + components.queryItems = [ + URLQueryItem(name: "code", value: code), + URLQueryItem(name: "client_id", value: clientId), + URLQueryItem(name: "grant_type", value: grantType), + URLQueryItem(name: "redirect_uri", value: redirectUri) + ] + return components.enhancedPercentEncodedQuery?.data(using: .utf8) + } +} + +nonisolated struct InkwellOAuthAccessTokenResponse: Decodable, OAuthAccessTokenResponse, Sendable { + let accessToken: String + let tokenType: String + let scope: String + let me: String? + let profile: InkwellOAuthAccessTokenProfile? + let expiresIn = 0 + let refreshToken: String? = nil + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case tokenType = "token_type" + case scope + case me + case profile + } +} + +nonisolated struct InkwellOAuthAccessTokenProfile: Decodable, Sendable { + let name: String? + let url: String? + let photo: String? +} + +struct InkwellVerifyResponse: Decodable, Sendable { + let token: String + let hasInkwell: Bool + let name: String + let username: String + let avatar: String? + + enum CodingKeys: String, CodingKey { + case token + case hasInkwell = "has_inkwell" + case name + case username + case avatar + } +} + +@MainActor final class InkwellAPICaller { + struct ConditionalGetKeys { + static let subscriptions = "subscriptions" + static let unreadEntries = "unreadEntries" + static let starredEntries = "starredEntries" + } + + private let inkwellBaseURL = URL(string: "https://micro.blog/feeds/v2/")! + private let oauthTokenURL = URL(string: "https://micro.blog/indieauth/token")! + private let verifyURL = URL(string: "https://micro.blog/account/verify")! + private let transport: Transport + private var suspended = false + private var lastBackdateStartTime: Date? + + weak var delegate: InkwellAPICallerDelegate? + var credentials: Credentials? + var accountSettings: AccountSettings? + + init(transport: Transport) { + self.transport = transport + } + + func suspend() { + transport.cancelAll() + suspended = true + } + + func resume() { + suspended = false + } + + func validateCredentials() async throws -> Credentials? { + guard let credentials else { + throw CredentialsError.missingAccessToken + } + + do { + let response = try await verifyAccessToken(credentials.secret) + guard response.hasInkwell else { + throw InkwellAccountError.inkwellNotEnabled + } + return Credentials(type: .bearerAccessToken, username: response.username, secret: response.token) + } catch { + if case TransportError.httpError(let status) = error, status == 401 || status == 403 { + return nil + } + throw error + } + } + + func requestAccessToken(_ authorizationRequest: InkwellOAuthAccessTokenRequest) async throws -> InkwellOAuthAccessTokenResponse { + if suspended { + throw TransportError.suspended + } + + guard let formData = authorizationRequest.formData else { + throw AccountError.invalidParameter + } + + var request = URLRequest(url: oauthTokenURL) + request.addValue(MimeType.formURLEncoded, forHTTPHeaderField: HTTPRequestHeader.contentType) + request.addValue("application/json", forHTTPHeaderField: "Accept") + + let (_, response) = try await transport.send(request: request, method: HTTPMethod.post, data: formData, resultType: InkwellOAuthAccessTokenResponse.self) + guard let response else { + throw TransportError.noData + } + return response + } + + func verifyAccessToken(_ token: String) async throws -> InkwellVerifyResponse { + if suspended { + throw TransportError.suspended + } + + var components = URLComponents() + components.queryItems = [URLQueryItem(name: "token", value: token)] + + guard let formData = components.enhancedPercentEncodedQuery?.data(using: .utf8) else { + throw AccountError.invalidParameter + } + + var request = URLRequest(url: verifyURL) + request.addValue(MimeType.formURLEncoded, forHTTPHeaderField: HTTPRequestHeader.contentType) + request.addValue("application/json", forHTTPHeaderField: "Accept") + + let (_, response) = try await transport.send(request: request, method: HTTPMethod.post, data: formData, resultType: InkwellVerifyResponse.self) + guard let response else { + throw TransportError.noData + } + return response + } + + func retrieveSubscriptions() async throws -> [FeedbinSubscription]? { + var callComponents = URLComponents(url: inkwellBaseURL.appendingPathComponent("subscriptions.json"), resolvingAgainstBaseURL: false)! + callComponents.queryItems = [URLQueryItem(name: "mode", value: "extended")] + + let conditionalGet = accountSettings?.conditionalGetInfo(for: ConditionalGetKeys.subscriptions) + let request = URLRequest(url: callComponents.url!, credentials: credentials, conditionalGet: conditionalGet) + + let (response, subscriptions) = try await send(request: request, resultType: [FeedbinSubscription].self) + storeConditionalGet(key: ConditionalGetKeys.subscriptions, headers: response.allHeaderFields) + return subscriptions + } + + func createSubscription(url: String) async throws -> CreateSubscriptionResult { + var callComponents = URLComponents(url: inkwellBaseURL.appendingPathComponent("subscriptions.json"), resolvingAgainstBaseURL: false)! + callComponents.queryItems = [URLQueryItem(name: "mode", value: "extended")] + + var request = URLRequest(url: callComponents.url!, credentials: credentials) + request.addValue("application/json; charset=utf-8", forHTTPHeaderField: HTTPRequestHeader.contentType) + + let payload = try JSONEncoder().encode(FeedbinCreateSubscription(feedURL: url)) + + do { + let (response, data) = try await send(request: request, method: HTTPMethod.post, payload: payload) + + switch response.forcedStatusCode { + case HTTPResponseCode.created: + guard let data else { + throw TransportError.noData + } + return .created(try JSONDecoder().decode(FeedbinSubscription.self, from: data)) + case HTTPResponseCode.redirectMultipleChoices: + guard let data else { + throw TransportError.noData + } + return .multipleChoice(try JSONDecoder().decode([FeedbinSubscriptionChoice].self, from: data)) + case HTTPResponseCode.redirectTemporary: + return .alreadySubscribed + default: + throw TransportError.httpError(status: response.forcedStatusCode) + } + } catch { + switch error { + case TransportError.httpError(let status): + switch status { + case HTTPResponseCode.notFound: + return .notFound + default: + throw error + } + default: + throw error + } + } + } + + func renameSubscription(subscriptionID: String, newName: String) async throws { + let callURL = inkwellBaseURL.appendingPathComponent("subscriptions/\(subscriptionID).json") + let request = URLRequest(url: callURL, credentials: credentials) + let payload = FeedbinUpdateSubscription(title: newName) + + try await send(request: request, method: HTTPMethod.patch, payload: payload) + } + + func deleteSubscription(subscriptionID: String) async throws { + let callURL = inkwellBaseURL.appendingPathComponent("subscriptions/\(subscriptionID).json") + let request = URLRequest(url: callURL, credentials: credentials) + + try await send(request: request, method: HTTPMethod.delete) + } + + func retrieveEntries(articleIDs: [String]) async throws -> [FeedbinEntry]? { + guard !articleIDs.isEmpty else { + return [] + } + + let concatIDs = articleIDs.reduce("") { partial, articleID in + partial + ",\(articleID)" + } + let paramIDs = String(concatIDs.dropFirst()) + let url = inkwellBaseURL + .appendingPathComponent("entries.json") + .appendingQueryItems([ + URLQueryItem(name: "ids", value: paramIDs), + URLQueryItem(name: "mode", value: "extended") + ]) + let request = URLRequest(url: url!, credentials: credentials) + + let (_, entries) = try await send(request: request, resultType: [FeedbinEntry].self) + return entries + } + + func retrieveEntries(feedID: String) async throws -> ([FeedbinEntry]?, String?) { + let since = Calendar.current.date(byAdding: .month, value: -3, to: Date()) ?? Date() + let sinceString = FeedbinDate.formatter.string(from: since) + let url = inkwellBaseURL + .appendingPathComponent("feeds/\(feedID)/entries.json") + .appendingQueryItems([ + URLQueryItem(name: "since", value: sinceString), + URLQueryItem(name: "per_page", value: "100"), + URLQueryItem(name: "mode", value: "extended") + ]) + let request = URLRequest(url: url!, credentials: credentials) + + let (response, entries) = try await send(request: request, resultType: [FeedbinEntry].self) + let pagingInfo = HTTPLinkPagingInfo(urlResponse: response) + return (entries, pagingInfo.nextPage) + } + + func retrieveEntries() async throws -> ([FeedbinEntry]?, String?, Date?, Int?) { + let since: Date = { + if let lastArticleFetch = accountSettings?.lastArticleFetchStartTime { + if let lastBackdateStartTime { + if lastBackdateStartTime.byAdding(days: 1) < lastArticleFetch { + self.lastBackdateStartTime = lastArticleFetch + return lastArticleFetch.bySubtracting(days: 1) + } else { + return lastArticleFetch + } + } else { + self.lastBackdateStartTime = lastArticleFetch + return lastArticleFetch.bySubtracting(days: 1) + } + } else { + return Calendar.current.date(byAdding: .month, value: -3, to: Date()) ?? Date() + } + }() + + let sinceString = FeedbinDate.formatter.string(from: since) + let url = inkwellBaseURL + .appendingPathComponent("entries.json") + .appendingQueryItems([ + URLQueryItem(name: "since", value: sinceString), + URLQueryItem(name: "per_page", value: "100"), + URLQueryItem(name: "mode", value: "extended") + ]) + let request = URLRequest(url: url!, credentials: credentials) + + let (response, entries) = try await send(request: request, resultType: [FeedbinEntry].self) + let dateInfo = HTTPDateInfo(urlResponse: response) + let pagingInfo = HTTPLinkPagingInfo(urlResponse: response) + let lastPageNumber = extractPageNumber(link: pagingInfo.lastPage) + return (entries, pagingInfo.nextPage, dateInfo?.date, lastPageNumber) + } + + func retrieveEntries(page: String) async throws -> ([FeedbinEntry]?, String?) { + guard let url = URL(string: page) else { + return (nil, nil) + } + + let request = URLRequest(url: url, credentials: credentials) + let (response, entries) = try await send(request: request, resultType: [FeedbinEntry].self) + let pagingInfo = HTTPLinkPagingInfo(urlResponse: response) + return (entries, pagingInfo.nextPage) + } + + func retrieveUnreadEntries() async throws -> [Int]? { + let callURL = inkwellBaseURL.appendingPathComponent("unread_entries.json") + let conditionalGet = accountSettings?.conditionalGetInfo(for: ConditionalGetKeys.unreadEntries) + let request = URLRequest(url: callURL, credentials: credentials, conditionalGet: conditionalGet) + + let (response, unreadEntries) = try await send(request: request, resultType: [Int].self) + storeConditionalGet(key: ConditionalGetKeys.unreadEntries, headers: response.allHeaderFields) + return unreadEntries + } + + func createUnreadEntries(entries: [Int]) async throws { + let callURL = inkwellBaseURL.appendingPathComponent("unread_entries.json") + let request = URLRequest(url: callURL, credentials: credentials) + let payload = FeedbinUnreadEntry(unreadEntries: entries) + + try await send(request: request, method: HTTPMethod.post, payload: payload) + } + + func deleteUnreadEntries(entries: [Int]) async throws { + let callURL = inkwellBaseURL.appendingPathComponent("unread_entries.json") + let request = URLRequest(url: callURL, credentials: credentials) + let payload = FeedbinUnreadEntry(unreadEntries: entries) + + try await send(request: request, method: HTTPMethod.delete, payload: payload) + } + + func retrieveStarredEntries() async throws -> [Int]? { + let callURL = inkwellBaseURL.appendingPathComponent("starred_entries.json") + let conditionalGet = accountSettings?.conditionalGetInfo(for: ConditionalGetKeys.starredEntries) + let request = URLRequest(url: callURL, credentials: credentials, conditionalGet: conditionalGet) + + let (response, starredEntries) = try await send(request: request, resultType: [Int].self) + storeConditionalGet(key: ConditionalGetKeys.starredEntries, headers: response.allHeaderFields) + return starredEntries + } + + func createStarredEntries(entries: [Int]) async throws { + let callURL = inkwellBaseURL.appendingPathComponent("starred_entries.json") + let request = URLRequest(url: callURL, credentials: credentials) + let payload = FeedbinStarredEntry(starredEntries: entries) + + try await send(request: request, method: HTTPMethod.post, payload: payload) + } + + func deleteStarredEntries(entries: [Int]) async throws { + let callURL = inkwellBaseURL.appendingPathComponent("starred_entries.json") + let request = URLRequest(url: callURL, credentials: credentials) + let payload = FeedbinStarredEntry(starredEntries: entries) + + try await send(request: request, method: HTTPMethod.delete, payload: payload) + } +} + +private extension InkwellAPICaller { + func send(request: URLRequest) async throws -> (HTTPURLResponse, Data?) { + try await sendWithTokenRefresh(request: request) { request in + try await transport.send(request: request) + } + } + + func send(request: URLRequest, resultType: R.Type) async throws -> (HTTPURLResponse, R?) { + try await sendWithTokenRefresh(request: request) { request in + try await transport.send(request: request, resultType: resultType) + } + } + + func send(request: URLRequest, method: String) async throws { + _ = try await sendWithTokenRefresh(request: request) { request in + try await transport.send(request: request, method: method) + return true + } + } + + func send(request: URLRequest, method: String, payload: P) async throws { + _ = try await sendWithTokenRefresh(request: request) { request in + try await transport.send(request: request, method: method, payload: payload) + return true + } + } + + func send(request: URLRequest, method: String, payload: Data) async throws -> (HTTPURLResponse, Data?) { + try await sendWithTokenRefresh(request: request) { request in + try await transport.send(request: request, method: method, payload: payload) + } + } + + func sendWithTokenRefresh(request: URLRequest, operation: (URLRequest) async throws -> T) async throws -> T { + if suspended { + throw TransportError.suspended + } + + do { + return try await operation(request) + } catch { + guard shouldRefreshCredentials(for: error) else { + throw error + } + + try await refreshCredentials() + + var retryRequest = request + if let credentials { + retryRequest.setValue("Bearer \(credentials.secret)", forHTTPHeaderField: HTTPRequestHeader.authorization) + } + + return try await operation(retryRequest) + } + } + + func shouldRefreshCredentials(for error: Error) -> Bool { + guard case TransportError.httpError(let status) = error else { + return false + } + return status == HTTPResponseCode.unauthorized || status == HTTPResponseCode.forbidden + } + + func refreshCredentials() async throws { + guard let credentials else { + throw CredentialsError.missingAccessToken + } + + let response = try await verifyAccessToken(credentials.secret) + guard response.hasInkwell else { + throw InkwellAccountError.inkwellNotEnabled + } + + let refreshedCredentials = Credentials(type: .bearerAccessToken, username: response.username, secret: response.token) + if let delegate { + try delegate.inkwellAPICaller(self, store: refreshedCredentials) + } else { + self.credentials = refreshedCredentials + } + } + + func storeConditionalGet(key: String, headers: [AnyHashable: Any]) { + accountSettings?.setConditionalGetInfo(HTTPConditionalGetInfo(headers: headers), for: key) + } + + func extractPageNumber(link: String?) -> Int? { + guard let link else { + return nil + } + + if let lowerBound = link.range(of: "page=")?.upperBound { + let partialLink = link[lowerBound..") { + return Int(partialLink[partialLink.startIndex.. URLRequest { + let client = OAuthAuthorizationClient.inkwellClient + let authorizationRequest = OAuthAuthorizationRequest(clientId: client.id, + redirectUri: client.redirectUri, + scope: oauthAuthorizationGrantScope, + state: client.state) + var components = URLComponents() + components.scheme = "https" + components.host = "micro.blog" + components.path = "/indieauth/auth" + components.queryItems = authorizationRequest.queryItems + + return URLRequest(url: components.url!) + } + + @MainActor static func requestOAuthAccessToken(with response: OAuthAuthorizationResponse, transport: Transport, completion: @escaping @MainActor (Result) -> Void) { + let client = OAuthAuthorizationClient.inkwellClient + let request = InkwellOAuthAccessTokenRequest(authorizationResponse: response, client: client) + let caller = InkwellAPICaller(transport: transport) + + Task { @MainActor in + do { + let tokenResponse = try await caller.requestAccessToken(request) + let verificationResponse = try await caller.verifyAccessToken(tokenResponse.accessToken) + + guard verificationResponse.hasInkwell else { + throw InkwellAccountError.inkwellNotEnabled + } + + let accessToken = Credentials(type: .bearerAccessToken, username: verificationResponse.username, secret: verificationResponse.token) + let grant = OAuthAuthorizationGrant(accessToken: accessToken, refreshToken: nil) + completion(.success(grant)) + } catch { + completion(.failure(error)) + } + } + } +} diff --git a/Modules/Account/Sources/Account/Inkwell/InkwellAccountDelegate.swift b/Modules/Account/Sources/Account/Inkwell/InkwellAccountDelegate.swift new file mode 100644 index 0000000000..f2f62aec81 --- /dev/null +++ b/Modules/Account/Sources/Account/Inkwell/InkwellAccountDelegate.swift @@ -0,0 +1,584 @@ +// +// InkwellAccountDelegate.swift +// Account +// +// Created by Manton Reece on 3/11/26. +// + +import Articles +import FeedFinder +import RSCore +import RSDatabase +import RSParser +import RSWeb +import SyncDatabase +import os.log +import Secrets + +@MainActor final class InkwellAccountDelegate: AccountDelegate { + let behaviors: AccountBehaviors = [.disallowFolderManagement, .disallowOPMLImports] + let server: String? = "micro.blog" + var isOPMLImportInProgress = false + + var progressInfo = ProgressInfo() { + didSet { + if progressInfo != oldValue { + postProgressInfoDidChangeNotification() + } + } + } + let refreshProgress = RSProgress() + + var credentials: Credentials? { + didSet { + caller.credentials = credentials + } + } + + var accountSettings: AccountSettings? { + didSet { + caller.accountSettings = accountSettings + } + } + + private let syncDatabase: SyncDatabase + private let caller: InkwellAPICaller + private weak var initializedAccount: Account? + private static let logger = Inkwell.logger + + init(dataFolder: String, transport: Transport?) { + let databaseFilePath = (dataFolder as NSString).appendingPathComponent("Sync.sqlite3") + syncDatabase = SyncDatabase(databasePath: databaseFilePath) + + if let transport { + caller = InkwellAPICaller(transport: transport) + } else { + let sessionConfiguration = URLSessionConfiguration.default + sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData + sessionConfiguration.timeoutIntervalForRequest = 60.0 + sessionConfiguration.httpShouldSetCookies = false + sessionConfiguration.httpCookieAcceptPolicy = .never + sessionConfiguration.httpMaximumConnectionsPerHost = 1 + sessionConfiguration.httpCookieStorage = nil + sessionConfiguration.urlCache = nil + + if let userAgentHeaders = UserAgent.headers() { + sessionConfiguration.httpAdditionalHeaders = userAgentHeaders + } + + caller = InkwellAPICaller(transport: URLSession(configuration: sessionConfiguration)) + } + + caller.delegate = self + + NotificationCenter.default.addObserver(self, selector: #selector(progressInfoDidChange(_:)), name: .progressInfoDidChange, object: refreshProgress) + } + + func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable: Any]) async { + } + + func refreshAll(for account: Account) async throws { + if credentials == nil { + credentials = try? account.retrieveCredentials(type: .bearerAccessToken) + } + + refreshProgress.reset() + refreshProgress.addTasks(3) + + do { + try await refreshAccount(account) + try await refreshArticlesAndStatuses(account) + } catch { + refreshProgress.reset() + throw AccountError.wrapped(error, account) + } + } + + func syncArticleStatus(for account: Account) async throws { + try await sendArticleStatus(for: account) + try await refreshArticleStatus(for: account) + } + + func sendArticleStatus(for account: Account) async throws { + Self.logger.info("Inkwell: Sending article statuses") + defer { + Self.logger.info("Inkwell: Finished sending article statuses") + } + + guard let syncStatuses = try await syncDatabase.selectForProcessing() else { + return + } + + let createUnreadStatuses = Array(syncStatuses.filter { $0.key == SyncStatus.Key.read && $0.flag == false }) + try await sendArticleStatuses(createUnreadStatuses, apiCall: caller.createUnreadEntries) + + let deleteUnreadStatuses = Array(syncStatuses.filter { $0.key == SyncStatus.Key.read && $0.flag == true }) + try await sendArticleStatuses(deleteUnreadStatuses, apiCall: caller.deleteUnreadEntries) + + let createStarredStatuses = Array(syncStatuses.filter { $0.key == SyncStatus.Key.starred && $0.flag == true }) + try await sendArticleStatuses(createStarredStatuses, apiCall: caller.createStarredEntries) + + let deleteStarredStatuses = Array(syncStatuses.filter { $0.key == SyncStatus.Key.starred && $0.flag == false }) + try await sendArticleStatuses(deleteStarredStatuses, apiCall: caller.deleteStarredEntries) + } + + func refreshArticleStatus(for account: Account) async throws { + Self.logger.info("Inkwell: Refreshing article statuses") + var refreshError: Error? + + do { + let articleIDs = try await caller.retrieveUnreadEntries() + await syncArticleReadState(account: account, articleIDs: articleIDs) + } catch { + refreshError = error + Self.logger.error("Inkwell: Retrieving unread entries failed: \(error.localizedDescription)") + } + + do { + let articleIDs = try await caller.retrieveStarredEntries() + await syncArticleStarredState(account: account, articleIDs: articleIDs) + } catch { + refreshError = error + Self.logger.error("Inkwell: Retrieving starred entries failed: \(error.localizedDescription)") + } + + Self.logger.info("Inkwell: Finished refreshing article statuses") + if let refreshError { + throw refreshError + } + } + + func importOPML(for account: Account, opmlFile: URL) async throws { + throw AccountError.invalidParameter + } + + func createFolder(for account: Account, name: String) async throws -> Folder { + throw AccountError.invalidParameter + } + + func renameFolder(for account: Account, with folder: Folder, to name: String) async throws { + throw AccountError.invalidParameter + } + + func removeFolder(for account: Account, with folder: Folder) async throws { + throw AccountError.invalidParameter + } + + @discardableResult + func createFeed(for account: Account, url urlString: String, name: String?, container: Container, validateFeed: Bool) async throws -> Feed { + guard container is Account else { + throw AccountError.invalidParameter + } + + refreshProgress.addTask() + defer { + refreshProgress.completeTask() + } + + do { + let subResult = try await caller.createSubscription(url: urlString) + switch subResult { + case .created(let subscription): + return try await createFeed(account: account, subscription: subscription, name: name, container: container) + case .multipleChoice(let choices): + return try await decideBestFeedChoice(account: account, url: urlString, name: name, container: container, choices: choices) + case .alreadySubscribed: + throw AccountError.createErrorAlreadySubscribed + case .notFound: + throw AccountError.createErrorNotFound + } + } catch { + throw AccountError.wrapped(error, account) + } + } + + func renameFeed(for account: Account, with feed: Feed, to name: String) async throws { + guard let subscriptionID = feed.externalID else { + throw AccountError.invalidParameter + } + + refreshProgress.addTask() + defer { + refreshProgress.completeTask() + } + + do { + try await caller.renameSubscription(subscriptionID: subscriptionID, newName: name) + feed.editedName = name + } catch { + throw AccountError.wrapped(error, account) + } + } + + func addFeed(account: Account, feed: Feed, container: Container) async throws { + guard let account = container as? Account else { + throw AccountError.invalidParameter + } + + account.addFeedIfNotInAnyFolder(feed) + } + + func removeFeed(account: Account, feed: Feed, container: Container) async throws { + try await deleteSubscription(for: account, with: feed) + } + + func moveFeed(account: Account, feed: Feed, sourceContainer: Container, destinationContainer: Container) async throws { + guard destinationContainer is Account else { + throw AccountError.invalidParameter + } + } + + func restoreFeed(for account: Account, feed: Feed, container: Container) async throws { + guard container is Account else { + throw AccountError.invalidParameter + } + + if let existingFeed = account.existingFeed(withURL: feed.url) { + try await account.addFeed(existingFeed, container: container) + } else { + try await createFeed(for: account, url: feed.url, name: feed.editedName, container: container, validateFeed: true) + } + } + + func restoreFolder(for account: Account, folder: Folder) async throws { + throw AccountError.invalidParameter + } + + func markArticles(for account: Account, articles: Set
, statusKey: ArticleStatus.Key, flag: Bool) async throws { + let articles = try await account.updateAsync(articles: articles, statusKey: statusKey, flag: flag) + let syncStatuses = Set(articles.map { article in + SyncStatus(articleID: article.articleID, key: SyncStatus.Key(statusKey), flag: flag) + }) + + try await syncDatabase.insertStatuses(syncStatuses) + if let count = try? await syncDatabase.selectPendingCount(), count > 100 { + try await sendArticleStatus(for: account) + } + } + + func accountDidInitialize(_ account: Account) { + initializedAccount = account + credentials = try? account.retrieveCredentials(type: .bearerAccessToken) + } + + func accountWillBeDeleted(_ account: Account) { + } + + static func validateCredentials(transport: Transport, credentials: Credentials, endpoint: URL?) async throws -> Credentials? { + let caller = InkwellAPICaller(transport: transport) + caller.credentials = credentials + return try await caller.validateCredentials() + } + + func suspendNetwork() { + caller.suspend() + } + + func suspendDatabase() { + syncDatabase.suspend() + } + + func resume(account: Account) { + if credentials == nil { + credentials = try? account.retrieveCredentials(type: .bearerAccessToken) + } + caller.resume() + syncDatabase.resume() + } + + @objc func progressInfoDidChange(_ notification: Notification) { + progressInfo = refreshProgress.progressInfo + } +} + +extension InkwellAccountDelegate: InkwellAPICallerDelegate { + func inkwellAPICaller(_ caller: InkwellAPICaller, store credentials: Credentials) throws { + guard let initializedAccount else { + throw CredentialsError.missingAccessToken + } + + try initializedAccount.storeCredentials(credentials) + } +} + +private extension InkwellAccountDelegate { + func refreshAccount(_ account: Account) async throws { + let subscriptions = try await caller.retrieveSubscriptions() + + BatchUpdate.shared.perform { + syncFeeds(account, subscriptions) + } + + refreshProgress.completeTask() + } + + func refreshArticlesAndStatuses(_ account: Account) async throws { + try await sendArticleStatus(for: account) + try await refreshArticleStatus(for: account) + try await refreshArticles(account) + try await refreshMissingArticles(account) + refreshProgress.reset() + } + + func syncFeeds(_ account: Account, _ subscriptions: [FeedbinSubscription]?) { + guard let subscriptions else { + return + } + assert(Thread.isMainThread) + + Self.logger.info("Inkwell: Syncing feeds with \(subscriptions.count) subscriptions") + + let subscriptionFeedIDs = subscriptions.map { String($0.feedID) } + + for feed in account.topLevelFeeds { + if !subscriptionFeedIDs.contains(feed.feedID) { + account.removeFeedFromTreeAtTopLevel(feed) + } + } + + var subscriptionsToAdd = Set() + for subscription in subscriptions { + let subscriptionFeedID = String(subscription.feedID) + + if let feed = account.existingFeed(withFeedID: subscriptionFeedID) { + feed.name = subscription.name + feed.editedName = nil + feed.homePageURL = subscription.homePageURL + feed.externalID = String(subscription.subscriptionID) + feed.faviconURL = subscription.jsonFeed?.favicon + feed.iconURL = subscription.jsonFeed?.icon + } else { + subscriptionsToAdd.insert(subscription) + } + } + + for subscription in subscriptionsToAdd { + let feed = account.createFeed(with: subscription.name, url: subscription.url, feedID: String(subscription.feedID), homePageURL: subscription.homePageURL) + feed.externalID = String(subscription.subscriptionID) + account.addFeedToTreeAtTopLevel(feed) + } + } + + func sendArticleStatuses(_ statuses: [SyncStatus], apiCall: ([Int]) async throws -> Void) async throws { + guard !statuses.isEmpty else { + return + } + + var savedError: Error? + + let articleIDs = statuses.compactMap { Int($0.articleID) } + let articleIDGroups = articleIDs.chunked(into: 1000) + for articleIDGroup in articleIDGroups { + do { + try await apiCall(articleIDGroup) + try? await syncDatabase.deleteSelectedForProcessing(Set(articleIDGroup.map(String.init))) + } catch { + savedError = error + Self.logger.error("Inkwell: Article status sync call failed: \(error.localizedDescription)") + try? await syncDatabase.resetSelectedForProcessing(Set(articleIDGroup.map(String.init))) + } + } + + if let savedError { + throw savedError + } + } + + func decideBestFeedChoice(account: Account, url: String, name: String?, container: Container, choices: [FeedbinSubscriptionChoice]) async throws -> Feed { + var orderFound = 0 + + let feedSpecifiers: [FeedSpecifier] = choices.map { choice in + let source = url == choice.url ? FeedSpecifier.Source.userEntered : FeedSpecifier.Source.HTMLLink + orderFound += 1 + return FeedSpecifier(title: choice.name, urlString: choice.url, source: source, orderFound: orderFound) + } + + if let bestSpecifier = FeedSpecifier.bestFeed(in: Set(feedSpecifiers)) { + return try await createFeed(for: account, url: bestSpecifier.urlString, name: name, container: container, validateFeed: true) + } else { + throw AccountError.invalidParameter + } + } + + @discardableResult + func createFeed(account: Account, subscription: FeedbinSubscription, name: String?, container: Container) async throws -> Feed { + let feed = account.createFeed(with: subscription.name, url: subscription.url, feedID: String(subscription.feedID), homePageURL: subscription.homePageURL) + feed.externalID = String(subscription.subscriptionID) + feed.iconURL = subscription.jsonFeed?.icon + feed.faviconURL = subscription.jsonFeed?.favicon + + try await account.addFeed(feed, container: container) + if let name { + try await account.renameFeed(feed, name: name) + } + + Task { + try? await initialFeedDownload(account: account, feed: feed) + } + + return feed + } + + func initialFeedDownload(account: Account, feed: Feed) async throws -> Feed { + let (entries, page) = try await caller.retrieveEntries(feedID: feed.feedID) + try await processEntries(account: account, entries: entries) + try await refreshArticleStatus(for: account) + try await refreshArticles(account, page: page, updateFetchDate: nil) + try await refreshMissingArticles(account) + + return feed + } + + func refreshArticles(_ account: Account) async throws { + Self.logger.info("Inkwell: Refreshing articles") + + let (entries, page, updateFetchDate, lastPageNumber) = try await caller.retrieveEntries() + + if let last = lastPageNumber { + refreshProgress.addTasks(last - 1) + } + + try await processEntries(account: account, entries: entries) + refreshProgress.completeTask() + + try await refreshArticles(account, page: page, updateFetchDate: updateFetchDate) + } + + func refreshMissingArticles(_ account: Account) async throws { + Self.logger.info("Inkwell: Refreshing missing articles") + defer { + refreshProgress.completeTask() + Self.logger.info("Inkwell: Finished refreshing missing articles") + } + + var savedError: Error? + + do { + let fetchedArticleIDs = try await account.fetchArticleIDsForStatusesWithoutArticlesNewerThanCutoffDateAsync() + let articleIDs = Array(fetchedArticleIDs) + let chunkedArticleIDs = articleIDs.chunked(into: 100) + + for chunk in chunkedArticleIDs { + do { + let entries = try await caller.retrieveEntries(articleIDs: chunk) + try await processEntries(account: account, entries: entries) + } catch { + savedError = error + Self.logger.error("Inkwell: Refresh missing articles error: \(error.localizedDescription)") + } + } + } catch { + savedError = error + Self.logger.error("Inkwell: Refresh missing articles error: \(error.localizedDescription)") + } + + if let savedError { + throw savedError + } + } + + func refreshArticles(_ account: Account, page: String?, updateFetchDate: Date?) async throws { + guard let page else { + if let lastArticleFetch = updateFetchDate { + accountSettings?.lastArticleFetchStartTime = lastArticleFetch + accountSettings?.lastRefreshCompletedDate = Date() + } + return + } + + let (entries, nextPage) = try await caller.retrieveEntries(page: page) + + try await processEntries(account: account, entries: entries) + refreshProgress.completeTask() + + try await refreshArticles(account, page: nextPage, updateFetchDate: updateFetchDate) + } + + func processEntries(account: Account, entries: [FeedbinEntry]?) async throws { + let parsedItems = mapEntriesToParsedItems(entries: entries) + let feedIDsAndItems = Dictionary(grouping: parsedItems, by: \.feedURL).mapValues(Set.init) + try await account.updateAsync(feedIDsAndItems: feedIDsAndItems, defaultRead: true) + } + + func mapEntriesToParsedItems(entries: [FeedbinEntry]?) -> Set { + guard let entries else { + return [] + } + + let parsedItems: [ParsedItem] = entries.map { entry in + let authors = Set([ParsedAuthor(name: entry.authorName, url: entry.jsonFeed?.jsonFeedAuthor?.url, avatarURL: entry.jsonFeed?.jsonFeedAuthor?.avatarURL, emailAddress: nil)]) + return ParsedItem(syncServiceID: String(entry.articleID), uniqueID: String(entry.articleID), feedURL: String(entry.feedID), url: entry.url, externalURL: entry.jsonFeed?.jsonFeedExternalURL, title: entry.title, language: nil, contentHTML: entry.contentHTML, contentText: nil, markdown: nil, summary: entry.summary, imageURL: nil, bannerImageURL: nil, datePublished: entry.parsedDatePublished, dateModified: nil, authors: authors, tags: nil, attachments: nil) + } + + return Set(parsedItems) + } + + func syncArticleReadState(account: Account, articleIDs: [Int]?) async { + guard let articleIDs else { + return + } + + do { + guard let pendingArticleIDs = try? await syncDatabase.selectPendingReadStatusArticleIDs() else { + return + } + + let inkwellUnreadArticleIDs = Set(articleIDs.map(String.init)) + let updatableUnreadArticleIDs = inkwellUnreadArticleIDs.subtracting(pendingArticleIDs) + + let currentUnreadArticleIDs = try await account.fetchUnreadArticleIDsAsync() + + let deltaUnreadArticleIDs = updatableUnreadArticleIDs.subtracting(currentUnreadArticleIDs) + try await account.markAsUnreadAsync(articleIDs: deltaUnreadArticleIDs) + + let deltaReadArticleIDs = currentUnreadArticleIDs.subtracting(updatableUnreadArticleIDs) + try await account.markAsReadAsync(articleIDs: deltaReadArticleIDs) + } catch { + Self.logger.error("Inkwell: Sync article read status failed: \(error.localizedDescription)") + } + } + + func syncArticleStarredState(account: Account, articleIDs: [Int]?) async { + guard let articleIDs else { + return + } + + do { + guard let pendingArticleIDs = try? await syncDatabase.selectPendingStarredStatusArticleIDs() else { + return + } + + let inkwellStarredArticleIDs = Set(articleIDs.map(String.init)) + let updatableStarredArticleIDs = inkwellStarredArticleIDs.subtracting(pendingArticleIDs) + + let currentStarredArticleIDs = try await account.fetchStarredArticleIDsAsync() + + let deltaStarredArticleIDs = updatableStarredArticleIDs.subtracting(currentStarredArticleIDs) + try await account.markAsStarredAsync(articleIDs: deltaStarredArticleIDs) + + let deltaUnstarredArticleIDs = currentStarredArticleIDs.subtracting(updatableStarredArticleIDs) + try await account.markAsUnstarredAsync(articleIDs: deltaUnstarredArticleIDs) + } catch { + Self.logger.error("Inkwell: Sync article starred status failed: \(error.localizedDescription)") + } + } + + func deleteSubscription(for account: Account, with feed: Feed) async throws { + guard let subscriptionID = feed.externalID else { + throw AccountError.invalidParameter + } + + refreshProgress.addTask() + defer { + refreshProgress.completeTask() + } + + do { + try await caller.deleteSubscription(subscriptionID: subscriptionID) + } catch { + Self.logger.error("Inkwell: Unable to remove feed from Inkwell. Removing locally and continuing processing: \(error.localizedDescription)") + } + + account.removeAllInstancesOfFeedFromTreeAtAllLevels(feed) + } +} diff --git a/Modules/Account/Sources/Account/URLRequest+Account.swift b/Modules/Account/Sources/Account/URLRequest+Account.swift index f222c0e466..99c885b731 100755 --- a/Modules/Account/Sources/Account/URLRequest+Account.swift +++ b/Modules/Account/Sources/Account/URLRequest+Account.swift @@ -50,6 +50,9 @@ public extension URLRequest { case .readerAPIKey: let auth = "GoogleLogin auth=\(credentials.secret)" setValue(auth, forHTTPHeaderField: HTTPRequestHeader.authorization) + case .bearerAccessToken: + let auth = "Bearer \(credentials.secret)" + setValue(auth, forHTTPHeaderField: HTTPRequestHeader.authorization) case .oauthAccessToken: let auth = "OAuth \(credentials.secret)" setValue(auth, forHTTPHeaderField: "Authorization") diff --git a/Modules/Account/Tests/AccountTests/Inkwell/AccountInkwellSyncTest.swift b/Modules/Account/Tests/AccountTests/Inkwell/AccountInkwellSyncTest.swift new file mode 100644 index 0000000000..03f8c1471b --- /dev/null +++ b/Modules/Account/Tests/AccountTests/Inkwell/AccountInkwellSyncTest.swift @@ -0,0 +1,31 @@ +// +// AccountInkwellSyncTest.swift +// AccountTests +// +// Created by Manton Reece on 3/11/26. +// + +import XCTest +@testable import Account + +@MainActor final class AccountInkwellSyncTest: XCTestCase { + func testDownloadSync() async throws { + let testTransport = TestTransport() + testTransport.testFiles["https://micro.blog/feeds/v2/subscriptions.json"] = "JSON/subscriptions_initial.json" + let account = TestAccountManager.shared.createAccount(type: .inkwell, transport: testTransport) + + try await account.refreshAll() + + XCTAssertEqual(224, account.flattenedFeeds().count) + XCTAssertEqual(0, account.folders?.count ?? 0) + XCTAssertTrue(account.behaviors.contains(.disallowFolderManagement)) + XCTAssertTrue(account.behaviors.contains(.disallowOPMLImports)) + + let daringFireball = account.idToFeedDictionary["1296379"] + XCTAssertEqual("Daring Fireball", daringFireball?.name) + XCTAssertEqual("https://daringfireball.net/feeds/json", daringFireball?.url) + XCTAssertEqual("https://daringfireball.net/", daringFireball?.homePageURL) + + TestAccountManager.shared.deleteAccount(account) + } +} diff --git a/Modules/Account/Tests/AccountTests/Inkwell/InkwellAPICallerTests.swift b/Modules/Account/Tests/AccountTests/Inkwell/InkwellAPICallerTests.swift new file mode 100644 index 0000000000..7058333dbf --- /dev/null +++ b/Modules/Account/Tests/AccountTests/Inkwell/InkwellAPICallerTests.swift @@ -0,0 +1,171 @@ +// +// InkwellAPICallerTests.swift +// AccountTests +// +// Created by Manton Reece on 3/11/26. +// + +import XCTest +import RSWeb +import Secrets +@testable import Account + +final class InkwellAPICallerTests: XCTestCase { + func testBearerCredentialsUseBearerAuthorizationHeader() { + let credentials = Credentials(type: .bearerAccessToken, username: "manton", secret: "ABCDEF") + let request = URLRequest(url: URL(string: "https://micro.blog/feeds/v2/subscriptions.json")!, credentials: credentials) + + XCTAssertEqual("Bearer ABCDEF", request.value(forHTTPHeaderField: HTTPRequestHeader.authorization)) + } + + @MainActor func testVerifyAccessTokenUsesFormEncodedBody() async throws { + let transport = RecordingTransport() + let caller = InkwellAPICaller(transport: transport) + + let response = try await caller.verifyAccessToken("ABCDEF") + + XCTAssertEqual("POST", transport.lastMethod) + XCTAssertEqual(MimeType.formURLEncoded, transport.lastRequest?.value(forHTTPHeaderField: HTTPRequestHeader.contentType)) + let body = String(data: transport.lastRequestBody ?? Data(), encoding: .utf8) + XCTAssertEqual("token=ABCDEF", body) + XCTAssertEqual("NEW-TOKEN", response.token) + XCTAssertTrue(response.hasInkwell) + XCTAssertEqual("manton", response.username) + } + + @MainActor func testRetrieveSubscriptionsRefreshesBearerTokenAfterUnauthorized() async throws { + let transport = RefreshingTransport() + let caller = InkwellAPICaller(transport: transport) + caller.credentials = Credentials(type: .bearerAccessToken, username: "manton", secret: "OLD-TOKEN") + + let subscriptions = try await caller.retrieveSubscriptions() + + XCTAssertEqual(1, subscriptions?.count) + XCTAssertEqual(1, transport.verifyRequestCount) + XCTAssertEqual(["Bearer OLD-TOKEN", "Bearer NEW-TOKEN"], transport.subscriptionAuthorizationHeaders) + XCTAssertEqual("NEW-TOKEN", caller.credentials?.secret) + } +} + +private final class RecordingTransport: Transport, @unchecked Sendable { + nonisolated(unsafe) var lastRequest: URLRequest? + nonisolated(unsafe) var lastRequestBody: Data? + nonisolated(unsafe) var lastMethod: String? + + func cancelAll() { + } + + func send(request: URLRequest) async throws -> (HTTPURLResponse, Data?) { + throw TransportError.noData + } + + func send(request: URLRequest, completion: @escaping @Sendable (Result<(HTTPURLResponse, Data?), Error>) -> Void) { + completion(.failure(TransportError.noData)) + } + + func send(request: URLRequest, method: String) async throws { + throw TransportError.noData + } + + func send(request: URLRequest, method: String, completion: @escaping @Sendable (Result) -> Void) { + completion(.failure(TransportError.noData)) + } + + func send(request: URLRequest, method: String, payload: Data) async throws -> (HTTPURLResponse, Data?) { + lastRequest = request + lastRequestBody = payload + lastMethod = method + + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)! + let data = """ + {"token":"NEW-TOKEN","has_inkwell":true,"name":"Manton Reece","username":"manton","avatar":"https://example.com/avatar.png"} + """.data(using: .utf8) + return (response, data) + } + + func send(request: URLRequest, method: String, payload: Data, completion: @escaping @Sendable (Result<(HTTPURLResponse, Data?), Error>) -> Void) { + Task { + do { + let result = try await send(request: request, method: method, payload: payload) + completion(.success(result)) + } catch { + completion(.failure(error)) + } + } + } +} + +private final class RefreshingTransport: Transport, @unchecked Sendable { + nonisolated(unsafe) var subscriptionAuthorizationHeaders = [String]() + nonisolated(unsafe) var verifyRequestCount = 0 + + func cancelAll() { + } + + func send(request: URLRequest) async throws -> (HTTPURLResponse, Data?) { + let url = request.url!.absoluteString + + if url.contains("/feeds/v2/subscriptions.json") { + if let header = request.value(forHTTPHeaderField: HTTPRequestHeader.authorization) { + subscriptionAuthorizationHeaders.append(header) + } + + if subscriptionAuthorizationHeaders.count == 1 { + throw TransportError.httpError(status: HTTPResponseCode.unauthorized) + } + + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)! + let data = """ + [{"id":10,"feed_id":1296379,"title":"Daring Fireball","feed_url":"https://daringfireball.net/feeds/json","site_url":"https://daringfireball.net/"}] + """.data(using: .utf8) + return (response, data) + } + + throw TransportError.noData + } + + func send(request: URLRequest, completion: @escaping @Sendable (Result<(HTTPURLResponse, Data?), Error>) -> Void) { + Task { + do { + let result = try await send(request: request) + completion(.success(result)) + } catch { + completion(.failure(error)) + } + } + } + + func send(request: URLRequest, method: String) async throws { + throw TransportError.noData + } + + func send(request: URLRequest, method: String, completion: @escaping @Sendable (Result) -> Void) { + completion(.failure(TransportError.noData)) + } + + func send(request: URLRequest, method: String, payload: Data) async throws -> (HTTPURLResponse, Data?) { + let url = request.url!.absoluteString + + if url == "https://micro.blog/account/verify" { + verifyRequestCount += 1 + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)! + let data = """ + {"token":"NEW-TOKEN","has_inkwell":true,"name":"Manton Reece","username":"manton","avatar":"https://example.com/avatar.png"} + """.data(using: .utf8) + return (response, data) + } + + throw TransportError.noData + } + + func send(request: URLRequest, method: String, payload: Data, completion: @escaping @Sendable (Result<(HTTPURLResponse, Data?), Error>) -> Void) { + Task { + do { + let result = try await send(request: request, method: method, payload: payload) + completion(.success(result)) + } catch { + completion(.failure(error)) + } + } + } +} diff --git a/Modules/Secrets/Sources/Secrets/Credentials.swift b/Modules/Secrets/Sources/Secrets/Credentials.swift index 5a10b8ab59..ed28fc0b62 100644 --- a/Modules/Secrets/Sources/Secrets/Credentials.swift +++ b/Modules/Secrets/Sources/Secrets/Credentials.swift @@ -24,6 +24,7 @@ public enum CredentialsType: String, Sendable { case newsBlurSessionID = "newsBlurSessionId" case readerBasic = "readerBasic" case readerAPIKey = "readerAPIKey" + case bearerAccessToken = "bearerAccessToken" case oauthAccessToken = "oauthAccessToken" case oauthAccessTokenSecret = "oauthAccessTokenSecret" case oauthRefreshToken = "oauthRefreshToken" diff --git a/Shared/AccountType+Helpers.swift b/Shared/AccountType+Helpers.swift index 84801760c2..0f6c4b7261 100644 --- a/Shared/AccountType+Helpers.swift +++ b/Shared/AccountType+Helpers.swift @@ -32,6 +32,8 @@ extension AccountType { return NSLocalizedString("Feedbin", comment: "Account name") case .feedly: return NSLocalizedString("Feedly", comment: "Account name") + case .inkwell: + return NSLocalizedString("Inkwell", comment: "Account name") case .freshRSS: return NSLocalizedString("FreshRSS", comment: "Account name") case .inoreader: @@ -65,6 +67,8 @@ extension AccountType { return Image("accountFeedbin") case .feedly: return Image("accountFeedly") + case .inkwell: + return Image("accountInkwell") case .freshRSS: return Image("accountFreshRSS") case .inoreader: diff --git a/Shared/Assets.swift b/Shared/Assets.swift index b9b5b53926..ee2a11abda 100644 --- a/Shared/Assets.swift +++ b/Shared/Assets.swift @@ -27,6 +27,7 @@ struct Assets { static var accountCloudKit: RSImage { RSImage(named: "accountCloudKit")! } static var accountFeedbin: RSImage { RSImage(named: "accountFeedbin")! } static var accountFeedly: RSImage { RSImage(named: "accountFeedly")! } + static var accountInkwell: RSImage { RSImage(named: "accountInkwell")! } static var accountFreshRSS: RSImage { RSImage(named: "accountFreshRSS")! } static var accountInoreader: RSImage { RSImage(named: "accountInoreader")! } static var accountNewsBlur: RSImage { RSImage(named: "accountNewsBlur")! } @@ -200,6 +201,8 @@ struct Assets { return Assets.Images.accountFeedbin case .feedly: return Assets.Images.accountFeedly + case .inkwell: + return Assets.Images.accountInkwell case .freshRSS: return Assets.Images.accountFreshRSS case .inoreader: diff --git a/iOS/Inspector/AccountInspectorViewController.swift b/iOS/Inspector/AccountInspectorViewController.swift index 8ac64b9f24..cba7aee4f8 100644 --- a/iOS/Inspector/AccountInspectorViewController.swift +++ b/iOS/Inspector/AccountInspectorViewController.swift @@ -95,7 +95,7 @@ final class AccountInspectorViewController: UITableViewController { let title = NSLocalizedString("Remove Account", comment: "Remove Account") let message: String = { switch account.type { - case .feedly: + case .feedly, .inkwell: return NSLocalizedString("Are you sure you want to remove this account? NetNewsWire will no longer be able to access articles and feeds unless the account is added again.", comment: "Log Out and Remove Account") default: return NSLocalizedString("Are you sure you want to remove this account? This cannot be undone.", comment: "Remove Account") @@ -140,7 +140,7 @@ extension AccountInspectorViewController { return true } switch account.type { - case .onMyMac, .cloudKit, .feedly: + case .onMyMac, .cloudKit, .feedly, .inkwell: return true default: return false diff --git a/iOS/Resources/Assets.xcassets/accountInkwell.imageset/Contents.json b/iOS/Resources/Assets.xcassets/accountInkwell.imageset/Contents.json new file mode 100644 index 0000000000..6b6eefe0d7 --- /dev/null +++ b/iOS/Resources/Assets.xcassets/accountInkwell.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "inkwell_icon_48.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "original" + } +} diff --git a/iOS/Resources/Assets.xcassets/accountInkwell.imageset/inkwell_icon_48.png b/iOS/Resources/Assets.xcassets/accountInkwell.imageset/inkwell_icon_48.png new file mode 100644 index 0000000000..fa7452e2a1 Binary files /dev/null and b/iOS/Resources/Assets.xcassets/accountInkwell.imageset/inkwell_icon_48.png differ diff --git a/iOS/Settings/AddAccountViewController.swift b/iOS/Settings/AddAccountViewController.swift index b45e8e7170..0f1aa0d990 100644 --- a/iOS/Settings/AddAccountViewController.swift +++ b/iOS/Settings/AddAccountViewController.swift @@ -56,9 +56,9 @@ final class AddAccountViewController: UITableViewController, AddAccountDismissDe return [.cloudKit] case .web: #if DEBUG - return [.bazQux, .feedbin, .feedly, .inoreader, .newsBlur, .theOldReader] + return [.bazQux, .feedbin, .feedly, .inkwell, .inoreader, .newsBlur, .theOldReader] #else - return [.bazQux, .feedbin, .feedly, .inoreader, .newsBlur, .theOldReader] + return [.bazQux, .feedbin, .feedly, .inkwell, .inoreader, .newsBlur, .theOldReader] #endif case .selfhosted: return [.freshRSS] @@ -197,6 +197,11 @@ final class AddAccountViewController: UITableViewController, AddAccountDismissDe addAccount.delegate = self addAccount.presentationAnchor = self.view.window! MainThreadOperationQueue.shared.add(addAccount) + case .inkwell: + let addAccount = OAuthAccountAuthorizationOperation(accountType: .inkwell) + addAccount.delegate = self + addAccount.presentationAnchor = self.view.window! + MainThreadOperationQueue.shared.add(addAccount) case .newsBlur: let navController = UIStoryboard.account.instantiateViewController(withIdentifier: "NewsBlurAccountNavigationViewController") as! UINavigationController navController.modalPresentationStyle = .currentContext