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