Compare commits
No commits in common. "c65b69cfbd3f1275ee43299b8e77fc1641226abb" and "55e4966bd1aa609860d485ac3cd3e29b8270f526" have entirely different histories.
c65b69cfbd
...
55e4966bd1
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct ClientRegistration: Decodable, Sendable {
|
public struct ClientRegistration: Decodable {
|
||||||
public let clientID: String
|
public let clientID: String
|
||||||
public let clientSecret: String
|
public let clientSecret: String
|
||||||
|
|
||||||
|
|
|
@ -5,9 +5,9 @@
|
||||||
// Created by Shadowfacts on 10/29/21.
|
// Created by Shadowfacts on 10/29/21.
|
||||||
//
|
//
|
||||||
|
|
||||||
@preconcurrency import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct Feed: Decodable, Sendable {
|
public struct Feed: Decodable {
|
||||||
public let id: FervorID
|
public let id: FervorID
|
||||||
public let title: String
|
public let title: String
|
||||||
public let url: URL?
|
public let url: URL?
|
||||||
|
|
|
@ -5,13 +5,13 @@
|
||||||
// Created by Shadowfacts on 11/25/21.
|
// Created by Shadowfacts on 11/25/21.
|
||||||
//
|
//
|
||||||
|
|
||||||
@preconcurrency import Foundation
|
import Foundation
|
||||||
|
|
||||||
public actor FervorClient: Sendable {
|
public class FervorClient {
|
||||||
|
|
||||||
private let instanceURL: URL
|
let instanceURL: URL
|
||||||
private let session: URLSession
|
let session: URLSession
|
||||||
public private(set) var accessToken: String?
|
public var accessToken: String?
|
||||||
|
|
||||||
private let decoder: JSONDecoder = {
|
private let decoder: JSONDecoder = {
|
||||||
let d = JSONDecoder()
|
let d = JSONDecoder()
|
||||||
|
@ -81,9 +81,7 @@ public actor FervorClient: Sendable {
|
||||||
"client_id": clientID,
|
"client_id": clientID,
|
||||||
"client_secret": clientSecret,
|
"client_secret": clientSecret,
|
||||||
])
|
])
|
||||||
let result: Token = try await performRequest(request)
|
return try await performRequest(request)
|
||||||
self.accessToken = result.accessToken
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func groups() async throws -> [Group] {
|
public func groups() async throws -> [Group] {
|
||||||
|
|
|
@ -5,9 +5,9 @@
|
||||||
// Created by Shadowfacts on 10/29/21.
|
// Created by Shadowfacts on 10/29/21.
|
||||||
//
|
//
|
||||||
|
|
||||||
@preconcurrency import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct Group: Decodable, Sendable {
|
public struct Group: Decodable {
|
||||||
public let id: FervorID
|
public let id: FervorID
|
||||||
public let title: String
|
public let title: String
|
||||||
public let feedIDs: [FervorID]
|
public let feedIDs: [FervorID]
|
||||||
|
|
|
@ -5,9 +5,9 @@
|
||||||
// Created by Shadowfacts on 10/29/21.
|
// Created by Shadowfacts on 10/29/21.
|
||||||
//
|
//
|
||||||
|
|
||||||
@preconcurrency import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct Instance: Decodable, Sendable {
|
public struct Instance: Decodable {
|
||||||
public let name: String
|
public let name: String
|
||||||
public let url: URL
|
public let url: URL
|
||||||
public let version: String
|
public let version: String
|
||||||
|
|
|
@ -5,9 +5,9 @@
|
||||||
// Created by Shadowfacts on 10/29/21.
|
// Created by Shadowfacts on 10/29/21.
|
||||||
//
|
//
|
||||||
|
|
||||||
@preconcurrency import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct Item: Decodable, Sendable {
|
public struct Item: Decodable {
|
||||||
public let id: FervorID
|
public let id: FervorID
|
||||||
public let feedID: FervorID
|
public let feedID: FervorID
|
||||||
public let title: String?
|
public let title: String?
|
||||||
|
|
|
@ -5,9 +5,9 @@
|
||||||
// Created by Shadowfacts on 1/9/22.
|
// Created by Shadowfacts on 1/9/22.
|
||||||
//
|
//
|
||||||
|
|
||||||
@preconcurrency import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct ItemsSyncUpdate: Decodable, Sendable {
|
public struct ItemsSyncUpdate: Decodable {
|
||||||
|
|
||||||
public let syncTimestamp: Date
|
public let syncTimestamp: Date
|
||||||
public let delete: [FervorID]
|
public let delete: [FervorID]
|
||||||
|
|
|
@ -7,11 +7,10 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct Token: Codable, Sendable {
|
public struct Token: Decodable {
|
||||||
public let accessToken: String
|
public let accessToken: String
|
||||||
public let expiresIn: Int?
|
public let expiresIn: Int?
|
||||||
public let refreshToken: String?
|
public let refreshToken: String?
|
||||||
public let owner: String?
|
|
||||||
|
|
||||||
public init(from decoder: Decoder) throws {
|
public init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
@ -19,21 +18,11 @@ public struct Token: Codable, Sendable {
|
||||||
self.accessToken = try container.decode(String.self, forKey: .accessToken)
|
self.accessToken = try container.decode(String.self, forKey: .accessToken)
|
||||||
self.expiresIn = try container.decodeIfPresent(Int.self, forKey: .expiresIn)
|
self.expiresIn = try container.decodeIfPresent(Int.self, forKey: .expiresIn)
|
||||||
self.refreshToken = try container.decodeIfPresent(String.self, forKey: .refreshToken)
|
self.refreshToken = try container.decodeIfPresent(String.self, forKey: .refreshToken)
|
||||||
self.owner = try container.decodeIfPresent(String.self, forKey: .owner)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func encode(to encoder: Encoder) throws {
|
|
||||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
||||||
try container.encode(accessToken, forKey: .accessToken)
|
|
||||||
try container.encodeIfPresent(expiresIn, forKey: .expiresIn)
|
|
||||||
try container.encodeIfPresent(refreshToken, forKey: .refreshToken)
|
|
||||||
try container.encodeIfPresent(owner, forKey: .owner)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case accessToken = "access_token"
|
case accessToken = "access_token"
|
||||||
case expiresIn = "expires_in"
|
case expiresIn = "expires_in"
|
||||||
case refreshToken = "refresh_token"
|
case refreshToken = "refresh_token"
|
||||||
case owner
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,8 +9,7 @@ import CoreData
|
||||||
import Fervor
|
import Fervor
|
||||||
import OSLog
|
import OSLog
|
||||||
|
|
||||||
// todo: is this actually sendable?
|
class PersistentContainer: NSPersistentContainer {
|
||||||
class PersistentContainer: NSPersistentContainer, @unchecked Sendable {
|
|
||||||
|
|
||||||
private static let managedObjectModel: NSManagedObjectModel = {
|
private static let managedObjectModel: NSManagedObjectModel = {
|
||||||
let url = Bundle.main.url(forResource: "Reader", withExtension: "momd")!
|
let url = Bundle.main.url(forResource: "Reader", withExtension: "momd")!
|
||||||
|
@ -24,14 +23,14 @@ class PersistentContainer: NSPersistentContainer, @unchecked Sendable {
|
||||||
return context
|
return context
|
||||||
}()
|
}()
|
||||||
|
|
||||||
weak var fervorController: FervorController?
|
private weak var fervorController: FervorController?
|
||||||
|
|
||||||
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "PersistentContainer")
|
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "PersistentContainer")
|
||||||
|
|
||||||
init(account: LocalData.Account) {
|
init(account: LocalData.Account, fervorController: FervorController) {
|
||||||
// slashes the base64 string turn into subdirectories which we don't want
|
self.fervorController = fervorController
|
||||||
let name = account.id.base64EncodedString().replacingOccurrences(of: "/", with: "_")
|
|
||||||
super.init(name: name, managedObjectModel: PersistentContainer.managedObjectModel)
|
super.init(name: "\(account.id)", managedObjectModel: PersistentContainer.managedObjectModel)
|
||||||
|
|
||||||
loadPersistentStores { description, error in
|
loadPersistentStores { description, error in
|
||||||
if let error = error {
|
if let error = error {
|
||||||
|
@ -41,7 +40,7 @@ class PersistentContainer: NSPersistentContainer, @unchecked Sendable {
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func saveViewContext() throws {
|
private func saveViewContext() async throws {
|
||||||
if viewContext.hasChanges {
|
if viewContext.hasChanges {
|
||||||
try viewContext.save()
|
try viewContext.save()
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,12 +5,12 @@
|
||||||
// Created by Shadowfacts on 11/25/21.
|
// Created by Shadowfacts on 11/25/21.
|
||||||
//
|
//
|
||||||
|
|
||||||
@preconcurrency import Foundation
|
import Foundation
|
||||||
import Fervor
|
import Fervor
|
||||||
@preconcurrency import OSLog
|
import OSLog
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
actor FervorController {
|
class FervorController {
|
||||||
|
|
||||||
static let oauthRedirectURI = URL(string: "frenzy://oauth-callback")!
|
static let oauthRedirectURI = URL(string: "frenzy://oauth-callback")!
|
||||||
|
|
||||||
|
@ -19,40 +19,36 @@ actor FervorController {
|
||||||
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "FervorController")
|
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "FervorController")
|
||||||
|
|
||||||
let client: FervorClient
|
let client: FervorClient
|
||||||
nonisolated let account: LocalData.Account?
|
private(set) var account: LocalData.Account?
|
||||||
private(set) var clientID: String?
|
private(set) var clientID: String?
|
||||||
private(set) var clientSecret: String?
|
private(set) var clientSecret: String?
|
||||||
private(set) var token: Token?
|
private(set) var accessToken: String?
|
||||||
|
|
||||||
nonisolated let persistentContainer: PersistentContainer!
|
private(set) var persistentContainer: PersistentContainer!
|
||||||
|
|
||||||
nonisolated let syncState = PassthroughSubject<SyncState, Never>()
|
@Published private(set) var syncState = SyncState.done
|
||||||
private var lastSyncState = SyncState.done
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
|
||||||
|
|
||||||
init(instanceURL: URL, account: LocalData.Account?) async {
|
init(instanceURL: URL) {
|
||||||
self.instanceURL = instanceURL
|
self.instanceURL = instanceURL
|
||||||
self.client = FervorClient(instanceURL: instanceURL, accessToken: account?.token.accessToken)
|
self.client = FervorClient(instanceURL: instanceURL, accessToken: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
convenience init(account: LocalData.Account) {
|
||||||
|
self.init(instanceURL: account.instanceURL)
|
||||||
self.account = account
|
self.account = account
|
||||||
self.clientID = account?.clientID
|
self.clientID = account.clientID
|
||||||
self.clientSecret = account?.clientSecret
|
self.clientSecret = account.clientSecret
|
||||||
|
self.accessToken = account.accessToken
|
||||||
|
|
||||||
if let account = account {
|
self.client.accessToken = account.accessToken
|
||||||
self.persistentContainer = PersistentContainer(account: account)
|
|
||||||
} else {
|
|
||||||
self.persistentContainer = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
persistentContainer?.fervorController = self
|
self.persistentContainer = PersistentContainer(account: account, fervorController: self)
|
||||||
}
|
|
||||||
|
|
||||||
convenience init(account: LocalData.Account) async {
|
|
||||||
await self.init(instanceURL: account.instanceURL, account: account)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setSyncState(_ state: SyncState) {
|
private func setSyncState(_ state: SyncState) {
|
||||||
lastSyncState = state
|
DispatchQueue.main.async {
|
||||||
syncState.send(state)
|
self.syncState = state
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func register() async throws -> ClientRegistration {
|
func register() async throws -> ClientRegistration {
|
||||||
|
@ -63,11 +59,13 @@ actor FervorController {
|
||||||
}
|
}
|
||||||
|
|
||||||
func getToken(authCode: String) async throws {
|
func getToken(authCode: String) async throws {
|
||||||
token = try await client.token(authCode: authCode, redirectURI: FervorController.oauthRedirectURI, clientID: clientID!, clientSecret: clientSecret!)
|
let token = try await client.token(authCode: authCode, redirectURI: FervorController.oauthRedirectURI, clientID: clientID!, clientSecret: clientSecret!)
|
||||||
|
client.accessToken = token.accessToken
|
||||||
|
accessToken = token.accessToken
|
||||||
}
|
}
|
||||||
|
|
||||||
func syncAll() async throws {
|
func syncAll() async throws {
|
||||||
guard lastSyncState == .done else {
|
guard syncState == .done else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// always return to .done, even if we throw and stop syncing early
|
// always return to .done, even if we throw and stop syncing early
|
||||||
|
@ -140,7 +138,7 @@ actor FervorController {
|
||||||
func markItem(_ item: Item, read: Bool) async {
|
func markItem(_ item: Item, read: Bool) async {
|
||||||
item.read = read
|
item.read = read
|
||||||
do {
|
do {
|
||||||
let f = read ? client.read(item:) : client.unread(item:)
|
let f = item.read ? client.read(item:) : client.unread(item:)
|
||||||
_ = try await f(item.id!)
|
_ = try await f(item.id!)
|
||||||
item.needsReadStateSync = false
|
item.needsReadStateSync = false
|
||||||
} catch {
|
} catch {
|
||||||
|
@ -148,12 +146,14 @@ actor FervorController {
|
||||||
item.needsReadStateSync = true
|
item.needsReadStateSync = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if persistentContainer.viewContext.hasChanges {
|
||||||
do {
|
do {
|
||||||
try self.persistentContainer.saveViewContext()
|
try persistentContainer.viewContext.save()
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("Failed to save view context: \(String(describing: error), privacy: .public)")
|
logger.error("Failed to save view context: \(String(describing: error), privacy: .public)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -4,10 +4,6 @@
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSUserActivityTypes</key>
|
<key>NSUserActivityTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.read-unread</string>
|
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.read-all</string>
|
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.read-feed</string>
|
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.read-group</string>
|
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.preferences</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.preferences</string>
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.add-account</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.add-account</string>
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.activate-account</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.activate-account</string>
|
||||||
|
|
|
@ -6,8 +6,6 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Fervor
|
|
||||||
import CryptoKit
|
|
||||||
|
|
||||||
struct LocalData {
|
struct LocalData {
|
||||||
|
|
||||||
|
@ -30,12 +28,15 @@ struct LocalData {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static var mostRecentAccountID: Data? {
|
static var mostRecentAccountID: UUID? {
|
||||||
get {
|
get {
|
||||||
return UserDefaults.standard.data(forKey: "mostRecentAccountID")
|
guard let str = UserDefaults.standard.string(forKey: "mostRecentAccountID") else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return UUID(uuidString: str)
|
||||||
}
|
}
|
||||||
set {
|
set {
|
||||||
UserDefaults.standard.set(newValue, forKey: "mostRecentAccountID")
|
UserDefaults.standard.set(newValue?.uuidString, forKey: "mostRecentAccountID")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -43,32 +44,23 @@ struct LocalData {
|
||||||
guard let id = mostRecentAccountID else {
|
guard let id = mostRecentAccountID else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return account(with: id)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func account(with id: Data) -> Account? {
|
|
||||||
return accounts.first(where: { $0.id == id })
|
return accounts.first(where: { $0.id == id })
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Account: Codable {
|
struct Account: Codable {
|
||||||
let id: Data
|
let id: UUID
|
||||||
let instanceURL: URL
|
let instanceURL: URL
|
||||||
let clientID: String
|
let clientID: String
|
||||||
let clientSecret: String
|
let clientSecret: String
|
||||||
let token: Token
|
let accessToken: String
|
||||||
|
// todo: refresh tokens
|
||||||
init(instanceURL: URL, clientID: String, clientSecret: String, token: Token) {
|
|
||||||
// we use a hash of instance host and account id rather than random ids so that
|
|
||||||
// user activites can uniquely identify accounts across devices
|
|
||||||
var hasher = SHA256()
|
|
||||||
hasher.update(data: instanceURL.host!.data(using: .utf8)!)
|
|
||||||
hasher.update(data: token.owner!.data(using: .utf8)!)
|
|
||||||
self.id = Data(hasher.finalize())
|
|
||||||
|
|
||||||
|
init(instanceURL: URL, clientID: String, clientSecret: String, accessToken: String) {
|
||||||
|
self.id = UUID()
|
||||||
self.instanceURL = instanceURL
|
self.instanceURL = instanceURL
|
||||||
self.clientID = clientID
|
self.clientID = clientID
|
||||||
self.clientSecret = clientSecret
|
self.clientSecret = clientSecret
|
||||||
self.token = token
|
self.accessToken = accessToken
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,6 @@
|
||||||
// Created by Shadowfacts on 10/29/21.
|
// Created by Shadowfacts on 10/29/21.
|
||||||
//
|
//
|
||||||
|
|
||||||
@preconcurrency import Foundation
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import OSLog
|
import OSLog
|
||||||
|
|
||||||
|
@ -27,32 +26,22 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||||
window = UIWindow(windowScene: windowScene)
|
window = UIWindow(windowScene: windowScene)
|
||||||
window!.tintColor = .appTintColor
|
window!.tintColor = .appTintColor
|
||||||
|
|
||||||
var activity = connectionOptions.userActivities.first
|
let activity = connectionOptions.userActivities.first
|
||||||
|
|
||||||
var account = LocalData.mostRecentAccount()
|
|
||||||
|
|
||||||
if activity?.activityType == NSUserActivity.addAccountType {
|
if activity?.activityType == NSUserActivity.addAccountType {
|
||||||
account = nil
|
let loginVC = LoginViewController()
|
||||||
} else if let id = activity?.accountID() {
|
loginVC.delegate = self
|
||||||
account = LocalData.account(with: id)
|
window!.rootViewController = loginVC
|
||||||
if account == nil {
|
} else if activity?.activityType == NSUserActivity.activateAccountType,
|
||||||
activity = nil
|
let account = LocalData.accounts.first(where: { $0.id.uuidString == activity!.userInfo?["accountID"] as? String }) {
|
||||||
logger.log("Missing account for activity, not restoring")
|
fervorController = FervorController(account: account)
|
||||||
}
|
createAppUI()
|
||||||
}
|
} else if let account = LocalData.mostRecentAccount() {
|
||||||
|
fervorController = FervorController(account: account)
|
||||||
if let account = account {
|
|
||||||
Task { @MainActor [activity] in
|
|
||||||
fervorController = await FervorController(account: account)
|
|
||||||
syncFromServer()
|
|
||||||
createAppUI()
|
createAppUI()
|
||||||
if let activity = activity {
|
|
||||||
setupUI(from: activity)
|
|
||||||
}
|
|
||||||
setupSceneActivationConditions()
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
createLoginUI()
|
let loginVC = LoginViewController()
|
||||||
|
loginVC.delegate = self
|
||||||
|
window!.rootViewController = loginVC
|
||||||
}
|
}
|
||||||
|
|
||||||
#if targetEnvironment(macCatalyst)
|
#if targetEnvironment(macCatalyst)
|
||||||
|
@ -108,64 +97,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||||
// to restore the scene back to its current state.
|
// to restore the scene back to its current state.
|
||||||
}
|
}
|
||||||
|
|
||||||
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
|
|
||||||
setupUI(from: userActivity)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setupUI(from activity: NSUserActivity) {
|
|
||||||
guard let split = window?.rootViewController as? AppSplitViewController else {
|
|
||||||
logger.error("Failed to setup UI for user activity: missing split VC")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
switch activity.activityType {
|
|
||||||
case NSUserActivity.readUnreadType:
|
|
||||||
split.selectHomeItem(.unread)
|
|
||||||
case NSUserActivity.readAllType:
|
|
||||||
split.selectHomeItem(.all)
|
|
||||||
case NSUserActivity.readFeedType:
|
|
||||||
guard let feedID = activity.feedID() else {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
let req = Feed.fetchRequest()
|
|
||||||
req.predicate = NSPredicate(format: "id = %@", feedID)
|
|
||||||
if let feed = try? fervorController.persistentContainer.viewContext.fetch(req).first {
|
|
||||||
split.selectHomeItem(.feed(feed))
|
|
||||||
}
|
|
||||||
case NSUserActivity.readGroupType:
|
|
||||||
guard let groupID = activity.groupID() else {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
let req = Group.fetchRequest()
|
|
||||||
req.predicate = NSPredicate(format: "id = %@", groupID)
|
|
||||||
if let group = try? fervorController.persistentContainer.viewContext.fetch(req).first {
|
|
||||||
split.selectHomeItem(.group(group))
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func createLoginUI() {
|
|
||||||
let vc = LoginViewController()
|
|
||||||
vc.delegate = self
|
|
||||||
window!.rootViewController = vc
|
|
||||||
}
|
|
||||||
|
|
||||||
private func createAppUI() {
|
private func createAppUI() {
|
||||||
window!.rootViewController = AppSplitViewController(fervorController: fervorController)
|
window!.rootViewController = AppSplitViewController(fervorController: fervorController)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupSceneActivationConditions() {
|
|
||||||
guard let account = fervorController?.account else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let scene = self.window!.windowScene!
|
|
||||||
// todo: why the fuck doesn't this work
|
|
||||||
// it always picks the most recently focused window
|
|
||||||
scene.activationConditions.prefersToActivateForTargetContentIdentifierPredicate = NSPredicate(format: "self == '\(account.id.base64EncodedString())'")
|
|
||||||
scene.activationConditions.canActivateForTargetContentIdentifierPredicate = NSPredicate(value: false)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func syncFromServer() {
|
private func syncFromServer() {
|
||||||
guard let fervorController = fervorController else {
|
guard let fervorController = fervorController else {
|
||||||
return
|
return
|
||||||
|
@ -190,9 +125,9 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func switchToAccount(_ account: LocalData.Account) async {
|
func switchToAccount(_ account: LocalData.Account) {
|
||||||
LocalData.mostRecentAccountID = account.id
|
LocalData.mostRecentAccountID = account.id
|
||||||
fervorController = await FervorController(account: account)
|
fervorController = FervorController(account: account)
|
||||||
createAppUI()
|
createAppUI()
|
||||||
syncFromServer()
|
syncFromServer()
|
||||||
}
|
}
|
||||||
|
@ -201,20 +136,17 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||||
|
|
||||||
extension SceneDelegate: LoginViewControllerDelegate {
|
extension SceneDelegate: LoginViewControllerDelegate {
|
||||||
func didLogin(with controller: FervorController) {
|
func didLogin(with controller: FervorController) {
|
||||||
Task { @MainActor in
|
let account = LocalData.Account(instanceURL: controller.instanceURL, clientID: controller.clientID!, clientSecret: controller.clientSecret!, accessToken: controller.accessToken!)
|
||||||
let account = LocalData.Account(instanceURL: controller.instanceURL, clientID: await controller.clientID!, clientSecret: await controller.clientSecret!, token: await controller.token!)
|
|
||||||
LocalData.accounts.append(account)
|
LocalData.accounts.append(account)
|
||||||
LocalData.mostRecentAccountID = account.id
|
LocalData.mostRecentAccountID = account.id
|
||||||
fervorController = await FervorController(account: account)
|
fervorController = FervorController(account: account)
|
||||||
|
|
||||||
createAppUI()
|
createAppUI()
|
||||||
syncFromServer()
|
syncFromServer()
|
||||||
setupSceneActivationConditions()
|
|
||||||
|
|
||||||
UIMenuSystem.main.setNeedsRebuild()
|
UIMenuSystem.main.setNeedsRebuild()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
#if targetEnvironment(macCatalyst)
|
#if targetEnvironment(macCatalyst)
|
||||||
extension NSToolbarItem.Identifier {
|
extension NSToolbarItem.Identifier {
|
||||||
|
|
|
@ -54,18 +54,6 @@ class AppSplitViewController: UISplitViewController {
|
||||||
setViewController(nav, for: .compact)
|
setViewController(nav, for: .compact)
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectHomeItem(_ item: HomeViewController.Item) {
|
|
||||||
let column: Column
|
|
||||||
if traitCollection.horizontalSizeClass == .compact {
|
|
||||||
column = .compact
|
|
||||||
} else {
|
|
||||||
column = .primary
|
|
||||||
}
|
|
||||||
let nav = viewController(for: column) as! UINavigationController
|
|
||||||
let home = nav.viewControllers.first! as! HomeViewController
|
|
||||||
home.selectItem(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension AppSplitViewController: ItemsViewControllerDelegate {
|
extension AppSplitViewController: ItemsViewControllerDelegate {
|
||||||
|
@ -77,9 +65,7 @@ extension AppSplitViewController: ItemsViewControllerDelegate {
|
||||||
extension AppSplitViewController: HomeViewControllerDelegate {
|
extension AppSplitViewController: HomeViewControllerDelegate {
|
||||||
func switchToAccount(_ account: LocalData.Account) {
|
func switchToAccount(_ account: LocalData.Account) {
|
||||||
if let delegate = view.window?.windowScene?.delegate as? SceneDelegate {
|
if let delegate = view.window?.windowScene?.delegate as? SceneDelegate {
|
||||||
Task { @MainActor in
|
delegate.switchToAccount(account)
|
||||||
await delegate.switchToAccount(account)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -93,7 +93,7 @@ class HomeViewController: UIViewController {
|
||||||
feedResultsController.delegate = self
|
feedResultsController.delegate = self
|
||||||
try! feedResultsController.performFetch()
|
try! feedResultsController.performFetch()
|
||||||
|
|
||||||
fervorController.syncState
|
fervorController.$syncState
|
||||||
.debounce(for: .milliseconds(250), scheduler: RunLoop.main, options: nil)
|
.debounce(for: .milliseconds(250), scheduler: RunLoop.main, options: nil)
|
||||||
.sink { [unowned self] in
|
.sink { [unowned self] in
|
||||||
self.syncStateChanged($0)
|
self.syncStateChanged($0)
|
||||||
|
@ -149,17 +149,9 @@ class HomeViewController: UIViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func syncStateChanged(_ newState: FervorController.SyncState) {
|
private func syncStateChanged(_ newState: FervorController.SyncState) {
|
||||||
if newState == .done {
|
if newState == .done && syncStateView == nil {
|
||||||
// update unread counts for visible items
|
|
||||||
var snapshot = dataSource.snapshot()
|
|
||||||
snapshot.reconfigureItems(snapshot.itemIdentifiers)
|
|
||||||
dataSource.apply(snapshot, animatingDifferences: false)
|
|
||||||
|
|
||||||
if syncStateView == nil {
|
|
||||||
// no sync state view, nothing further to update
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func updateView(_ syncStateView: SyncStateView) {
|
func updateView(_ syncStateView: SyncStateView) {
|
||||||
switch newState {
|
switch newState {
|
||||||
|
@ -206,28 +198,6 @@ class HomeViewController: UIViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func itemsViewController(for item: Item) -> ItemsViewController {
|
|
||||||
let vc = ItemsViewController(fetchRequest: item.idFetchRequest, fervorController: fervorController)
|
|
||||||
vc.title = item.title
|
|
||||||
vc.delegate = itemsDelegate
|
|
||||||
switch item {
|
|
||||||
case .all:
|
|
||||||
vc.userActivity = .readAll(account: fervorController.account!)
|
|
||||||
case .unread:
|
|
||||||
vc.userActivity = .readUnread(account: fervorController.account!)
|
|
||||||
case .group(let group):
|
|
||||||
vc.userActivity = .readGroup(group, account: fervorController.account!)
|
|
||||||
case .feed(let feed):
|
|
||||||
vc.userActivity = .readFeed(feed, account: fervorController.account!)
|
|
||||||
}
|
|
||||||
return vc
|
|
||||||
}
|
|
||||||
|
|
||||||
func selectItem(_ item: Item) {
|
|
||||||
navigationController!.popToRootViewController(animated: false)
|
|
||||||
navigationController!.pushViewController(itemsViewController(for: item), animated: false)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension HomeViewController {
|
extension HomeViewController {
|
||||||
|
@ -266,6 +236,21 @@ extension HomeViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var fetchRequest: NSFetchRequest<Reader.Item> {
|
||||||
|
let req = Reader.Item.fetchRequest()
|
||||||
|
switch self {
|
||||||
|
case .unread:
|
||||||
|
req.predicate = NSPredicate(format: "read = NO")
|
||||||
|
case .all:
|
||||||
|
break
|
||||||
|
case .group(let group):
|
||||||
|
req.predicate = NSPredicate(format: "feed in %@", group.feeds!)
|
||||||
|
case .feed(let feed):
|
||||||
|
req.predicate = NSPredicate(format: "feed = %@", feed)
|
||||||
|
}
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
var idFetchRequest: NSFetchRequest<NSManagedObjectID> {
|
var idFetchRequest: NSFetchRequest<NSManagedObjectID> {
|
||||||
let req = NSFetchRequest<NSManagedObjectID>(entityName: "Item")
|
let req = NSFetchRequest<NSManagedObjectID>(entityName: "Item")
|
||||||
req.resultType = .managedObjectIDResultType
|
req.resultType = .managedObjectIDResultType
|
||||||
|
@ -322,7 +307,10 @@ extension HomeViewController: UICollectionViewDelegate {
|
||||||
guard let item = dataSource.itemIdentifier(for: indexPath) else {
|
guard let item = dataSource.itemIdentifier(for: indexPath) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
show(itemsViewController(for: item), sender: nil)
|
let vc = ItemsViewController(fetchRequest: item.idFetchRequest, fervorController: fervorController)
|
||||||
|
vc.title = item.title
|
||||||
|
vc.delegate = itemsDelegate
|
||||||
|
show(vc, sender: nil)
|
||||||
UISelectionFeedbackGenerator().selectionChanged()
|
UISelectionFeedbackGenerator().selectionChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -330,8 +318,8 @@ extension HomeViewController: UICollectionViewDelegate {
|
||||||
guard let item = dataSource.itemIdentifier(for: indexPath) else {
|
guard let item = dataSource.itemIdentifier(for: indexPath) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return UIContextMenuConfiguration(identifier: nil, previewProvider: { [unowned self] in
|
return UIContextMenuConfiguration(identifier: nil, previewProvider: {
|
||||||
return self.itemsViewController(for: item)
|
return ItemsViewController(fetchRequest: item.idFetchRequest, fervorController: self.fervorController)
|
||||||
}, actionProvider: nil)
|
}, actionProvider: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,6 @@
|
||||||
// Created by Shadowfacts on 11/25/21.
|
// Created by Shadowfacts on 11/25/21.
|
||||||
//
|
//
|
||||||
|
|
||||||
@preconcurrency import Foundation
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import AuthenticationServices
|
import AuthenticationServices
|
||||||
import Fervor
|
import Fervor
|
||||||
|
@ -73,7 +72,7 @@ class LoginViewController: UIViewController {
|
||||||
textField.isEnabled = false
|
textField.isEnabled = false
|
||||||
activityIndicator.startAnimating()
|
activityIndicator.startAnimating()
|
||||||
|
|
||||||
let controller = await FervorController(instanceURL: components.url!, account: nil)
|
let controller = FervorController(instanceURL: components.url!)
|
||||||
|
|
||||||
let registration: ClientRegistration
|
let registration: ClientRegistration
|
||||||
do {
|
do {
|
||||||
|
@ -100,7 +99,7 @@ class LoginViewController: UIViewController {
|
||||||
let components = URLComponents(url: callbackURL!, resolvingAgainstBaseURL: false)
|
let components = URLComponents(url: callbackURL!, resolvingAgainstBaseURL: false)
|
||||||
guard let codeItem = components?.queryItems?.first(where: { $0.name == "code" }),
|
guard let codeItem = components?.queryItems?.first(where: { $0.name == "code" }),
|
||||||
let codeValue = codeItem.value else {
|
let codeValue = codeItem.value else {
|
||||||
Task { @MainActor in
|
DispatchQueue.main.async {
|
||||||
let alert = UIAlertController(title: "Unable to retrieve authorization code", message: error?.localizedDescription ?? "Unknown Error", preferredStyle: .alert)
|
let alert = UIAlertController(title: "Unable to retrieve authorization code", message: error?.localizedDescription ?? "Unknown Error", preferredStyle: .alert)
|
||||||
alert.addAction(UIAlertAction(title: "Ok", style: .default, handler: nil))
|
alert.addAction(UIAlertAction(title: "Ok", style: .default, handler: nil))
|
||||||
self.present(alert, animated: true)
|
self.present(alert, animated: true)
|
||||||
|
|
|
@ -12,40 +12,6 @@ extension NSUserActivity {
|
||||||
static let preferencesType = "net.shadowfacts.Reader.activity.preferences"
|
static let preferencesType = "net.shadowfacts.Reader.activity.preferences"
|
||||||
static let addAccountType = "net.shadowfacts.Reader.activity.add-account"
|
static let addAccountType = "net.shadowfacts.Reader.activity.add-account"
|
||||||
static let activateAccountType = "net.shadowfacts.Reader.activity.activate-account"
|
static let activateAccountType = "net.shadowfacts.Reader.activity.activate-account"
|
||||||
static let readUnreadType = "net.shadowfacts.Reader.activity.read-unread"
|
|
||||||
static let readAllType = "net.shadowfacts.Reader.activity.read-all"
|
|
||||||
static let readFeedType = "net.shadowfacts.Reader.activity.read-feed"
|
|
||||||
static let readGroupType = "net.shadowfacts.Reader.activity.read-group"
|
|
||||||
|
|
||||||
func accountID() -> Data? {
|
|
||||||
let types = [
|
|
||||||
NSUserActivity.activateAccountType,
|
|
||||||
NSUserActivity.readUnreadType,
|
|
||||||
NSUserActivity.readAllType,
|
|
||||||
]
|
|
||||||
if types.contains(self.activityType),
|
|
||||||
let id = self.userInfo?["accountID"] as? Data {
|
|
||||||
return id
|
|
||||||
} else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func feedID() -> String? {
|
|
||||||
if activityType == NSUserActivity.readFeedType {
|
|
||||||
return userInfo?["feedID"] as? String
|
|
||||||
} else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func groupID() -> String? {
|
|
||||||
if activityType == NSUserActivity.readGroupType {
|
|
||||||
return userInfo?["groupID"] as? String
|
|
||||||
} else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func preferences() -> NSUserActivity {
|
static func preferences() -> NSUserActivity {
|
||||||
return NSUserActivity(activityType: preferencesType)
|
return NSUserActivity(activityType: preferencesType)
|
||||||
|
@ -58,59 +24,9 @@ extension NSUserActivity {
|
||||||
static func activateAccount(_ account: LocalData.Account) -> NSUserActivity {
|
static func activateAccount(_ account: LocalData.Account) -> NSUserActivity {
|
||||||
let activity = NSUserActivity(activityType: activateAccountType)
|
let activity = NSUserActivity(activityType: activateAccountType)
|
||||||
activity.userInfo = [
|
activity.userInfo = [
|
||||||
"accountID": account.id,
|
"accountID": account.id.uuidString
|
||||||
]
|
]
|
||||||
return activity
|
return activity
|
||||||
}
|
}
|
||||||
|
|
||||||
static func readUnread(account: LocalData.Account) -> NSUserActivity {
|
|
||||||
let activity = NSUserActivity(activityType: readUnreadType)
|
|
||||||
activity.isEligibleForHandoff = true
|
|
||||||
activity.isEligibleForPrediction = true
|
|
||||||
activity.title = "Show unread articles"
|
|
||||||
activity.userInfo = [
|
|
||||||
"accountID": account.id
|
|
||||||
]
|
|
||||||
activity.targetContentIdentifier = account.id.base64EncodedString()
|
|
||||||
return activity
|
|
||||||
}
|
|
||||||
|
|
||||||
static func readAll(account: LocalData.Account) -> NSUserActivity {
|
|
||||||
let activity = NSUserActivity(activityType: readAllType)
|
|
||||||
activity.isEligibleForHandoff = true
|
|
||||||
activity.isEligibleForPrediction = true
|
|
||||||
activity.title = "Show all articles"
|
|
||||||
activity.userInfo = [
|
|
||||||
"accountID": account.id
|
|
||||||
]
|
|
||||||
activity.targetContentIdentifier = account.id.base64EncodedString()
|
|
||||||
return activity
|
|
||||||
}
|
|
||||||
|
|
||||||
static func readFeed(_ feed: Feed, account: LocalData.Account) -> NSUserActivity {
|
|
||||||
let activity = NSUserActivity(activityType: readFeedType)
|
|
||||||
activity.isEligibleForHandoff = true
|
|
||||||
activity.isEligibleForPrediction = true
|
|
||||||
activity.title = "Show articles from \(feed.title!)"
|
|
||||||
activity.userInfo = [
|
|
||||||
"accountID": account.id,
|
|
||||||
"feedID": feed.id!
|
|
||||||
]
|
|
||||||
activity.targetContentIdentifier = account.id.base64EncodedString()
|
|
||||||
return activity
|
|
||||||
}
|
|
||||||
|
|
||||||
static func readGroup(_ group: Group, account: LocalData.Account) -> NSUserActivity {
|
|
||||||
let activity = NSUserActivity(activityType: readGroupType)
|
|
||||||
activity.isEligibleForHandoff = true
|
|
||||||
activity.isEligibleForPrediction = true
|
|
||||||
activity.title = "Show articles from \(group.title)"
|
|
||||||
activity.userInfo = [
|
|
||||||
"accountID": account.id,
|
|
||||||
"groupID": group.id!
|
|
||||||
]
|
|
||||||
activity.targetContentIdentifier = account.id.base64EncodedString()
|
|
||||||
return activity
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue