Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Mac/Preferences/Accounts/AccountsDetailViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ final class AccountsDetailViewController: NSViewController {
accountsNewsBlurWindowController.account = account
accountsNewsBlurWindowController.runSheetOnWindow(window)

case .miniflux:
let accountsMinifluxWindowController = AccountsMinifluxWindowController()
accountsWindowController = accountsMinifluxWindowController
accountsMinifluxWindowController.account = account
accountsMinifluxWindowController.runSheetOnWindow(window)

default:
break
}
Expand Down
304 changes: 304 additions & 0 deletions Mac/Preferences/Accounts/AccountsMiniflux.xib

Large diffs are not rendered by default.

232 changes: 232 additions & 0 deletions Mac/Preferences/Accounts/AccountsMinifluxWindowController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
//
// AccountsMinifluxWindowController.swift
// NetNewsWire
//
// Copyright © 2026 Ranchero Software. All rights reserved.
//

import AppKit
import Account
import RSWeb
import Secrets

final class AccountsMinifluxWindowController: NSWindowController {

@IBOutlet var titleImageView: NSImageView!
@IBOutlet var titleLabel: NSTextField!

@IBOutlet var gridView: NSGridView!
@IBOutlet var progressIndicator: NSProgressIndicator!
@IBOutlet var usernameTextField: NSTextField!
@IBOutlet var apiURLTextField: NSTextField!
@IBOutlet var passwordTextField: NSSecureTextField!
@IBOutlet var apiTokenTextField: NSSecureTextField!
@IBOutlet var authModeControl: NSSegmentedControl!
@IBOutlet var createAccountButton: NSButton!
@IBOutlet var errorMessageLabel: NSTextField!
@IBOutlet var actionButton: NSButton!
@IBOutlet var noAccountTextField: NSTextField!

var account: Account?

private weak var hostWindow: NSWindow?

/// Fixed row indices in `gridView`: API URL, Username, Password, API Token.
private let usernameRowIndex = 1
private let passwordRowIndex = 2
private let apiTokenRowIndex = 3

convenience init() {
self.init(windowNibName: NSNib.Name("AccountsMiniflux"))
}

override func windowDidLoad() {
titleImageView.image = Assets.Images.accountMiniflux
titleLabel.stringValue = NSLocalizedString("Sign in to your Miniflux account.", comment: "Miniflux")
noAccountTextField.stringValue = NSLocalizedString("Don’t have a Miniflux instance?", comment: "No Miniflux")
createAccountButton.title = NSLocalizedString("Find out more", comment: "No Miniflux Button")
apiURLTextField.placeholderString = NSLocalizedString("https://miniflux.example.com", comment: "Miniflux API Helper")

authModeControl.selectedSegment = 0

if let account {
if let tokenCredentials = try? account.retrieveCredentials(type: .minifluxAPIToken) {
apiTokenTextField.stringValue = tokenCredentials.secret
authModeControl.selectedSegment = 1
} else if let basicCredentials = try? account.retrieveCredentials(type: .minifluxBasic) {
usernameTextField.stringValue = basicCredentials.username
authModeControl.selectedSegment = 0
}
apiURLTextField.stringValue = account.endpointURL?.absoluteString ?? ""
actionButton.title = NSLocalizedString("Update", comment: "Update")
} else {
actionButton.title = NSLocalizedString("Create", comment: "Create")
}

updateAuthModeVisibility()
enableAutofill()
apiURLTextField.becomeFirstResponder()
}

// MARK: API

func runSheetOnWindow(_ hostWindow: NSWindow, completion: ((NSApplication.ModalResponse) -> Void)? = nil) {
guard let window else {
return
}

self.hostWindow = hostWindow
hostWindow.beginSheet(window, completionHandler: completion)
}

// MARK: Actions

@IBAction func cancel(_ sender: Any) {
hostWindow!.endSheet(window!, returnCode: NSApplication.ModalResponse.cancel)
}

@IBAction func action(_ sender: Any) {
errorMessageLabel.stringValue = ""

guard let apiURL = normalizedEndpointURL(from: apiURLTextField.stringValue) else {
errorMessageLabel.stringValue = NSLocalizedString("Invalid API URL.", comment: "Invalid API URL")
return
}

let isTokenMode = authModeControl.selectedSegment == 1
let credentials: Credentials

if isTokenMode {
let token = apiTokenTextField.stringValue.trimmingWhitespace
guard !token.isEmpty else {
errorMessageLabel.stringValue = NSLocalizedString("An API token is required.", comment: "Credentials Error")
return
}
credentials = Credentials(type: .minifluxAPIToken, username: "", secret: token)
} else {
let trimmedUsername = usernameTextField.stringValue.trimmingWhitespace
let password = passwordTextField.stringValue
guard !trimmedUsername.isEmpty, !password.isEmpty else {
errorMessageLabel.stringValue = NSLocalizedString("Username and password are required.", comment: "Credentials Error")
return
}
credentials = Credentials(type: .minifluxBasic, username: trimmedUsername, secret: password)
}

Task { @MainActor in
actionButton.isEnabled = false
progressIndicator.isHidden = false
progressIndicator.startAnimation(self)

@MainActor func stopAnimation() {
actionButton.isEnabled = true
progressIndicator.isHidden = true
progressIndicator.stopAnimation(self)
}

do {
let validatedCredentials = try await Account.validateCredentials(type: .miniflux, credentials: credentials, endpoint: apiURL)
stopAnimation()

guard let validatedCredentials else {
errorMessageLabel.stringValue = NSLocalizedString("Invalid username/password combination, or invalid API token.", comment: "Credentials Error")
return
}

guard account != nil || !AccountManager.shared.duplicateServiceAccount(type: .miniflux, endpointURL: apiURL, username: validatedCredentials.username) else {
errorMessageLabel.stringValue = NSLocalizedString("There is already a Miniflux account for this user on this server.", comment: "Duplicate Error")
return
}

if account == nil {
account = AccountManager.shared.createAccount(type: .miniflux)
}

do {
account?.endpointURL = apiURL

// Remove any credentials of the other type so a stale token or
// password doesn't take precedence after switching auth modes.
let staleCredentialsType: CredentialsType = validatedCredentials.type == .minifluxAPIToken ? .minifluxBasic : .minifluxAPIToken
try? account?.removeCredentials(type: staleCredentialsType)
try account?.storeCredentials(validatedCredentials)

hostWindow?.endSheet(window!, returnCode: NSApplication.ModalResponse.OK)

let refreshingAccount = account
Task { @MainActor in
do {
try await refreshingAccount?.refreshAll()
} catch {
NSApplication.shared.presentError(error)
}
}
} catch {
errorMessageLabel.stringValue = NSLocalizedString("Keychain error while storing credentials.", comment: "Credentials Error")
}

} catch {
stopAnimation()
errorMessageLabel.stringValue = error.localizedDescription
}
}
}

@IBAction func createAccountWithProvider(_ sender: Any) {
NSWorkspace.shared.open(URL(string: "https://miniflux.app")!)
}

@IBAction func authModeChanged(_ sender: Any) {
updateAuthModeVisibility()
}

// MARK: Autofill

func enableAutofill() {
usernameTextField.contentType = .username
passwordTextField.contentType = .password
}

// MARK: Auth Mode

private func updateAuthModeVisibility() {
let isTokenMode = authModeControl.selectedSegment == 1
gridView.row(at: usernameRowIndex).isHidden = isTokenMode
gridView.row(at: passwordRowIndex).isHidden = isTokenMode
gridView.row(at: apiTokenRowIndex).isHidden = !isTokenMode
}

// MARK: URL Normalization

/// Trims whitespace, adds a `https://` scheme if none was given, drops a
/// trailing slash, and strips a trailing `/v1` path component in case the
/// user pasted the API URL rather than the instance's base URL —
/// e.g. `https://host/v1/` becomes `https://host`.
private func normalizedEndpointURL(from string: String) -> URL? {
var trimmed = string.trimmingWhitespace
guard !trimmed.isEmpty else {
return nil
}

if !trimmed.contains("://") {
trimmed = "https://" + trimmed
}

if trimmed.hasSuffix("/") {
trimmed = String(trimmed.dropLast())
}
if trimmed.hasSuffix("/v1") {
trimmed = String(trimmed.dropLast(3))
}
if trimmed.hasSuffix("/") {
trimmed = String(trimmed.dropLast())
}

guard !trimmed.isEmpty else {
return nil
}

return URL(string: trimmed)
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,11 @@ extension AccountsPreferencesViewController: AccountsPreferencesAddAccountDelega
let accountsNewsBlurWindowController = AccountsNewsBlurWindowController()
addAccountWindowController = accountsNewsBlurWindowController
accountsNewsBlurWindowController.runSheetOnWindow(window)

case .miniflux:
let accountsMinifluxWindowController = AccountsMinifluxWindowController()
addAccountWindowController = accountsMinifluxWindowController
accountsMinifluxWindowController.runSheetOnWindow(window)
}
}

Expand Down
2 changes: 1 addition & 1 deletion Mac/Preferences/Accounts/AddAccountsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ enum AddAccountSections: Int, CaseIterable {
return [.bazQux, .feedbin, .feedly, .inoreader, .newsBlur, .theOldReader]
}
case .selfhosted:
return [.freshRSS]
return [.freshRSS, .miniflux]
case .allOrdered:
return AddAccountSections.local.sectionContent +
AddAccountSections.icloud.sectionContent +
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"images" : [
{
"filename" : "accountMiniflux.pdf",
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "accountMiniflux-dark.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "original"
}
}
Binary file not shown.
Binary file not shown.
2 changes: 2 additions & 0 deletions Mac/Scripting/Account+Scriptability.swift
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,8 @@ import RSCore
osType = "Bzqx"
case .theOldReader:
osType = "Tord"
case .miniflux:
osType = "Mnfx"
}
return osType.fourCharCode
}
Expand Down
2 changes: 1 addition & 1 deletion Modules/Account/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ let package = Package(
),
.testTarget(
name: "AccountTests",
dependencies: ["Account"],
dependencies: ["Account", "SyncDatabase"],
resources: [
.copy("JSON")
],
Expand Down
23 changes: 23 additions & 0 deletions Modules/Account/Sources/Account/Account.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ nonisolated public enum AccountType: Int, Codable, Sendable {
case inoreader = 21
case bazQux = 22
case theOldReader = 23
case miniflux = 24

public var isDeveloperRestricted: Bool {
return self == .cloudKit || self == .feedbin || self == .feedly || self == .inoreader
Expand All @@ -74,6 +75,8 @@ nonisolated public enum AccountType: Int, Codable, Sendable {
return NSLocalizedString("BazQux", comment: "Account name")
case .theOldReader:
return NSLocalizedString("The Old Reader", comment: "Account name")
case .miniflux:
return "Miniflux"
}
}
}
Expand Down Expand Up @@ -313,6 +316,8 @@ public enum FetchType {
self.delegate = ReaderAPIAccountDelegate(dataFolder: dataFolder, variant: .bazQux)
case .theOldReader:
self.delegate = ReaderAPIAccountDelegate(dataFolder: dataFolder, variant: .theOldReader)
case .miniflux:
self.delegate = MinifluxAccountDelegate(dataFolder: dataFolder)
}

self.accountID = accountID
Expand Down Expand Up @@ -411,6 +416,8 @@ public enum FetchType {
return try await NewsBlurAccountDelegate.validateCredentials(credentials: credentials, endpoint: endpoint)
case .freshRSS, .inoreader, .bazQux, .theOldReader:
return try await ReaderAPIAccountDelegate.validateCredentials(credentials: credentials, endpoint: endpoint)
case .miniflux:
return try await MinifluxAccountDelegate.validateCredentials(credentials: credentials, endpoint: endpoint)
default:
return nil
}
Expand Down Expand Up @@ -1038,6 +1045,22 @@ public enum FetchType {
await database.deleteAsync(articleIDs: articleIDs)
}

/// Delete the statuses for the given articleIDs. Call this only when the sync
/// service has affirmatively reported the articles as deleted server-side.
///
/// Statuses deliberately outlive their articles — they're the memory that stops
/// old articles from reappearing as unread, so no cleanup removes them early.
/// But a status whose article is gone from the service matches the
/// statuses-without-articles query on every sync, and the delegate re-requests
/// an article the service will never return. When the service says the article
/// is deleted, removing the status is correct and ends that loop.
func deleteStatuses(articleIDs: Set<String>) async {
guard !articleIDs.isEmpty else {
return
}
await database.deleteStatusesAsync(articleIDs: articleIDs)
}

/// Empty caches that can reasonably be emptied. Call when the app goes in the background, for instance.
func emptyCaches() {
database.emptyCaches()
Expand Down
Loading
Loading