Merge pull request #2259 from stuartbreckenridge/feature/mac-preferences

Mac Preferences
This commit is contained in:
Maurice Parker
2020-07-15 19:16:19 -05:00
committed by GitHub
24 changed files with 1739 additions and 313 deletions

View File

@@ -65,7 +65,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
private let appNewsURLString = "https://nnw.ranchero.com/feed.json"
private let appMovementMonitor = RSAppMovementMonitor()
#if !MAC_APP_STORE && !TEST
private var softwareUpdater: SPUUpdater!
var softwareUpdater: SPUUpdater!
#endif
override init() {

View File

@@ -35,5 +35,23 @@
<string>$(ORGANIZATION_IDENTIFIER)</string>
<key>DeveloperEntitlements</key>
<string>$(DEVELOPER_ENTITLEMENTS)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>CFBundleURLName</key>
<string>RSS Feed</string>
<key>CFBundleURLSchemes</key>
<array>
<string>feed</string>
<string>feeds</string>
</array>
</dict>
</array>
<key>SUFeedURL</key>
<string>https://ranchero.com/downloads/netnewswire-5.1-beta.xml</string>
<key>FeedURLForTestBuilds</key>
<string>https://ranchero.com/downloads/netnewswire-5.1-beta.xml</string>
</dict>
</plist>

View File

@@ -7,83 +7,90 @@
import SwiftUI
struct MacPreferenceViewModel {
enum PreferencePane: Int, CaseIterable {
case general = 0
case accounts = 1
case advanced = 2
var description: String {
switch self {
case .general:
return "General"
case .accounts:
return "Accounts"
case .advanced:
return "Advanced"
}
}
}
var currentPreferencePane: PreferencePane = PreferencePane.general
enum PreferencePane: Int, CaseIterable {
case general = 0
case accounts = 1
case advanced = 2
var description: String {
switch self {
case .general:
return "General"
case .accounts:
return "Accounts"
case .advanced:
return "Advanced"
}
}
}
struct MacPreferencesView: View {
@EnvironmentObject var defaults: AppDefaults
@State private var viewModel = MacPreferenceViewModel()
var body: some View {
VStack {
if viewModel.currentPreferencePane == .general {
AnyView(GeneralPreferencesView().environmentObject(defaults))
}
else if viewModel.currentPreferencePane == .accounts {
AnyView(AccountsPreferencesView().environmentObject(defaults))
}
else {
AnyView(AdvancedPreferencesView().environmentObject(defaults))
}
}
.toolbar {
ToolbarItem {
Button(action: {
viewModel.currentPreferencePane = .general
}, label: {
Image(systemName: "checkmark.rectangle")
Text("General")
})
}
ToolbarItem {
Button(action: {
viewModel.currentPreferencePane = .accounts
}, label: {
Image(systemName: "network")
Text("Accounts")
})
}
ToolbarItem {
Button(action: {
viewModel.currentPreferencePane = .advanced
}, label: {
Image(systemName: "gearshape.fill")
Text("Advanced")
})
}
}
.presentedWindowToolbarStyle(UnifiedCompactWindowToolbarStyle())
.presentedWindowStyle(TitleBarWindowStyle())
.navigationTitle(Text(viewModel.currentPreferencePane.description))
}
@EnvironmentObject var defaults: AppDefaults
@State private var preferencePane: PreferencePane = .general
var body: some View {
VStack {
switch preferencePane {
case .general:
GeneralPreferencesView()
.environmentObject(defaults)
case .accounts:
AccountsPreferencesView()
.environmentObject(defaults)
case .advanced:
AdvancedPreferencesView()
.environmentObject(defaults)
}
}
.toolbar {
ToolbarItem {
HStack {
Button(action: {
preferencePane = .general
}, label: {
VStack {
Image(systemName: "gearshape")
.font(.title2)
Text("General")
}.foregroundColor(
preferencePane == .general ? Color("AccentColor") : Color.gray
)
}).frame(width: 70)
Button(action: {
preferencePane = .accounts
}, label: {
VStack {
Image(systemName: "at")
.font(.title2)
Text("Accounts")
}.foregroundColor(
preferencePane == .accounts ? Color("AccentColor") : Color.gray
)
}).frame(width: 70)
Button(action: {
preferencePane = .advanced
}, label: {
VStack {
Image(systemName: "scale.3d")
.font(.title2)
Text("Advanced")
}.foregroundColor(
preferencePane == .advanced ? Color("AccentColor") : Color.gray
)
}).frame(width: 70)
}
}
}
}
}
struct MacPreferencesView_Previews: PreviewProvider {
static var previews: some View {
MacPreferencesView()
}
static var previews: some View {
MacPreferencesView()
}
}

View File

@@ -0,0 +1,93 @@
//
// AccountsPreferencesModel.swift
// Multiplatform macOS
//
// Created by Stuart Breckenridge on 13/7/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import Foundation
import Account
import Combine
class AccountsPreferencesModel: ObservableObject {
enum AccountConfigurationSheets {
case add, credentials, none
}
// Selected Account
public private(set) var account: Account?
// All Accounts
@Published var sortedAccounts: [Account] = []
@Published var selectedConfiguredAccountID: String? = AccountManager.shared.defaultAccount.accountID {
didSet {
if let accountID = selectedConfiguredAccountID {
account = sortedAccounts.first(where: { $0.accountID == accountID })
accountIsActive = account?.isActive ?? false
accountName = account?.name ?? ""
}
}
}
@Published var showAddAccountView: Bool = false
var selectedAccountIsDefault: Bool {
guard let selected = selectedConfiguredAccountID else {
return true
}
if selected == AccountManager.shared.defaultAccount.accountID {
return true
}
return false
}
// Edit Account
@Published var accountIsActive: Bool = false {
didSet {
account?.isActive = accountIsActive
}
}
@Published var accountName: String = "" {
didSet {
account?.name = accountName
}
}
// Sheets
@Published var showSheet: Bool = false
@Published var sheetToShow: AccountConfigurationSheets = .none {
didSet {
showSheet = sheetToShow != .none
}
}
@Published var showDeleteConfirmation: Bool = false
// Subscriptions
var notificationSubscriptions = Set<AnyCancellable>()
init() {
sortedAccounts = AccountManager.shared.sortedAccounts
NotificationCenter.default.publisher(for: .UserDidAddAccount).sink(receiveValue: { [weak self] _ in
self?.sortedAccounts = AccountManager.shared.sortedAccounts
}).store(in: &notificationSubscriptions)
NotificationCenter.default.publisher(for: .UserDidDeleteAccount).sink(receiveValue: { [weak self] _ in
self?.selectedConfiguredAccountID = nil
self?.sortedAccounts = AccountManager.shared.sortedAccounts
self?.selectedConfiguredAccountID = AccountManager.shared.defaultAccount.accountID
}).store(in: &notificationSubscriptions)
NotificationCenter.default.publisher(for: .AccountStateDidChange).sink(receiveValue: { [weak self] notification in
guard let account = notification.object as? Account else {
return
}
if account.accountID == self?.account?.accountID {
self?.account = account
self?.accountIsActive = account.isActive
self?.accountName = account.name ?? ""
}
}).store(in: &notificationSubscriptions)
}
}

View File

@@ -0,0 +1,110 @@
//
// AccountsPreferencesView.swift
// macOS
//
// Created by Stuart Breckenridge on 27/6/20.
//
import SwiftUI
import Account
struct AccountsPreferencesView: View {
@StateObject var viewModel = AccountsPreferencesModel()
@State private var hoverOnAdd: Bool = false
@State private var hoverOnRemove: Bool = false
var body: some View {
VStack {
HStack(alignment: .top, spacing: 10) {
listOfAccounts
AccountDetailView(viewModel: viewModel)
.frame(height: 300, alignment: .leading)
}
Spacer()
}
.sheet(isPresented: $viewModel.showSheet,
onDismiss: { viewModel.sheetToShow = .none },
content: {
switch viewModel.sheetToShow {
case .add:
AddAccountView(preferencesModel: viewModel)
case .credentials:
EditAccountCredentialsView(viewModel: viewModel)
case .none:
EmptyView()
}
})
.alert(isPresented: $viewModel.showDeleteConfirmation, content: {
Alert(title: Text("Delete \(viewModel.account!.nameForDisplay)?"),
message: Text("Are you sure you want to delete the account \"\(viewModel.account!.nameForDisplay)\"? This can not be undone."),
primaryButton: .destructive(Text("Delete"), action: {
AccountManager.shared.deleteAccount(viewModel.account!)
viewModel.showDeleteConfirmation = false
}),
secondaryButton: .cancel({
viewModel.showDeleteConfirmation = false
}))
})
}
var listOfAccounts: some View {
VStack(alignment: .leading) {
List(viewModel.sortedAccounts, id: \.accountID, selection: $viewModel.selectedConfiguredAccountID) {
ConfiguredAccountRow(account: $0)
.id($0.accountID)
}.overlay(
Group {
bottomButtonStack
}, alignment: .bottom)
}
.frame(width: 160, height: 300, alignment: .leading)
.border(Color.gray, width: 1)
}
var bottomButtonStack: some View {
VStack(alignment: .leading, spacing: 0) {
Divider()
HStack(alignment: .center, spacing: 4) {
Button(action: {
viewModel.sheetToShow = .add
}, label: {
Image(systemName: "plus")
.font(.title)
.frame(width: 30, height: 30)
.overlay(RoundedRectangle(cornerRadius: 4, style: .continuous)
.foregroundColor(hoverOnAdd ? Color.gray.opacity(0.1) : Color.clear))
.padding(4)
})
.buttonStyle(BorderlessButtonStyle())
.onHover { hovering in
hoverOnAdd = hovering
}
.help("Add Account")
Button(action: {
viewModel.showDeleteConfirmation = true
}, label: {
Image(systemName: "minus")
.font(.title)
.frame(width: 30, height: 30)
.overlay(RoundedRectangle(cornerRadius: 4, style: .continuous)
.foregroundColor(hoverOnRemove ? Color.gray.opacity(0.1) : Color.clear))
.padding(4)
})
.buttonStyle(BorderlessButtonStyle())
.onHover { hovering in
hoverOnRemove = hovering
}
.disabled(viewModel.selectedAccountIsDefault)
.help("Delete Account")
Spacer()
}
.background(Color.init(.windowBackgroundColor))
}
}
}

View File

@@ -0,0 +1,296 @@
//
// AddAccountModel.swift
// Multiplatform macOS
//
// Created by Stuart Breckenridge on 13/7/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import Foundation
import Account
import RSWeb
import Secrets
import RSCore
class AddAccountModel: ObservableObject {
#if DEBUG
let addableAccountTypes: [AccountType] = [.onMyMac, .feedbin, .feedly, .feedWrangler, .freshRSS, .cloudKit, .newsBlur]
#else
let addableAccountTypes: [AccountType] = [.onMyMac, .feedbin, .feedly]
#endif
// Add Accounts
@Published var selectedAddAccount: AccountType = .onMyMac
@Published var userName: String = ""
@Published var password: String = ""
@Published var apiUrl: String = ""
@Published var newLocalAccountName: String = ""
@Published var accountIsAuthenticating: Bool = false
@Published var addAccountError: AccountUpdateErrors = .none {
didSet {
if addAccountError == .none {
showError = false
} else {
showError = true
}
}
}
@Published var showError: Bool = false
@Published var accountAdded: Bool = false
func resetUserEntries() {
userName = ""
password = ""
newLocalAccountName = ""
apiUrl = ""
}
func authenticateAccount() {
switch selectedAddAccount {
case .onMyMac:
addLocalAccount()
case .cloudKit:
authenticateCloudKit()
case .feedbin:
authenticateFeedbin()
case .feedWrangler:
authenticateFeedWrangler()
case .freshRSS:
authenticateFreshRSS()
case .feedly:
authenticateFeedly()
case .newsBlur:
authenticateNewsBlur()
}
}
}
// MARK:- Authentication APIs
extension AddAccountModel {
private func addLocalAccount() {
let account = AccountManager.shared.createAccount(type: .onMyMac)
account.name = newLocalAccountName
accountAdded = true
}
private func authenticateFeedbin() {
accountIsAuthenticating = true
let credentials = Credentials(type: .basic, username: userName, secret: password)
Account.validateCredentials(type: .feedbin, credentials: credentials) { [weak self] result in
guard let self = self else { return }
self.accountIsAuthenticating = false
switch result {
case .success(let validatedCredentials):
guard let validatedCredentials = validatedCredentials else {
self.addAccountError = .invalidUsernamePassword
return
}
let account = AccountManager.shared.createAccount(type: .feedbin)
do {
try account.removeCredentials(type: .basic)
try account.storeCredentials(validatedCredentials)
self.accountAdded = true
account.refreshAll(completion: { result in
switch result {
case .success:
break
case .failure(let error):
self.addAccountError = .other(error: error)
}
})
} catch {
self.addAccountError = .keyChainError
}
case .failure:
self.addAccountError = .networkError
}
}
}
private func authenticateFeedWrangler() {
accountIsAuthenticating = true
let credentials = Credentials(type: .feedWranglerBasic, username: userName, secret: password)
Account.validateCredentials(type: .feedWrangler, credentials: credentials) { [weak self] result in
guard let self = self else { return }
self.accountIsAuthenticating = false
switch result {
case .success(let validatedCredentials):
guard let validatedCredentials = validatedCredentials else {
self.addAccountError = .invalidUsernamePassword
return
}
let account = AccountManager.shared.createAccount(type: .feedWrangler)
do {
try account.removeCredentials(type: .feedWranglerBasic)
try account.removeCredentials(type: .feedWranglerToken)
try account.storeCredentials(credentials)
try account.storeCredentials(validatedCredentials)
self.accountAdded = true
account.refreshAll(completion: { result in
switch result {
case .success:
break
case .failure(let error):
self.addAccountError = .other(error: error)
}
})
} catch {
self.addAccountError = .keyChainError
}
case .failure:
self.addAccountError = .networkError
}
}
}
private func authenticateNewsBlur() {
accountIsAuthenticating = true
let credentials = Credentials(type: .newsBlurBasic, username: userName, secret: password)
Account.validateCredentials(type: .newsBlur, credentials: credentials) { [weak self] result in
guard let self = self else { return }
self.accountIsAuthenticating = false
switch result {
case .success(let validatedCredentials):
guard let validatedCredentials = validatedCredentials else {
self.addAccountError = .invalidUsernamePassword
return
}
let account = AccountManager.shared.createAccount(type: .newsBlur)
do {
try account.removeCredentials(type: .newsBlurBasic)
try account.removeCredentials(type: .newsBlurSessionId)
try account.storeCredentials(credentials)
try account.storeCredentials(validatedCredentials)
self.accountAdded = true
account.refreshAll(completion: { result in
switch result {
case .success:
break
case .failure(let error):
self.addAccountError = .other(error: error)
}
})
} catch {
self.addAccountError = .keyChainError
}
case .failure:
self.addAccountError = .networkError
}
}
}
private func authenticateFreshRSS() {
accountIsAuthenticating = true
let credentials = Credentials(type: .readerBasic, username: userName, secret: password)
Account.validateCredentials(type: .freshRSS, credentials: credentials, endpoint: URL(string: apiUrl)!) { [weak self] result in
guard let self = self else { return }
self.accountIsAuthenticating = false
switch result {
case .success(let validatedCredentials):
guard let validatedCredentials = validatedCredentials else {
self.addAccountError = .invalidUsernamePassword
return
}
let account = AccountManager.shared.createAccount(type: .freshRSS)
do {
try account.removeCredentials(type: .readerBasic)
try account.removeCredentials(type: .readerAPIKey)
try account.storeCredentials(credentials)
try account.storeCredentials(validatedCredentials)
self.accountAdded = true
account.refreshAll(completion: { result in
switch result {
case .success:
break
case .failure(let error):
self.addAccountError = .other(error: error)
}
})
} catch {
self.addAccountError = .keyChainError
}
case .failure:
self.addAccountError = .networkError
}
}
}
private func authenticateCloudKit() {
let _ = AccountManager.shared.createAccount(type: .cloudKit)
self.accountAdded = true
}
private func authenticateFeedly() {
accountIsAuthenticating = true
let addAccount = OAuthAccountAuthorizationOperation(accountType: .feedly)
addAccount.delegate = self
addAccount.presentationAnchor = NSApplication.shared.windows.last
MainThreadOperationQueue.shared.add(addAccount)
}
}
// MARK:- OAuthAccountAuthorizationOperationDelegate
extension AddAccountModel: OAuthAccountAuthorizationOperationDelegate {
func oauthAccountAuthorizationOperation(_ operation: OAuthAccountAuthorizationOperation, didCreate account: Account) {
accountIsAuthenticating = false
accountAdded = true
account.refreshAll { [weak self] result in
switch result {
case .success:
break
case .failure(let error):
self?.addAccountError = .other(error: error)
}
}
}
func oauthAccountAuthorizationOperation(_ operation: OAuthAccountAuthorizationOperation, didFailWith error: Error) {
accountIsAuthenticating = false
addAccountError = .other(error: error)
}
}

View File

@@ -0,0 +1,49 @@
//
// AddAccountPickerRow.swift
// Multiplatform macOS
//
// Created by Stuart Breckenridge on 13/7/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import SwiftUI
import Account
struct AddAccountPickerRow: View {
var accountType: AccountType
var body: some View {
HStack {
if let img = AppAssets.image(for: accountType) {
Image(rsImage: img)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 15, height: 15)
}
switch accountType {
case .onMyMac:
Text(Account.defaultLocalAccountName)
case .cloudKit:
Text("iCloud")
case .feedbin:
Text("Feedbin")
case .feedWrangler:
Text("FeedWrangler")
case .freshRSS:
Text("FreshRSS")
case .feedly:
Text("Feedly")
case .newsBlur:
Text("NewsBlur")
}
}
}
}
struct AddAccountPickerRow_Previews: PreviewProvider {
static var previews: some View {
AddAccountPickerRow(accountType: .onMyMac)
}
}

View File

@@ -0,0 +1,151 @@
//
// AddAccountView.swift
// Multiplatform macOS
//
// Created by Stuart Breckenridge on 13/7/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import SwiftUI
import Account
struct AddAccountView: View {
@Environment(\.presentationMode) private var presentationMode
@ObservedObject var preferencesModel: AccountsPreferencesModel
@StateObject private var viewModel = AddAccountModel()
var body: some View {
VStack(alignment: .leading) {
Text("Add an Account").font(.headline)
Form {
Picker("Account Type",
selection: $viewModel.selectedAddAccount,
content: {
ForEach(0..<viewModel.addableAccountTypes.count, content: { i in
AddAccountPickerRow(accountType: viewModel.addableAccountTypes[i]).tag(viewModel.addableAccountTypes[i])
})
})
switch viewModel.selectedAddAccount {
case .onMyMac:
addLocalAccountView
case .cloudKit:
iCloudAccountView
case .feedbin:
userNameAndPasswordView
case .feedWrangler:
userNameAndPasswordView
case .freshRSS:
userNamePasswordAndAPIUrlView
case .feedly:
oAuthView
case .newsBlur:
userNameAndPasswordView
}
}
Spacer()
HStack {
if viewModel.accountIsAuthenticating {
ProgressView("Adding Account")
}
Spacer()
Button(action: { presentationMode.wrappedValue.dismiss() }, label: {
Text("Cancel")
})
switch viewModel.selectedAddAccount {
case .onMyMac:
Button("Add Account", action: {
viewModel.authenticateAccount()
})
case .feedly:
Button("Authenticate", action: {
viewModel.authenticateAccount()
})
case .cloudKit:
Button("Add Account", action: {
viewModel.authenticateAccount()
})
case .freshRSS:
Button("Add Account", action: {
viewModel.authenticateAccount()
})
.disabled(viewModel.userName.count == 0 || viewModel.password.count == 0 || viewModel.apiUrl.count == 0)
default:
Button("Add Account", action: {
viewModel.authenticateAccount()
})
.disabled(viewModel.userName.count == 0 || viewModel.password.count == 0)
}
}
}
.frame(width: 300, height: 200, alignment: .top)
.padding()
.onChange(of: viewModel.selectedAddAccount) { _ in
viewModel.resetUserEntries()
}
.onChange(of: viewModel.accountAdded) { value in
if value == true {
preferencesModel.showAddAccountView = false
presentationMode.wrappedValue.dismiss()
}
}
.alert(isPresented: $viewModel.showError) {
Alert(title: Text("Error Adding Account"),
message: Text(viewModel.addAccountError.description),
dismissButton: .default(Text("Dismiss"),
action: {
viewModel.addAccountError = .none
}))
}
}
var addLocalAccountView: some View {
Group {
TextField("Account Name", text: $viewModel.newLocalAccountName)
Text("This account stores all of its data on your device. It does not sync.")
.foregroundColor(.secondary)
.multilineTextAlignment(.leading)
}.textFieldStyle(RoundedBorderTextFieldStyle())
}
var iCloudAccountView: some View {
Group {
Text("This account syncs across your devices using your iCloud account.")
.foregroundColor(.secondary)
.multilineTextAlignment(.leading)
}.textFieldStyle(RoundedBorderTextFieldStyle())
}
var userNameAndPasswordView: some View {
Group {
TextField("Email", text: $viewModel.userName)
SecureField("Password", text: $viewModel.password)
}.textFieldStyle(RoundedBorderTextFieldStyle())
}
var userNamePasswordAndAPIUrlView: some View {
Group {
TextField("Email", text: $viewModel.userName)
SecureField("Password", text: $viewModel.password)
TextField("API URL", text: $viewModel.apiUrl)
}.textFieldStyle(RoundedBorderTextFieldStyle())
}
var oAuthView: some View {
Group {
Text("Click Authenticate")
}.textFieldStyle(RoundedBorderTextFieldStyle())
}
}
struct AddAccountView_Previews: PreviewProvider {
static var previews: some View {
AddAccountView(preferencesModel: AccountsPreferencesModel())
}
}

View File

@@ -0,0 +1,29 @@
//
// ConfiguredAccountRow.swift
// Multiplatform macOS
//
// Created by Stuart Breckenridge on 13/7/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import SwiftUI
import Account
struct ConfiguredAccountRow: View {
var account: Account
var body: some View {
HStack(alignment: .center) {
if let img = account.smallIcon?.image {
Image(rsImage: img)
.resizable()
.frame(width: 20, height: 20)
.aspectRatio(contentMode: .fit)
}
Text(account.nameForDisplay)
}.padding(.vertical, 4)
}
}

View File

@@ -0,0 +1,83 @@
//
// AccountDetailView.swift
// Multiplatform macOS
//
// Created by Stuart Breckenridge on 14/7/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import SwiftUI
import Account
import Combine
struct AccountDetailView: View {
@ObservedObject var viewModel: AccountsPreferencesModel
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 8, style: .circular)
.foregroundColor(Color.secondary.opacity(0.1))
.padding(.top, 8)
VStack {
editAccountHeader
if viewModel.account != nil {
editAccountForm
}
Spacer()
}
}
}
var editAccountHeader: some View {
HStack {
Spacer()
Button("Account Information", action: {})
Spacer()
}
.padding([.leading, .trailing, .bottom], 4)
}
var editAccountForm: some View {
Form(content: {
HStack(alignment: .top) {
Text("Type: ")
.frame(width: 50)
VStack(alignment: .leading) {
Text(viewModel.account!.defaultName)
Toggle("Active", isOn: $viewModel.accountIsActive)
}
}
HStack(alignment: .top) {
Text("Name: ")
.frame(width: 50)
VStack(alignment: .leading) {
TextField(viewModel.account!.name ?? "", text: $viewModel.accountName)
.textFieldStyle(RoundedBorderTextFieldStyle())
Text("The name appears in the sidebar. It can be anything you want. You can even use emoji. 🎸")
.foregroundColor(.secondary)
}
}
Spacer()
if viewModel.account?.type != .onMyMac {
HStack {
Spacer()
Button("Credentials", action: {
viewModel.sheetToShow = .credentials
})
Spacer()
}
}
})
.padding()
}
}
struct AccountDetailView_Previews: PreviewProvider {
static var previews: some View {
AccountDetailView(viewModel: AccountsPreferencesModel())
}
}

View File

@@ -0,0 +1,276 @@
//
// EditAccountCredentialsModel.swift
// Multiplatform macOS
//
// Created by Stuart Breckenridge on 14/7/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import Foundation
import Account
import Secrets
import RSCore
class EditAccountCredentialsModel: ObservableObject {
@Published var userName: String = ""
@Published var password: String = ""
@Published var apiUrl: String = ""
@Published var accountIsUpdatingCredentials: Bool = false
@Published var accountCredentialsWereUpdated: Bool = false
@Published var error: AccountUpdateErrors = .none {
didSet {
if error == .none {
showError = false
} else {
showError = true
}
}
}
@Published var showError: Bool = false
func updateAccountCredentials(_ account: Account) {
switch account.type {
case .onMyMac:
return
case .feedbin:
updateFeedbin(account)
case .cloudKit:
return
case .feedWrangler:
updateFeedWrangler(account)
case .feedly:
updateFeedly(account)
case .freshRSS:
updateFreshRSS(account)
case .newsBlur:
updateNewsblur(account)
}
}
func retrieveCredentials(_ account: Account) {
switch account.type {
case .feedbin:
let credentials = try? account.retrieveCredentials(type: .basic)
userName = credentials?.username ?? ""
case .feedWrangler:
let credentials = try? account.retrieveCredentials(type: .feedWranglerBasic)
userName = credentials?.username ?? ""
case .feedly:
return
case .freshRSS:
let credentials = try? account.retrieveCredentials(type: .readerBasic)
userName = credentials?.username ?? ""
case .newsBlur:
let credentials = try? account.retrieveCredentials(type: .newsBlurBasic)
userName = credentials?.username ?? ""
default:
return
}
}
}
// MARK:- Update API
extension EditAccountCredentialsModel {
func updateFeedbin(_ account: Account) {
accountIsUpdatingCredentials = true
let credentials = Credentials(type: .basic, username: userName, secret: password)
Account.validateCredentials(type: .feedbin, credentials: credentials) { [weak self] result in
guard let self = self else { return }
self.accountIsUpdatingCredentials = false
switch result {
case .success(let validatedCredentials):
guard let validatedCredentials = validatedCredentials else {
self.error = .invalidUsernamePassword
return
}
do {
try account.removeCredentials(type: .basic)
try account.storeCredentials(validatedCredentials)
self.accountCredentialsWereUpdated = true
account.refreshAll(completion: { result in
switch result {
case .success:
break
case .failure(let error):
self.error = .other(error: error)
}
})
} catch {
self.error = .keyChainError
}
case .failure:
self.error = .networkError
}
}
}
func updateFeedWrangler(_ account: Account) {
accountIsUpdatingCredentials = true
let credentials = Credentials(type: .feedWranglerBasic, username: userName, secret: password)
Account.validateCredentials(type: .feedWrangler, credentials: credentials) { [weak self] result in
guard let self = self else { return }
self.accountIsUpdatingCredentials = false
switch result {
case .success(let validatedCredentials):
guard let validatedCredentials = validatedCredentials else {
self.error = .invalidUsernamePassword
return
}
do {
try account.removeCredentials(type: .feedWranglerBasic)
try account.removeCredentials(type: .feedWranglerToken)
try account.storeCredentials(credentials)
try account.storeCredentials(validatedCredentials)
self.accountCredentialsWereUpdated = true
account.refreshAll(completion: { result in
switch result {
case .success:
break
case .failure(let error):
self.error = .other(error: error)
}
})
} catch {
self.error = .keyChainError
}
case .failure:
self.error = .networkError
}
}
}
func updateFeedly(_ account: Account) {
accountIsUpdatingCredentials = true
let updateAccount = OAuthAccountAuthorizationOperation(accountType: .feedly)
updateAccount.delegate = self
updateAccount.presentationAnchor = NSApplication.shared.windows.last
MainThreadOperationQueue.shared.add(updateAccount)
}
func updateFreshRSS(_ account: Account) {
accountIsUpdatingCredentials = true
let credentials = Credentials(type: .readerBasic, username: userName, secret: password)
Account.validateCredentials(type: .freshRSS, credentials: credentials) { [weak self] result in
guard let self = self else { return }
self.accountIsUpdatingCredentials = false
switch result {
case .success(let validatedCredentials):
guard let validatedCredentials = validatedCredentials else {
self.error = .invalidUsernamePassword
return
}
do {
try account.removeCredentials(type: .readerBasic)
try account.removeCredentials(type: .readerAPIKey)
try account.storeCredentials(credentials)
try account.storeCredentials(validatedCredentials)
self.accountCredentialsWereUpdated = true
account.refreshAll(completion: { result in
switch result {
case .success:
break
case .failure(let error):
self.error = .other(error: error)
}
})
} catch {
self.error = .keyChainError
}
case .failure:
self.error = .networkError
}
}
}
func updateNewsblur(_ account: Account) {
accountIsUpdatingCredentials = true
let credentials = Credentials(type: .newsBlurBasic, username: userName, secret: password)
Account.validateCredentials(type: .newsBlur, credentials: credentials) { [weak self] result in
guard let self = self else { return }
self.accountIsUpdatingCredentials = false
switch result {
case .success(let validatedCredentials):
guard let validatedCredentials = validatedCredentials else {
self.error = .invalidUsernamePassword
return
}
do {
try account.removeCredentials(type: .newsBlurBasic)
try account.removeCredentials(type: .newsBlurSessionId)
try account.storeCredentials(credentials)
try account.storeCredentials(validatedCredentials)
self.accountCredentialsWereUpdated = true
account.refreshAll(completion: { result in
switch result {
case .success:
break
case .failure(let error):
self.error = .other(error: error)
}
})
} catch {
self.error = .keyChainError
}
case .failure:
self.error = .networkError
}
}
}
}
// MARK:- OAuthAccountAuthorizationOperationDelegate
extension EditAccountCredentialsModel: OAuthAccountAuthorizationOperationDelegate {
func oauthAccountAuthorizationOperation(_ operation: OAuthAccountAuthorizationOperation, didCreate account: Account) {
accountIsUpdatingCredentials = false
accountCredentialsWereUpdated = true
account.refreshAll { [weak self] result in
switch result {
case .success:
break
case .failure(let error):
self?.error = .other(error: error)
}
}
}
func oauthAccountAuthorizationOperation(_ operation: OAuthAccountAuthorizationOperation, didFailWith error: Error) {
accountIsUpdatingCredentials = false
self.error = .other(error: error)
}
}

View File

@@ -0,0 +1,95 @@
//
// EditAccountCredentialsView.swift
// Multiplatform macOS
//
// Created by Stuart Breckenridge on 14/7/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import SwiftUI
import Secrets
struct EditAccountCredentialsView: View {
@Environment(\.presentationMode) var presentationMode
@StateObject private var editModel = EditAccountCredentialsModel()
@ObservedObject var viewModel: AccountsPreferencesModel
var body: some View {
Form {
HStack {
Spacer()
Image(rsImage: viewModel.account!.smallIcon!.image)
.resizable()
.frame(width: 30, height: 30)
Text(viewModel.account?.nameForDisplay ?? "")
Spacer()
}.padding()
HStack(alignment: .center) {
VStack(alignment: .trailing, spacing: 12) {
Text("Username: ")
Text("Password: ")
if viewModel.account?.type == .freshRSS {
Text("API URL: ")
}
}.frame(width: 75)
VStack(alignment: .leading, spacing: 12) {
TextField("Username", text: $editModel.userName)
SecureField("Password", text: $editModel.password)
if viewModel.account?.type == .freshRSS {
TextField("API URL", text: $editModel.apiUrl)
}
}
}.textFieldStyle(RoundedBorderTextFieldStyle())
Spacer()
HStack{
if editModel.accountIsUpdatingCredentials {
ProgressView("Updating")
}
Spacer()
Button("Cancel", action: {
presentationMode.wrappedValue.dismiss()
})
if viewModel.account?.type != .freshRSS {
Button("Update", action: {
editModel.updateAccountCredentials(viewModel.account!)
}).disabled(editModel.userName.count == 0 || editModel.password.count == 0)
} else {
Button("Update", action: {
editModel.updateAccountCredentials(viewModel.account!)
}).disabled(editModel.userName.count == 0 || editModel.password.count == 0 || editModel.apiUrl.count == 0)
}
}
}.onAppear {
editModel.retrieveCredentials(viewModel.account!)
}
.onChange(of: editModel.accountCredentialsWereUpdated) { value in
if value == true {
viewModel.sheetToShow = .none
presentationMode.wrappedValue.dismiss()
}
}
.alert(isPresented: $editModel.showError) {
Alert(title: Text("Error Adding Account"),
message: Text(editModel.error.description),
dismissButton: .default(Text("Dismiss"),
action: {
editModel.error = .none
}))
}
.frame(idealWidth: 300, idealHeight: 200, alignment: .top)
.padding()
}
}
struct EditAccountCredentials_Previews: PreviewProvider {
static var previews: some View {
EditAccountCredentialsView(viewModel: AccountsPreferencesModel())
}
}

View File

@@ -0,0 +1,18 @@
//
// AccountManagement.swift
// Multiplatform macOS
//
// Created by Stuart Breckenridge on 14/7/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import Foundation
protocol AccountManagement {
}

View File

@@ -0,0 +1,39 @@
//
// AccountUpdateErrors.swift
// Multiplatform macOS
//
// Created by Stuart Breckenridge on 14/7/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import Foundation
enum AccountUpdateErrors: CustomStringConvertible {
case invalidUsernamePassword, invalidUsernamePasswordAPI, networkError, keyChainError, other(error: Error) , none
var description: String {
switch self {
case .invalidUsernamePassword:
return NSLocalizedString("Invalid email or password combination.", comment: "Invalid email/password combination.")
case .invalidUsernamePasswordAPI:
return NSLocalizedString("Invalid email, password, or API URL combination.", comment: "Invalid email/password/API combination.")
case .networkError:
return NSLocalizedString("Network Error. Please try later.", comment: "Network Error. Please try later.")
case .keyChainError:
return NSLocalizedString("Keychain error while storing credentials.", comment: "Credentials Error")
case .other(let error):
return NSLocalizedString(error.localizedDescription, comment: "Other add account error")
default:
return NSLocalizedString("N/A", comment: "N/A")
}
}
static func ==(lhs: AccountUpdateErrors, rhs: AccountUpdateErrors) -> Bool {
switch (lhs, rhs) {
case (.other(let lhsError), .other(let rhsError)):
return lhsError.localizedDescription == rhsError.localizedDescription
default:
return false
}
}
}

View File

@@ -0,0 +1,32 @@
//
// AdvancedPreferencesModel.swift
// Multiplatform macOS
//
// Created by Stuart Breckenridge on 16/7/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import Foundation
class AdvancedPreferencesModel: ObservableObject {
let releaseBuildsURL = Bundle.main.infoDictionary!["SUFeedURL"]! as! String
let testBuildsURL = Bundle.main.infoDictionary!["FeedURLForTestBuilds"]! as! String
let appcastDefaultsKey = "SUFeedURL"
init() {
if AppDefaults.shared.downloadTestBuilds == false {
AppDefaults.store.setValue(releaseBuildsURL, forKey: appcastDefaultsKey)
} else {
AppDefaults.store.setValue(testBuildsURL, forKey: appcastDefaultsKey)
}
}
func updateAppcast() {
if AppDefaults.shared.downloadTestBuilds == false {
AppDefaults.store.setValue(releaseBuildsURL, forKey: appcastDefaultsKey)
} else {
AppDefaults.store.setValue(testBuildsURL, forKey: appcastDefaultsKey)
}
}
}

View File

@@ -10,39 +10,36 @@ import SwiftUI
struct AdvancedPreferencesView: View {
@EnvironmentObject private var preferences: AppDefaults
@StateObject private var viewModel = AdvancedPreferencesModel()
var body: some View {
VStack {
Form {
Toggle("Check for app updates automatically", isOn: $preferences.checkForUpdatesAutomatically)
Toggle("Download Test Builds", isOn: $preferences.downloadTestBuilds)
Text("If youre not sure, don't enable test builds. Test builds may have bugs, which may include crashing bugs and data loss.")
.foregroundColor(.secondary)
HStack {
Spacer()
Text("If youre not sure, don't enable test builds. Test builds may have bugs, which may include crashing bugs and data loss.").foregroundColor(.secondary)
Button("Check for Updates") {
appDelegate.softwareUpdater.checkForUpdates()
}
Spacer()
}
HStack {
Spacer()
Button("Check for Updates", action: {})
Spacer()
}.padding(.vertical, 12)
Toggle("Send Crash Logs Automatically", isOn: $preferences.sendCrashLogs)
Spacer()
Divider()
HStack {
Spacer()
Button("Privacy Policy", action: {})
Button("Privacy Policy", action: {
NSWorkspace.shared.open(URL(string: "https://ranchero.com/netnewswire/privacypolicy")!)
})
Spacer()
}.padding(.top, 12)
}
Spacer()
}.frame(width: 300, alignment: .center)
}
}
.onChange(of: preferences.downloadTestBuilds, perform: { _ in
viewModel.updateAppcast()
})
.frame(width: 400, alignment: .center)
.lineLimit(3)
}
}

View File

@@ -0,0 +1,129 @@
//
// GeneralPreferencesModel.swift
// Multiplatform macOS
//
// Created by Stuart Breckenridge on 12/7/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import Foundation
class GeneralPreferencesModel: ObservableObject {
@Published var rssReaders = [RSSReader]()
@Published var readerSelection: Int = 0 {
willSet {
if newValue != readerSelection {
registerAppWithBundleID(rssReaders[newValue].bundleID)
}
}
}
private let readerInfo = RSSReaderInfo()
init() {
prepareRSSReaders()
}
}
// MARK:- RSS Readers
private extension GeneralPreferencesModel {
func prepareRSSReaders() {
// Populate rssReaders
var thisApp = RSSReader(bundleID: Bundle.main.bundleIdentifier!)
thisApp?.nameMinusAppSuffix.append(" (this app—multiplatform)")
let otherRSSReaders = readerInfo.rssReaders.filter { $0.bundleID != Bundle.main.bundleIdentifier! }.sorted(by: { $0.nameMinusAppSuffix < $1.nameMinusAppSuffix })
rssReaders.append(thisApp!)
rssReaders.append(contentsOf: otherRSSReaders)
if readerInfo.defaultRSSReaderBundleID != nil {
let defaultReader = rssReaders.filter({ $0.bundleID == readerInfo.defaultRSSReaderBundleID })
if defaultReader.count == 1 {
let reader = defaultReader[0]
readerSelection = rssReaders.firstIndex(of: reader)!
}
}
}
func registerAppWithBundleID(_ bundleID: String) {
NSWorkspace.shared.setDefaultAppBundleID(forURLScheme: "feed", to: bundleID)
NSWorkspace.shared.setDefaultAppBundleID(forURLScheme: "feeds", to: bundleID)
}
}
// MARK: - RSSReaderInfo
struct RSSReaderInfo {
var defaultRSSReaderBundleID: String? {
NSWorkspace.shared.defaultAppBundleID(forURLScheme: RSSReaderInfo.feedURLScheme)
}
let rssReaders: Set<RSSReader>
static let feedURLScheme = "feed:"
init() {
self.rssReaders = RSSReaderInfo.fetchRSSReaders()
}
static func fetchRSSReaders() -> Set<RSSReader> {
let rssReaderBundleIDs = NSWorkspace.shared.bundleIDsForApps(forURLScheme: feedURLScheme)
var rssReaders = Set<RSSReader>()
rssReaderBundleIDs.forEach { (bundleID) in
if let reader = RSSReader(bundleID: bundleID) {
rssReaders.insert(reader)
}
}
return rssReaders
}
}
// MARK: - RSSReader
struct RSSReader: Hashable {
let bundleID: String
let name: String
var nameMinusAppSuffix: String
let path: String
init?(bundleID: String) {
guard let path = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) else {
return nil
}
self.path = path.path
self.bundleID = bundleID
let name = (self.path as NSString).lastPathComponent
self.name = name
if name.hasSuffix(".app") {
self.nameMinusAppSuffix = name.stripping(suffix: ".app")
}
else {
self.nameMinusAppSuffix = name
}
}
// MARK: - Hashable
func hash(into hasher: inout Hasher) {
hasher.combine(bundleID)
}
// MARK: - Equatable
static func ==(lhs: RSSReader, rhs: RSSReader) -> Bool {
return lhs.bundleID == rhs.bundleID
}
}

View File

@@ -0,0 +1,59 @@
//
// GeneralPreferencesView.swift
// macOS
//
// Created by Stuart Breckenridge on 27/6/20.
//
import SwiftUI
struct GeneralPreferencesView: View {
@EnvironmentObject private var defaults: AppDefaults
@Environment(\.colorScheme) private var colorScheme
@StateObject private var preferences = GeneralPreferencesModel()
private let colorPalettes = UserInterfaceColorPalette.allCases
var body: some View {
Form {
Picker("Refresh feeds",
selection: $defaults.interval,
content: {
ForEach(RefreshInterval.allCases, content: { interval in
Text(interval.description())
.tag(interval.rawValue)
})
})
Picker("Default RSS reader", selection: $preferences.readerSelection, content: {
ForEach(0..<preferences.rssReaders.count, content: { index in
if index > 0 && preferences.rssReaders[index].nameMinusAppSuffix.contains("NetNewsWire") {
Text(preferences.rssReaders[index].nameMinusAppSuffix.appending(" (old version)"))
} else {
Text(preferences.rssReaders[index].nameMinusAppSuffix)
.tag(index)
}
})
})
Toggle("Open webpages in background in browser", isOn: $defaults.openInBrowserInBackground)
Toggle("Hide Unread Count in Dock", isOn: $defaults.hideDockUnreadCount)
Divider()
Picker("Appearance", selection: $defaults.userInterfaceColorPalette, content: {
ForEach(colorPalettes, id: \.self, content: {
Text($0.description)
})
}).pickerStyle(RadioGroupPickerStyle())
}
.frame(width: 400, alignment: .center)
.lineLimit(2)
}
}

View File

@@ -1,127 +0,0 @@
//
// AccountsPreferencesView.swift
// macOS
//
// Created by Stuart Breckenridge on 27/6/20.
//
import SwiftUI
struct AccountPreferencesViewModel {
let accountTypes = ["On My Mac", "FeedBin"]
var selectedAccount = Int?.none
}
struct AccountsPreferencesView: View {
@State private var viewModel = AccountPreferencesViewModel()
@State private var addAccountViewModel = AccountPreferencesViewModel()
@State private var showAddAccountView: Bool = false
var body: some View {
VStack {
HStack(alignment: .top, spacing: 10) {
VStack(alignment: .leading) {
List(selection: $viewModel.selectedAccount, content: {
ForEach(0..<viewModel.accountTypes.count, content: { i in
AccountDetailRow(imageName: "desktopcomputer", accountName: viewModel.accountTypes[i]).padding(.vertical, 8)
})
})
HStack {
Button("+", action: {
showAddAccountView.toggle()
})
Button("-", action: {})
.disabled(viewModel.selectedAccount == nil)
Spacer()
}
}.frame(width: 225, height: 300, alignment: .leading)
VStack(alignment: .leading) {
viewModel.selectedAccount == nil ? Text("Select Account") : Text(viewModel.accountTypes[viewModel.selectedAccount!])
Spacer()
}.frame(width: 225, height: 300, alignment: .leading)
}
Spacer()
}.sheet(isPresented: $showAddAccountView,
onDismiss: { showAddAccountView.toggle() },
content: {
AddAccountView()
})
}
}
struct AccountDetailRow: View {
var imageName: String
var accountName: String
var body: some View {
HStack {
Image(systemName: imageName).font(.headline)
Text(accountName).font(.headline)
}
}
}
struct AddAccountView: View {
@Environment(\.presentationMode) var presentationMode
let accountTypes = ["On My Mac", "FeedBin"]
@State var selectedAccount: Int = 0
@State private var userName: String = ""
@State private var password: String = ""
var body: some View {
VStack(alignment: .leading) {
Text("Add an Account").font(.headline)
Form {
Picker("Account Type",
selection: $selectedAccount,
content: {
ForEach(0..<accountTypes.count, content: {
AccountDetailRow(imageName: "desktopcomputer", accountName: accountTypes[$0])
})
})
if selectedAccount == 1 {
TextField("Email", text: $userName)
SecureField("Password", text: $password)
}
}
Spacer()
HStack {
Spacer()
Button(action: { presentationMode.wrappedValue.dismiss() }, label: {
Text("Cancel")
})
if selectedAccount == 0 {
Button("Add", action: {})
}
if selectedAccount != 0 {
Button("Create", action: {})
.disabled(userName.count == 0 || password.count == 0)
}
}
}.frame(width: 300, alignment: .top).padding()
}
}
class AddAccountModel: ObservableObject {
let accountTypes = ["On My Mac", "FeedBin"]
@Published var selectedAccount = Int?.none
}

View File

@@ -1,33 +0,0 @@
//
// GeneralPreferencesView.swift
// macOS
//
// Created by Stuart Breckenridge on 27/6/20.
//
import SwiftUI
struct GeneralPreferencesView: View {
@EnvironmentObject private var defaults: AppDefaults
var body: some View {
VStack {
Form {
Picker("Refresh Feeds",
selection: $defaults.interval,
content: {
ForEach(RefreshInterval.allCases, content: { interval in
Text(interval.description()).tag(interval.rawValue)
})
}).frame(width: 300, alignment: .center)
Toggle("Open webpages in background in browser", isOn: $defaults.openInBrowserInBackground)
Toggle("Hide Unread Count in Dock", isOn: $defaults.hideDockUnreadCount)
}
Spacer()
}.frame(width: 300, alignment: .center)
}
}