Compare commits
54 Commits
15699b0b85
...
e242510c5e
Author | SHA1 | Date |
---|---|---|
Shadowfacts | e242510c5e | |
Shadowfacts | ed0a2f1ba3 | |
Shadowfacts | c89df7604b | |
Shadowfacts | 53d4b0bae8 | |
Shadowfacts | dbfc76fc6a | |
Shadowfacts | b8a415b6fd | |
Shadowfacts | 75be4141dd | |
Shadowfacts | df00108dae | |
Shadowfacts | 0450fe2c0e | |
Shadowfacts | be579b849d | |
Shadowfacts | c276bbdea6 | |
Shadowfacts | 6fbda7dc78 | |
Shadowfacts | 1726a7c711 | |
Shadowfacts | 36fda4d51f | |
Shadowfacts | 1d2e666c00 | |
Shadowfacts | 0be678063b | |
Shadowfacts | 949f2bca01 | |
Shadowfacts | c3d0174f23 | |
Shadowfacts | dbd274f57c | |
Shadowfacts | 4b0bda88b8 | |
Shadowfacts | 83c3bc927e | |
Shadowfacts | f8026125cc | |
Shadowfacts | 30ec5e54e0 | |
Shadowfacts | a368bc4365 | |
Shadowfacts | be788bd0a6 | |
Shadowfacts | 9deafa4b33 | |
Shadowfacts | 4c4044c382 | |
Shadowfacts | b2a8174099 | |
Shadowfacts | b2b99c6a11 | |
Shadowfacts | 503d35f301 | |
Shadowfacts | 8e6bf219c8 | |
Shadowfacts | 415340882e | |
Shadowfacts | e1296223fe | |
Shadowfacts | 85819ea6aa | |
Shadowfacts | 755f98a2e2 | |
Shadowfacts | 703b936676 | |
Shadowfacts | c9b12a6b70 | |
Shadowfacts | 12e0e3cdfd | |
Shadowfacts | 3d07069ee5 | |
Shadowfacts | a1cf4a5789 | |
Shadowfacts | 736e8283e1 | |
Shadowfacts | 96255b2a1f | |
Shadowfacts | b4d288bd29 | |
Shadowfacts | ba186fd1b2 | |
Shadowfacts | 220fbf7b75 | |
Shadowfacts | f53f198071 | |
Shadowfacts | dab4d6075a | |
Shadowfacts | 61f073109c | |
Shadowfacts | dd71c06257 | |
Shadowfacts | 8d385e61f5 | |
Shadowfacts | eded49b266 | |
Shadowfacts | 2b38b883fe | |
Shadowfacts | 8acc303a80 | |
Shadowfacts | 3ca42e9916 |
|
@ -0,0 +1,3 @@
|
||||||
|
[submodule "lol-html"]
|
||||||
|
path = lol-html
|
||||||
|
url = https://github.com/cloudflare/lol-html.git
|
|
@ -10,7 +10,7 @@ import Foundation
|
||||||
public struct Feed: Decodable {
|
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?
|
||||||
public let serviceURL: URL?
|
public let serviceURL: URL?
|
||||||
public let feedURL: URL
|
public let feedURL: URL
|
||||||
public let lastUpdated: Date
|
public let lastUpdated: Date
|
||||||
|
@ -21,7 +21,7 @@ public struct Feed: Decodable {
|
||||||
|
|
||||||
self.id = try container.decode(FervorID.self, forKey: .id)
|
self.id = try container.decode(FervorID.self, forKey: .id)
|
||||||
self.title = try container.decode(String.self, forKey: .title)
|
self.title = try container.decode(String.self, forKey: .title)
|
||||||
self.url = try container.decode(URL.self, forKey: .url)
|
self.url = try container.decode(URL?.self, forKey: .url)
|
||||||
self.serviceURL = try container.decodeIfPresent(URL.self, forKey: .serviceURL)
|
self.serviceURL = try container.decodeIfPresent(URL.self, forKey: .serviceURL)
|
||||||
self.feedURL = try container.decode(URL.self, forKey: .feedURL)
|
self.feedURL = try container.decode(URL.self, forKey: .feedURL)
|
||||||
self.lastUpdated = try container.decode(Date.self, forKey: .lastUpdated)
|
self.lastUpdated = try container.decode(Date.self, forKey: .lastUpdated)
|
||||||
|
|
|
@ -8,4 +8,4 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
// todo: fervor: ids should be strings
|
// todo: fervor: ids should be strings
|
||||||
public typealias FervorID = Int
|
public typealias FervorID = String
|
||||||
|
|
|
@ -13,7 +13,26 @@ public class FervorClient {
|
||||||
let session: URLSession
|
let session: URLSession
|
||||||
public var accessToken: String?
|
public var accessToken: String?
|
||||||
|
|
||||||
private let decoder = JSONDecoder()
|
private let decoder: JSONDecoder = {
|
||||||
|
let d = JSONDecoder()
|
||||||
|
let withFractionalSeconds = ISO8601DateFormatter()
|
||||||
|
withFractionalSeconds.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
let without = ISO8601DateFormatter()
|
||||||
|
without.formatOptions = [.withInternetDateTime]
|
||||||
|
// because fucking ISO8601DateFormatter isn't a DateFormatter
|
||||||
|
d.dateDecodingStrategy = .custom({ decoder in
|
||||||
|
let s = try decoder.singleValueContainer().decode(String.self)
|
||||||
|
// try both because Elixir's DateTime.to_iso8601 omits the .0 if the date doesn't have fractional seconds
|
||||||
|
if let d = withFractionalSeconds.date(from: s) {
|
||||||
|
return d
|
||||||
|
} else if let d = without.date(from: s) {
|
||||||
|
return d
|
||||||
|
} else {
|
||||||
|
throw DateDecodingError()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return d
|
||||||
|
}()
|
||||||
|
|
||||||
public init(instanceURL: URL, accessToken: String?, session: URLSession = .shared) {
|
public init(instanceURL: URL, accessToken: String?, session: URLSession = .shared) {
|
||||||
self.instanceURL = instanceURL
|
self.instanceURL = instanceURL
|
||||||
|
@ -21,9 +40,10 @@ public class FervorClient {
|
||||||
self.session = session
|
self.session = session
|
||||||
}
|
}
|
||||||
|
|
||||||
private func buildURL(path: String) -> URL {
|
private func buildURL(path: String, queryItems: [URLQueryItem] = []) -> URL {
|
||||||
var components = URLComponents(url: instanceURL, resolvingAgainstBaseURL: false)!
|
var components = URLComponents(url: instanceURL, resolvingAgainstBaseURL: false)!
|
||||||
components.path = path
|
components.path = path
|
||||||
|
components.queryItems = queryItems
|
||||||
return components.url!
|
return components.url!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -32,7 +52,10 @@ public class FervorClient {
|
||||||
if let accessToken = accessToken {
|
if let accessToken = accessToken {
|
||||||
request.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
request.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
||||||
}
|
}
|
||||||
let (data, _) = try await session.data(for: request, delegate: nil)
|
let (data, response) = try await session.data(for: request, delegate: nil)
|
||||||
|
if (response as! HTTPURLResponse).statusCode == 404 {
|
||||||
|
throw Error.notFound
|
||||||
|
}
|
||||||
let decoded = try decoder.decode(T.self, from: data)
|
let decoded = try decoder.decode(T.self, from: data)
|
||||||
return decoded
|
return decoded
|
||||||
}
|
}
|
||||||
|
@ -61,6 +84,53 @@ public class FervorClient {
|
||||||
return try await performRequest(request)
|
return try await performRequest(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func groups() async throws -> [Group] {
|
||||||
|
let request = URLRequest(url: buildURL(path: "/api/v1/groups"))
|
||||||
|
return try await performRequest(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func feeds() async throws -> [Feed] {
|
||||||
|
let request = URLRequest(url: buildURL(path: "/api/v1/feeds"))
|
||||||
|
return try await performRequest(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func syncItems(lastSync: Date?) async throws -> ItemsSyncUpdate {
|
||||||
|
let request = URLRequest(url: buildURL(path: "/api/v1/items/sync", queryItems: [
|
||||||
|
URLQueryItem(name: "last_sync", value: lastSync?.formatted(.iso8601))
|
||||||
|
]))
|
||||||
|
return try await performRequest(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func items(feed id: FervorID) async throws -> [Item] {
|
||||||
|
let request = URLRequest(url: buildURL(path: "/api/v1/feeds/\(id)/items"))
|
||||||
|
return try await performRequest(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func item(id: FervorID) async throws -> Item? {
|
||||||
|
let request = URLRequest(url: buildURL(path: "/api/v1/items/\(id)"))
|
||||||
|
do {
|
||||||
|
return try await performRequest(request)
|
||||||
|
} catch {
|
||||||
|
if let error = error as? Error, case .notFound = error {
|
||||||
|
return nil
|
||||||
|
} else {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func read(item id: FervorID) async throws -> Item {
|
||||||
|
var request = URLRequest(url: buildURL(path: "/api/v1/items/\(id)/read"))
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
return try await performRequest(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func unread(item id: FervorID) async throws -> Item {
|
||||||
|
var request = URLRequest(url: buildURL(path: "/api/v1/items/\(id)/unread"))
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
return try await performRequest(request)
|
||||||
|
}
|
||||||
|
|
||||||
public struct Auth {
|
public struct Auth {
|
||||||
public let accessToken: String
|
public let accessToken: String
|
||||||
public let refreshToken: String?
|
public let refreshToken: String?
|
||||||
|
@ -69,6 +139,11 @@ public class FervorClient {
|
||||||
public enum Error: Swift.Error {
|
public enum Error: Swift.Error {
|
||||||
case urlSession(Swift.Error)
|
case urlSession(Swift.Error)
|
||||||
case decode(Swift.Error)
|
case decode(Swift.Error)
|
||||||
|
case notFound
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct DateDecodingError: Error {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
@ -8,25 +8,25 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct Item: Decodable {
|
public struct Item: Decodable {
|
||||||
let id: FervorID
|
public let id: FervorID
|
||||||
let feedID: FervorID
|
public let feedID: FervorID
|
||||||
let title: String
|
public let title: String?
|
||||||
let author: String
|
public let author: String?
|
||||||
let published: Date?
|
public let published: Date?
|
||||||
let createdAt: Date?
|
public let createdAt: Date?
|
||||||
let content: String?
|
public let content: String?
|
||||||
let summary: String?
|
public let summary: String?
|
||||||
let url: URL
|
public let url: URL
|
||||||
let serviceURL: URL?
|
public let serviceURL: URL?
|
||||||
let read: Bool?
|
public let read: Bool?
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
self.id = try container.decode(FervorID.self, forKey: .id)
|
self.id = try container.decode(FervorID.self, forKey: .id)
|
||||||
self.feedID = try container.decode(FervorID.self, forKey: .feedID)
|
self.feedID = try container.decode(FervorID.self, forKey: .feedID)
|
||||||
self.title = try container.decode(String.self, forKey: .title)
|
self.title = try container.decodeIfPresent(String.self, forKey: .title)
|
||||||
self.author = try container.decode(String.self, forKey: .author)
|
self.author = try container.decodeIfPresent(String.self, forKey: .author)
|
||||||
self.published = try container.decodeIfPresent(Date.self, forKey: .published)
|
self.published = try container.decodeIfPresent(Date.self, forKey: .published)
|
||||||
self.createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt)
|
self.createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt)
|
||||||
self.content = try container.decodeIfPresent(String.self, forKey: .content)
|
self.content = try container.decodeIfPresent(String.self, forKey: .content)
|
||||||
|
|
|
@ -0,0 +1,30 @@
|
||||||
|
//
|
||||||
|
// ItemsSyncUpdate.swift
|
||||||
|
// Fervor
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 1/9/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct ItemsSyncUpdate: Decodable {
|
||||||
|
|
||||||
|
public let syncTimestamp: Date
|
||||||
|
public let delete: [FervorID]
|
||||||
|
public let upsert: [Item]
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
|
self.syncTimestamp = try container.decode(Date.self, forKey: .syncTimestamp)
|
||||||
|
self.delete = try container.decode([FervorID].self, forKey: .delete)
|
||||||
|
self.upsert = try container.decode([Item].self, forKey: .upsert)
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case syncTimestamp = "sync_timestamp"
|
||||||
|
case delete
|
||||||
|
case upsert
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Reader
|
||||||
|
|
||||||
|
In order to build reader you need the appropriate targets added to your Rust toolchain.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
|
||||||
|
```
|
||||||
|
|
||||||
|
x86_64-apple-ios is only necessary if you're on an Intel Mac, and aarch-64-apple-ios-sim if you're on Apple Silicon.
|
||||||
|
|
||||||
|
The Xcode build script will take care of actually building the Rust code.
|
|
@ -15,6 +15,16 @@
|
||||||
D65B18BC27504FE7004A9448 /* Token.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B18BB27504FE7004A9448 /* Token.swift */; };
|
D65B18BC27504FE7004A9448 /* Token.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B18BB27504FE7004A9448 /* Token.swift */; };
|
||||||
D65B18BE275051A1004A9448 /* LocalData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B18BD275051A1004A9448 /* LocalData.swift */; };
|
D65B18BE275051A1004A9448 /* LocalData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B18BD275051A1004A9448 /* LocalData.swift */; };
|
||||||
D65B18C127505348004A9448 /* HomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B18C027505348004A9448 /* HomeViewController.swift */; };
|
D65B18C127505348004A9448 /* HomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B18C027505348004A9448 /* HomeViewController.swift */; };
|
||||||
|
D68408E827947D0800E327D2 /* PrefsSceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68408E727947D0800E327D2 /* PrefsSceneDelegate.swift */; };
|
||||||
|
D68408ED2794803D00E327D2 /* PrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68408EC2794803D00E327D2 /* PrefsView.swift */; };
|
||||||
|
D68408EF2794808E00E327D2 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68408EE2794808E00E327D2 /* Preferences.swift */; };
|
||||||
|
D68409132794870000E327D2 /* ReaderMac.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68409122794870000E327D2 /* ReaderMac.swift */; };
|
||||||
|
D6840914279487DC00E327D2 /* ReaderMac.bundle in Embed PlugIns */ = {isa = PBXBuildFile; fileRef = D684090D279486BF00E327D2 /* ReaderMac.bundle */; platformFilter = maccatalyst; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
|
||||||
|
D68B303627907D9200E8B3FA /* ExcerptGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68B303527907D9200E8B3FA /* ExcerptGenerator.swift */; };
|
||||||
|
D68B303D2792204B00E8B3FA /* read.js in Resources */ = {isa = PBXBuildFile; fileRef = D68B303C2792204B00E8B3FA /* read.js */; };
|
||||||
|
D68B30402792729A00E8B3FA /* AppSplitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68B303F2792729A00E8B3FA /* AppSplitViewController.swift */; };
|
||||||
|
D68B304227932ED500E8B3FA /* UserActivities.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68B304127932ED500E8B3FA /* UserActivities.swift */; };
|
||||||
|
D6A8A33427766C2800CCEC72 /* PersistentContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A8A33327766C2800CCEC72 /* PersistentContainer.swift */; };
|
||||||
D6C687EC272CD27600874C10 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C687EB272CD27600874C10 /* AppDelegate.swift */; };
|
D6C687EC272CD27600874C10 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C687EB272CD27600874C10 /* AppDelegate.swift */; };
|
||||||
D6C687EE272CD27600874C10 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C687ED272CD27600874C10 /* SceneDelegate.swift */; };
|
D6C687EE272CD27600874C10 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C687ED272CD27600874C10 /* SceneDelegate.swift */; };
|
||||||
D6C687F6272CD27600874C10 /* Reader.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = D6C687F4272CD27600874C10 /* Reader.xcdatamodeld */; };
|
D6C687F6272CD27600874C10 /* Reader.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = D6C687F4272CD27600874C10 /* Reader.xcdatamodeld */; };
|
||||||
|
@ -31,9 +41,32 @@
|
||||||
D6C68834272CD44900874C10 /* Fervor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C68833272CD44900874C10 /* Fervor.swift */; };
|
D6C68834272CD44900874C10 /* Fervor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C68833272CD44900874C10 /* Fervor.swift */; };
|
||||||
D6C68856272CD7C600874C10 /* Item.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C68855272CD7C600874C10 /* Item.swift */; };
|
D6C68856272CD7C600874C10 /* Item.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C68855272CD7C600874C10 /* Item.swift */; };
|
||||||
D6C68858272CD8CD00874C10 /* Group.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C68857272CD8CD00874C10 /* Group.swift */; };
|
D6C68858272CD8CD00874C10 /* Group.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C68857272CD8CD00874C10 /* Group.swift */; };
|
||||||
|
D6E2434C278B456A0005E546 /* ItemsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E2434B278B456A0005E546 /* ItemsViewController.swift */; };
|
||||||
|
D6E24352278B6DF90005E546 /* ItemsSyncUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E24351278B6DF90005E546 /* ItemsSyncUpdate.swift */; };
|
||||||
|
D6E24357278B96E40005E546 /* Feed+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E24355278B96E40005E546 /* Feed+CoreDataClass.swift */; };
|
||||||
|
D6E24358278B96E40005E546 /* Feed+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E24356278B96E40005E546 /* Feed+CoreDataProperties.swift */; };
|
||||||
|
D6E2435D278B97240005E546 /* Item+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E24359278B97240005E546 /* Item+CoreDataClass.swift */; };
|
||||||
|
D6E2435E278B97240005E546 /* Item+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E2435A278B97240005E546 /* Item+CoreDataProperties.swift */; };
|
||||||
|
D6E2435F278B97240005E546 /* Group+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E2435B278B97240005E546 /* Group+CoreDataClass.swift */; };
|
||||||
|
D6E24360278B97240005E546 /* Group+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E2435C278B97240005E546 /* Group+CoreDataProperties.swift */; };
|
||||||
|
D6E24363278BA1410005E546 /* ItemCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E24361278BA1410005E546 /* ItemCollectionViewCell.swift */; };
|
||||||
|
D6E24369278BABB40005E546 /* UIColor+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E24368278BABB40005E546 /* UIColor+App.swift */; };
|
||||||
|
D6E2436B278BB1880005E546 /* HomeCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E2436A278BB1880005E546 /* HomeCollectionViewCell.swift */; };
|
||||||
|
D6E2436E278BD8160005E546 /* ReadViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E2436D278BD8160005E546 /* ReadViewController.swift */; };
|
||||||
|
D6E24371278BE1250005E546 /* HTMLEntities in Frameworks */ = {isa = PBXBuildFile; productRef = D6E24370278BE1250005E546 /* HTMLEntities */; };
|
||||||
|
D6E24373278BE2B80005E546 /* read.css in Resources */ = {isa = PBXBuildFile; fileRef = D6E24372278BE2B80005E546 /* read.css */; };
|
||||||
|
D6EB531D278C89C300AD2E61 /* AppNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EB531C278C89C300AD2E61 /* AppNavigationController.swift */; };
|
||||||
|
D6EB531F278E4A7500AD2E61 /* StretchyMenuInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EB531E278E4A7500AD2E61 /* StretchyMenuInteraction.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
D6840915279487DC00E327D2 /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = D6C687E0272CD27600874C10 /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = D684090C279486BF00E327D2;
|
||||||
|
remoteInfo = ReaderMac;
|
||||||
|
};
|
||||||
D6C68802272CD27700874C10 /* PBXContainerItemProxy */ = {
|
D6C68802272CD27700874C10 /* PBXContainerItemProxy */ = {
|
||||||
isa = PBXContainerItemProxy;
|
isa = PBXContainerItemProxy;
|
||||||
containerPortal = D6C687E0272CD27600874C10 /* Project object */;
|
containerPortal = D6C687E0272CD27600874C10 /* Project object */;
|
||||||
|
@ -58,6 +91,17 @@
|
||||||
/* End PBXContainerItemProxy section */
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
/* Begin PBXCopyFilesBuildPhase section */
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
|
D6840917279487DD00E327D2 /* Embed PlugIns */ = {
|
||||||
|
isa = PBXCopyFilesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
dstPath = "";
|
||||||
|
dstSubfolderSpec = 13;
|
||||||
|
files = (
|
||||||
|
D6840914279487DC00E327D2 /* ReaderMac.bundle in Embed PlugIns */,
|
||||||
|
);
|
||||||
|
name = "Embed PlugIns";
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
D6C6882E272CD2BA00874C10 /* Embed Frameworks */ = {
|
D6C6882E272CD2BA00874C10 /* Embed Frameworks */ = {
|
||||||
isa = PBXCopyFilesBuildPhase;
|
isa = PBXCopyFilesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
|
@ -80,6 +124,19 @@
|
||||||
D65B18BB27504FE7004A9448 /* Token.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Token.swift; sourceTree = "<group>"; };
|
D65B18BB27504FE7004A9448 /* Token.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Token.swift; sourceTree = "<group>"; };
|
||||||
D65B18BD275051A1004A9448 /* LocalData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalData.swift; sourceTree = "<group>"; };
|
D65B18BD275051A1004A9448 /* LocalData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalData.swift; sourceTree = "<group>"; };
|
||||||
D65B18C027505348004A9448 /* HomeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewController.swift; sourceTree = "<group>"; };
|
D65B18C027505348004A9448 /* HomeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewController.swift; sourceTree = "<group>"; };
|
||||||
|
D68408E727947D0800E327D2 /* PrefsSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefsSceneDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
D68408EC2794803D00E327D2 /* PrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefsView.swift; sourceTree = "<group>"; };
|
||||||
|
D68408EE2794808E00E327D2 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = "<group>"; };
|
||||||
|
D684090D279486BF00E327D2 /* ReaderMac.bundle */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ReaderMac.bundle; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
D68409122794870000E327D2 /* ReaderMac.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderMac.swift; sourceTree = "<group>"; };
|
||||||
|
D68B3032278FDD1A00E8B3FA /* Reader-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Reader-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
|
D68B303527907D9200E8B3FA /* ExcerptGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExcerptGenerator.swift; sourceTree = "<group>"; };
|
||||||
|
D68B3037279099FD00E8B3FA /* liblolhtml.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = liblolhtml.a; path = "lol-html/c-api/target/aarch64-apple-ios-sim/release/liblolhtml.a"; sourceTree = "<group>"; };
|
||||||
|
D68B303C2792204B00E8B3FA /* read.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = read.js; sourceTree = "<group>"; };
|
||||||
|
D68B303E27923C0000E8B3FA /* Reader.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Reader.entitlements; sourceTree = "<group>"; };
|
||||||
|
D68B303F2792729A00E8B3FA /* AppSplitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSplitViewController.swift; sourceTree = "<group>"; };
|
||||||
|
D68B304127932ED500E8B3FA /* UserActivities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserActivities.swift; sourceTree = "<group>"; };
|
||||||
|
D6A8A33327766C2800CCEC72 /* PersistentContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistentContainer.swift; sourceTree = "<group>"; };
|
||||||
D6C687E8272CD27600874C10 /* Reader.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Reader.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
D6C687E8272CD27600874C10 /* Reader.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Reader.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
D6C687EB272CD27600874C10 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
D6C687EB272CD27600874C10 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
D6C687ED272CD27600874C10 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
D6C687ED272CD27600874C10 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
@ -99,14 +156,37 @@
|
||||||
D6C68833272CD44900874C10 /* Fervor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Fervor.swift; sourceTree = "<group>"; };
|
D6C68833272CD44900874C10 /* Fervor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Fervor.swift; sourceTree = "<group>"; };
|
||||||
D6C68855272CD7C600874C10 /* Item.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Item.swift; sourceTree = "<group>"; };
|
D6C68855272CD7C600874C10 /* Item.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Item.swift; sourceTree = "<group>"; };
|
||||||
D6C68857272CD8CD00874C10 /* Group.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Group.swift; sourceTree = "<group>"; };
|
D6C68857272CD8CD00874C10 /* Group.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Group.swift; sourceTree = "<group>"; };
|
||||||
|
D6E2434B278B456A0005E546 /* ItemsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemsViewController.swift; sourceTree = "<group>"; };
|
||||||
|
D6E24351278B6DF90005E546 /* ItemsSyncUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemsSyncUpdate.swift; sourceTree = "<group>"; };
|
||||||
|
D6E24355278B96E40005E546 /* Feed+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Feed+CoreDataClass.swift"; sourceTree = "<group>"; };
|
||||||
|
D6E24356278B96E40005E546 /* Feed+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Feed+CoreDataProperties.swift"; sourceTree = "<group>"; };
|
||||||
|
D6E24359278B97240005E546 /* Item+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Item+CoreDataClass.swift"; sourceTree = "<group>"; };
|
||||||
|
D6E2435A278B97240005E546 /* Item+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Item+CoreDataProperties.swift"; sourceTree = "<group>"; };
|
||||||
|
D6E2435B278B97240005E546 /* Group+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Group+CoreDataClass.swift"; sourceTree = "<group>"; };
|
||||||
|
D6E2435C278B97240005E546 /* Group+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Group+CoreDataProperties.swift"; sourceTree = "<group>"; };
|
||||||
|
D6E24361278BA1410005E546 /* ItemCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||||
|
D6E24368278BABB40005E546 /* UIColor+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+App.swift"; sourceTree = "<group>"; };
|
||||||
|
D6E2436A278BB1880005E546 /* HomeCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||||
|
D6E2436D278BD8160005E546 /* ReadViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadViewController.swift; sourceTree = "<group>"; };
|
||||||
|
D6E24372278BE2B80005E546 /* read.css */ = {isa = PBXFileReference; lastKnownFileType = text.css; path = read.css; sourceTree = "<group>"; };
|
||||||
|
D6EB531C278C89C300AD2E61 /* AppNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNavigationController.swift; sourceTree = "<group>"; };
|
||||||
|
D6EB531E278E4A7500AD2E61 /* StretchyMenuInteraction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StretchyMenuInteraction.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
D684090A279486BF00E327D2 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
D6C687E5272CD27600874C10 /* Frameworks */ = {
|
D6C687E5272CD27600874C10 /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
D6C68829272CD2BA00874C10 /* Fervor.framework in Frameworks */,
|
D6C68829272CD2BA00874C10 /* Fervor.framework in Frameworks */,
|
||||||
|
D6E24371278BE1250005E546 /* HTMLEntities in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
@ -137,8 +217,13 @@
|
||||||
D65B18AF2750468B004A9448 /* Screens */ = {
|
D65B18AF2750468B004A9448 /* Screens */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
D6EB531C278C89C300AD2E61 /* AppNavigationController.swift */,
|
||||||
|
D68B303F2792729A00E8B3FA /* AppSplitViewController.swift */,
|
||||||
D65B18BF2750533E004A9448 /* Home */,
|
D65B18BF2750533E004A9448 /* Home */,
|
||||||
D65B18B027504691004A9448 /* Login */,
|
D65B18B027504691004A9448 /* Login */,
|
||||||
|
D6E2434A278B455C0005E546 /* Items */,
|
||||||
|
D6E2436C278BD80B0005E546 /* Read */,
|
||||||
|
D68408E927947E3800E327D2 /* Preferences */,
|
||||||
);
|
);
|
||||||
path = Screens;
|
path = Screens;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -155,18 +240,60 @@
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
D65B18C027505348004A9448 /* HomeViewController.swift */,
|
D65B18C027505348004A9448 /* HomeViewController.swift */,
|
||||||
|
D6E2436A278BB1880005E546 /* HomeCollectionViewCell.swift */,
|
||||||
);
|
);
|
||||||
path = Home;
|
path = Home;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
D68408E927947E3800E327D2 /* Preferences */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
D68408EC2794803D00E327D2 /* PrefsView.swift */,
|
||||||
|
);
|
||||||
|
path = Preferences;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
D6840911279486C400E327D2 /* ReaderMac */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
D68409122794870000E327D2 /* ReaderMac.swift */,
|
||||||
|
);
|
||||||
|
path = ReaderMac;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
D68B302E278FDCE200E8B3FA /* Frameworks */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
D68B3037279099FD00E8B3FA /* liblolhtml.a */,
|
||||||
|
);
|
||||||
|
name = Frameworks;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
D6A8A33527766E9300CCEC72 /* CoreData */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
D6C687F4272CD27600874C10 /* Reader.xcdatamodeld */,
|
||||||
|
D6A8A33327766C2800CCEC72 /* PersistentContainer.swift */,
|
||||||
|
D6E24355278B96E40005E546 /* Feed+CoreDataClass.swift */,
|
||||||
|
D6E24356278B96E40005E546 /* Feed+CoreDataProperties.swift */,
|
||||||
|
D6E2435B278B97240005E546 /* Group+CoreDataClass.swift */,
|
||||||
|
D6E2435C278B97240005E546 /* Group+CoreDataProperties.swift */,
|
||||||
|
D6E24359278B97240005E546 /* Item+CoreDataClass.swift */,
|
||||||
|
D6E2435A278B97240005E546 /* Item+CoreDataProperties.swift */,
|
||||||
|
);
|
||||||
|
path = CoreData;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
D6C687DF272CD27600874C10 = {
|
D6C687DF272CD27600874C10 = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
D6C687EA272CD27600874C10 /* Reader */,
|
D6C687EA272CD27600874C10 /* Reader */,
|
||||||
D6C68804272CD27700874C10 /* ReaderTests */,
|
D6C68804272CD27700874C10 /* ReaderTests */,
|
||||||
D6C6880E272CD27700874C10 /* ReaderUITests */,
|
D6C6880E272CD27700874C10 /* ReaderUITests */,
|
||||||
|
D6840911279486C400E327D2 /* ReaderMac */,
|
||||||
D6C68824272CD2BA00874C10 /* Fervor */,
|
D6C68824272CD2BA00874C10 /* Fervor */,
|
||||||
D6C687E9272CD27600874C10 /* Products */,
|
D6C687E9272CD27600874C10 /* Products */,
|
||||||
|
D68B302E278FDCE200E8B3FA /* Frameworks */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
@ -177,6 +304,7 @@
|
||||||
D6C68801272CD27700874C10 /* ReaderTests.xctest */,
|
D6C68801272CD27700874C10 /* ReaderTests.xctest */,
|
||||||
D6C6880B272CD27700874C10 /* ReaderUITests.xctest */,
|
D6C6880B272CD27700874C10 /* ReaderUITests.xctest */,
|
||||||
D6C68823272CD2BA00874C10 /* Fervor.framework */,
|
D6C68823272CD2BA00874C10 /* Fervor.framework */,
|
||||||
|
D684090D279486BF00E327D2 /* ReaderMac.bundle */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -184,15 +312,25 @@
|
||||||
D6C687EA272CD27600874C10 /* Reader */ = {
|
D6C687EA272CD27600874C10 /* Reader */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
D68B303E27923C0000E8B3FA /* Reader.entitlements */,
|
||||||
|
D68B3032278FDD1A00E8B3FA /* Reader-Bridging-Header.h */,
|
||||||
D6C687EB272CD27600874C10 /* AppDelegate.swift */,
|
D6C687EB272CD27600874C10 /* AppDelegate.swift */,
|
||||||
D6C687ED272CD27600874C10 /* SceneDelegate.swift */,
|
D6C687ED272CD27600874C10 /* SceneDelegate.swift */,
|
||||||
|
D68408E727947D0800E327D2 /* PrefsSceneDelegate.swift */,
|
||||||
D65B18B527504920004A9448 /* FervorController.swift */,
|
D65B18B527504920004A9448 /* FervorController.swift */,
|
||||||
D65B18BD275051A1004A9448 /* LocalData.swift */,
|
D65B18BD275051A1004A9448 /* LocalData.swift */,
|
||||||
|
D6E24368278BABB40005E546 /* UIColor+App.swift */,
|
||||||
|
D6EB531E278E4A7500AD2E61 /* StretchyMenuInteraction.swift */,
|
||||||
|
D68B303527907D9200E8B3FA /* ExcerptGenerator.swift */,
|
||||||
|
D68B304127932ED500E8B3FA /* UserActivities.swift */,
|
||||||
|
D68408EE2794808E00E327D2 /* Preferences.swift */,
|
||||||
|
D6A8A33527766E9300CCEC72 /* CoreData */,
|
||||||
D65B18AF2750468B004A9448 /* Screens */,
|
D65B18AF2750468B004A9448 /* Screens */,
|
||||||
D6C687F7272CD27700874C10 /* Assets.xcassets */,
|
D6C687F7272CD27700874C10 /* Assets.xcassets */,
|
||||||
D6C687F9272CD27700874C10 /* LaunchScreen.storyboard */,
|
D6C687F9272CD27700874C10 /* LaunchScreen.storyboard */,
|
||||||
D6C687FC272CD27700874C10 /* Info.plist */,
|
D6C687FC272CD27700874C10 /* Info.plist */,
|
||||||
D6C687F4272CD27600874C10 /* Reader.xcdatamodeld */,
|
D6E24372278BE2B80005E546 /* read.css */,
|
||||||
|
D68B303C2792204B00E8B3FA /* read.js */,
|
||||||
);
|
);
|
||||||
path = Reader;
|
path = Reader;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -226,11 +364,29 @@
|
||||||
D6C68857272CD8CD00874C10 /* Group.swift */,
|
D6C68857272CD8CD00874C10 /* Group.swift */,
|
||||||
D6C6882F272CD2CF00874C10 /* Instance.swift */,
|
D6C6882F272CD2CF00874C10 /* Instance.swift */,
|
||||||
D6C68855272CD7C600874C10 /* Item.swift */,
|
D6C68855272CD7C600874C10 /* Item.swift */,
|
||||||
|
D6E24351278B6DF90005E546 /* ItemsSyncUpdate.swift */,
|
||||||
D65B18BB27504FE7004A9448 /* Token.swift */,
|
D65B18BB27504FE7004A9448 /* Token.swift */,
|
||||||
);
|
);
|
||||||
path = Fervor;
|
path = Fervor;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
D6E2434A278B455C0005E546 /* Items */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
D6E2434B278B456A0005E546 /* ItemsViewController.swift */,
|
||||||
|
D6E24361278BA1410005E546 /* ItemCollectionViewCell.swift */,
|
||||||
|
);
|
||||||
|
path = Items;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
D6E2436C278BD80B0005E546 /* Read */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
D6E2436D278BD8160005E546 /* ReadViewController.swift */,
|
||||||
|
);
|
||||||
|
path = Read;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXHeadersBuildPhase section */
|
/* Begin PBXHeadersBuildPhase section */
|
||||||
|
@ -245,21 +401,44 @@
|
||||||
/* End PBXHeadersBuildPhase section */
|
/* End PBXHeadersBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
|
D684090C279486BF00E327D2 /* ReaderMac */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = D6840910279486BF00E327D2 /* Build configuration list for PBXNativeTarget "ReaderMac" */;
|
||||||
|
buildPhases = (
|
||||||
|
D6840909279486BF00E327D2 /* Sources */,
|
||||||
|
D684090A279486BF00E327D2 /* Frameworks */,
|
||||||
|
D684090B279486BF00E327D2 /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = ReaderMac;
|
||||||
|
productName = ReaderMac;
|
||||||
|
productReference = D684090D279486BF00E327D2 /* ReaderMac.bundle */;
|
||||||
|
productType = "com.apple.product-type.bundle";
|
||||||
|
};
|
||||||
D6C687E7272CD27600874C10 /* Reader */ = {
|
D6C687E7272CD27600874C10 /* Reader */ = {
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = D6C68815272CD27700874C10 /* Build configuration list for PBXNativeTarget "Reader" */;
|
buildConfigurationList = D6C68815272CD27700874C10 /* Build configuration list for PBXNativeTarget "Reader" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
|
D68B303B2791D2A900E8B3FA /* Compile lol-html c-api */,
|
||||||
D6C687E4272CD27600874C10 /* Sources */,
|
D6C687E4272CD27600874C10 /* Sources */,
|
||||||
D6C687E5272CD27600874C10 /* Frameworks */,
|
D6C687E5272CD27600874C10 /* Frameworks */,
|
||||||
D6C687E6272CD27600874C10 /* Resources */,
|
D6C687E6272CD27600874C10 /* Resources */,
|
||||||
D6C6882E272CD2BA00874C10 /* Embed Frameworks */,
|
D6C6882E272CD2BA00874C10 /* Embed Frameworks */,
|
||||||
|
D6840917279487DD00E327D2 /* Embed PlugIns */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
D6C68828272CD2BA00874C10 /* PBXTargetDependency */,
|
D6C68828272CD2BA00874C10 /* PBXTargetDependency */,
|
||||||
|
D6840916279487DC00E327D2 /* PBXTargetDependency */,
|
||||||
);
|
);
|
||||||
name = Reader;
|
name = Reader;
|
||||||
|
packageProductDependencies = (
|
||||||
|
D6E24370278BE1250005E546 /* HTMLEntities */,
|
||||||
|
);
|
||||||
productName = Reader;
|
productName = Reader;
|
||||||
productReference = D6C687E8272CD27600874C10 /* Reader.app */;
|
productReference = D6C687E8272CD27600874C10 /* Reader.app */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.application";
|
||||||
|
@ -328,6 +507,10 @@
|
||||||
LastSwiftUpdateCheck = 1320;
|
LastSwiftUpdateCheck = 1320;
|
||||||
LastUpgradeCheck = 1320;
|
LastUpgradeCheck = 1320;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
|
D684090C279486BF00E327D2 = {
|
||||||
|
CreatedOnToolsVersion = 13.2;
|
||||||
|
LastSwiftMigration = 1320;
|
||||||
|
};
|
||||||
D6C687E7272CD27600874C10 = {
|
D6C687E7272CD27600874C10 = {
|
||||||
CreatedOnToolsVersion = 13.2;
|
CreatedOnToolsVersion = 13.2;
|
||||||
};
|
};
|
||||||
|
@ -354,6 +537,9 @@
|
||||||
Base,
|
Base,
|
||||||
);
|
);
|
||||||
mainGroup = D6C687DF272CD27600874C10;
|
mainGroup = D6C687DF272CD27600874C10;
|
||||||
|
packageReferences = (
|
||||||
|
D6E2436F278BE1250005E546 /* XCRemoteSwiftPackageReference "swift-html-entities" */,
|
||||||
|
);
|
||||||
productRefGroup = D6C687E9272CD27600874C10 /* Products */;
|
productRefGroup = D6C687E9272CD27600874C10 /* Products */;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
|
@ -362,17 +548,27 @@
|
||||||
D6C68800272CD27700874C10 /* ReaderTests */,
|
D6C68800272CD27700874C10 /* ReaderTests */,
|
||||||
D6C6880A272CD27700874C10 /* ReaderUITests */,
|
D6C6880A272CD27700874C10 /* ReaderUITests */,
|
||||||
D6C68822272CD2BA00874C10 /* Fervor */,
|
D6C68822272CD2BA00874C10 /* Fervor */,
|
||||||
|
D684090C279486BF00E327D2 /* ReaderMac */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
|
|
||||||
/* Begin PBXResourcesBuildPhase section */
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
D684090B279486BF00E327D2 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
D6C687E6272CD27600874C10 /* Resources */ = {
|
D6C687E6272CD27600874C10 /* Resources */ = {
|
||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
D6C687FB272CD27700874C10 /* LaunchScreen.storyboard in Resources */,
|
D6C687FB272CD27700874C10 /* LaunchScreen.storyboard in Resources */,
|
||||||
D6C687F8272CD27700874C10 /* Assets.xcassets in Resources */,
|
D6C687F8272CD27700874C10 /* Assets.xcassets in Resources */,
|
||||||
|
D68B303D2792204B00E8B3FA /* read.js in Resources */,
|
||||||
|
D6E24373278BE2B80005E546 /* read.css in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
@ -399,16 +595,66 @@
|
||||||
};
|
};
|
||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXShellScriptBuildPhase section */
|
||||||
|
D68B303B2791D2A900E8B3FA /* Compile lol-html c-api */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
alwaysOutOfDate = 1;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
|
name = "Compile lol-html c-api";
|
||||||
|
outputFileListPaths = (
|
||||||
|
);
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/bash;
|
||||||
|
shellScript = "pushd \"$PROJECT_DIR/lol-html/c-api/\"\n\nbuild() {\n echo \"Building lol-html with CARGO_TARGET: $1\"\n\n ~/.cargo/bin/cargo build --release --target $1\n}\n\nbuild_std() {\n echo \"Building lol-html with CARGO_TARGET: $1\"\n echo \"Building std enabled\"\n \n ~/.cargo/bin/cargo +nightly build -Z build-std=panic_abort,std --release --target $1\n}\n\nif [ \"$PLATFORM_NAME\" == \"iphonesimulator\" ]; then\n if [ \"$ARCHS\" == \"arm64\" ]; then\n build \"aarch64-apple-ios-sim\"\n elif [ \"$ARCHS\" == \"x86_64\" ]; then\n build \"x86_64-apple-ios\"\n else\n echo \"error: unknown value for \\$ARCHS\"\n exit 1\n fi\nelif [ \"$PLATFORM_NAME\" == \"iphoneos\" ]; then\n build \"aarch64-apple-ios\"\nelif [ \"$PLATFORM_NAME\" == \"macosx\" ]; then\n if grep -q \"arm64\" <<< \"$ARCHS\"; then\n build_std \"aarch64-apple-ios-macabi\"\n fi\n if grep -q \"x86_64\" <<< \"$ARCHS\"; then\n build_std \"x86_64-apple-ios-macabi\"\n fi\nelse\n echo \"error: unknown value for \\$PLATFORM_NAME\"\n exit 1\nfi\n";
|
||||||
|
};
|
||||||
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
D6840909279486BF00E327D2 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
D68409132794870000E327D2 /* ReaderMac.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
D6C687E4272CD27600874C10 /* Sources */ = {
|
D6C687E4272CD27600874C10 /* Sources */ = {
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
D6A8A33427766C2800CCEC72 /* PersistentContainer.swift in Sources */,
|
||||||
|
D6E24357278B96E40005E546 /* Feed+CoreDataClass.swift in Sources */,
|
||||||
D65B18B627504920004A9448 /* FervorController.swift in Sources */,
|
D65B18B627504920004A9448 /* FervorController.swift in Sources */,
|
||||||
|
D68B304227932ED500E8B3FA /* UserActivities.swift in Sources */,
|
||||||
D6C687EC272CD27600874C10 /* AppDelegate.swift in Sources */,
|
D6C687EC272CD27600874C10 /* AppDelegate.swift in Sources */,
|
||||||
D6C687F6272CD27600874C10 /* Reader.xcdatamodeld in Sources */,
|
D6C687F6272CD27600874C10 /* Reader.xcdatamodeld in Sources */,
|
||||||
|
D6E2436B278BB1880005E546 /* HomeCollectionViewCell.swift in Sources */,
|
||||||
|
D6E2435F278B97240005E546 /* Group+CoreDataClass.swift in Sources */,
|
||||||
|
D6E24369278BABB40005E546 /* UIColor+App.swift in Sources */,
|
||||||
|
D6E2435D278B97240005E546 /* Item+CoreDataClass.swift in Sources */,
|
||||||
|
D68408E827947D0800E327D2 /* PrefsSceneDelegate.swift in Sources */,
|
||||||
|
D6EB531D278C89C300AD2E61 /* AppNavigationController.swift in Sources */,
|
||||||
|
D6E24360278B97240005E546 /* Group+CoreDataProperties.swift in Sources */,
|
||||||
|
D6E2434C278B456A0005E546 /* ItemsViewController.swift in Sources */,
|
||||||
|
D6E2435E278B97240005E546 /* Item+CoreDataProperties.swift in Sources */,
|
||||||
|
D6EB531F278E4A7500AD2E61 /* StretchyMenuInteraction.swift in Sources */,
|
||||||
|
D6E24358278B96E40005E546 /* Feed+CoreDataProperties.swift in Sources */,
|
||||||
|
D68B30402792729A00E8B3FA /* AppSplitViewController.swift in Sources */,
|
||||||
D65B18BE275051A1004A9448 /* LocalData.swift in Sources */,
|
D65B18BE275051A1004A9448 /* LocalData.swift in Sources */,
|
||||||
D65B18B22750469D004A9448 /* LoginViewController.swift in Sources */,
|
D65B18B22750469D004A9448 /* LoginViewController.swift in Sources */,
|
||||||
|
D68408ED2794803D00E327D2 /* PrefsView.swift in Sources */,
|
||||||
|
D68408EF2794808E00E327D2 /* Preferences.swift in Sources */,
|
||||||
|
D68B303627907D9200E8B3FA /* ExcerptGenerator.swift in Sources */,
|
||||||
|
D6E24363278BA1410005E546 /* ItemCollectionViewCell.swift in Sources */,
|
||||||
|
D6E2436E278BD8160005E546 /* ReadViewController.swift in Sources */,
|
||||||
D65B18C127505348004A9448 /* HomeViewController.swift in Sources */,
|
D65B18C127505348004A9448 /* HomeViewController.swift in Sources */,
|
||||||
D6C687EE272CD27600874C10 /* SceneDelegate.swift in Sources */,
|
D6C687EE272CD27600874C10 /* SceneDelegate.swift in Sources */,
|
||||||
);
|
);
|
||||||
|
@ -438,6 +684,7 @@
|
||||||
D65B18BC27504FE7004A9448 /* Token.swift in Sources */,
|
D65B18BC27504FE7004A9448 /* Token.swift in Sources */,
|
||||||
D6C68856272CD7C600874C10 /* Item.swift in Sources */,
|
D6C68856272CD7C600874C10 /* Item.swift in Sources */,
|
||||||
D6C68830272CD2CF00874C10 /* Instance.swift in Sources */,
|
D6C68830272CD2CF00874C10 /* Instance.swift in Sources */,
|
||||||
|
D6E24352278B6DF90005E546 /* ItemsSyncUpdate.swift in Sources */,
|
||||||
D6C68832272CD40600874C10 /* Feed.swift in Sources */,
|
D6C68832272CD40600874C10 /* Feed.swift in Sources */,
|
||||||
D6C68858272CD8CD00874C10 /* Group.swift in Sources */,
|
D6C68858272CD8CD00874C10 /* Group.swift in Sources */,
|
||||||
D6C68834272CD44900874C10 /* Fervor.swift in Sources */,
|
D6C68834272CD44900874C10 /* Fervor.swift in Sources */,
|
||||||
|
@ -450,6 +697,12 @@
|
||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXTargetDependency section */
|
/* Begin PBXTargetDependency section */
|
||||||
|
D6840916279487DC00E327D2 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
platformFilter = maccatalyst;
|
||||||
|
target = D684090C279486BF00E327D2 /* ReaderMac */;
|
||||||
|
targetProxy = D6840915279487DC00E327D2 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
D6C68803272CD27700874C10 /* PBXTargetDependency */ = {
|
D6C68803272CD27700874C10 /* PBXTargetDependency */ = {
|
||||||
isa = PBXTargetDependency;
|
isa = PBXTargetDependency;
|
||||||
target = D6C687E7272CD27600874C10 /* Reader */;
|
target = D6C687E7272CD27600874C10 /* Reader */;
|
||||||
|
@ -479,6 +732,65 @@
|
||||||
/* End PBXVariantGroup section */
|
/* End PBXVariantGroup section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
|
D684090E279486BF00E327D2 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = ZPBBSK8L8B;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
|
INFOPLIST_KEY_NSPrincipalClass = "";
|
||||||
|
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles";
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/../Frameworks",
|
||||||
|
"@loader_path/../Frameworks",
|
||||||
|
);
|
||||||
|
MACOSX_DEPLOYMENT_TARGET = 12.1;
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = net.shadowfacts.Reader.ReaderMac;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = macosx;
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
WRAPPER_EXTENSION = bundle;
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
D684090F279486BF00E327D2 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = ZPBBSK8L8B;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
|
INFOPLIST_KEY_NSPrincipalClass = "";
|
||||||
|
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles";
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/../Frameworks",
|
||||||
|
"@loader_path/../Frameworks",
|
||||||
|
);
|
||||||
|
MACOSX_DEPLOYMENT_TARGET = 12.1;
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = net.shadowfacts.Reader.ReaderMac;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = macosx;
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
WRAPPER_EXTENSION = bundle;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
D6C68813272CD27700874C10 /* Debug */ = {
|
D6C68813272CD27700874C10 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
|
@ -530,6 +842,7 @@
|
||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
HEADER_SEARCH_PATHS = "";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.2;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.2;
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
|
@ -537,6 +850,7 @@
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
SWIFT_OPTIMIZE_OBJECT_LIFETIME = YES;
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
|
@ -585,12 +899,14 @@
|
||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
HEADER_SEARCH_PATHS = "";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.2;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.2;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
|
SWIFT_OPTIMIZE_OBJECT_LIFETIME = YES;
|
||||||
VALIDATE_PRODUCT = YES;
|
VALIDATE_PRODUCT = YES;
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
|
@ -601,10 +917,13 @@
|
||||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Reader/Reader.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = ZPBBSK8L8B;
|
DEVELOPMENT_TEAM = ZPBBSK8L8B;
|
||||||
|
EXCLUDED_ARCHS = "";
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
HEADER_SEARCH_PATHS = "$(PROJECT_DIR)/lol-html/c-api/include/";
|
||||||
INFOPLIST_FILE = Reader/Info.plist;
|
INFOPLIST_FILE = Reader/Info.plist;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||||
|
@ -615,12 +934,21 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
|
LIBRARY_SEARCH_PATHS = "$(inherited)";
|
||||||
|
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/lol-html/c-api/target/aarch64-apple-ios/release/";
|
||||||
|
"LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*][arch=arm64]" = "$(PROJECT_DIR)/lol-html/c-api/target/aarch64-apple-ios-sim/release/";
|
||||||
|
"LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*][arch=x86_64]" = "$(PROJECT_DIR)/lol-html/c-api/target/x86_64-apple-ios/release/";
|
||||||
|
"LIBRARY_SEARCH_PATHS[sdk=macosx*][arch=arm64]" = "$(PROJECT_DIR)/lol-html/c-api/target/aarch64-apple-ios-macabi/release";
|
||||||
|
"LIBRARY_SEARCH_PATHS[sdk=macosx*][arch=x86_64]" = "$(PROJECT_DIR)/lol-html/c-api/target/x86_64-apple-ios-macabi/release";
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
|
OTHER_LDFLAGS = "-llolhtml";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = net.shadowfacts.Reader;
|
PRODUCT_BUNDLE_IDENTIFIER = net.shadowfacts.Reader;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SUPPORTS_MACCATALYST = YES;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_OBJC_BRIDGING_HEADER = "Reader/Reader-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2,6";
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
|
@ -630,10 +958,13 @@
|
||||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Reader/Reader.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = ZPBBSK8L8B;
|
DEVELOPMENT_TEAM = ZPBBSK8L8B;
|
||||||
|
EXCLUDED_ARCHS = "";
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
HEADER_SEARCH_PATHS = "$(PROJECT_DIR)/lol-html/c-api/include/";
|
||||||
INFOPLIST_FILE = Reader/Info.plist;
|
INFOPLIST_FILE = Reader/Info.plist;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||||
|
@ -644,12 +975,21 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
|
LIBRARY_SEARCH_PATHS = "$(inherited)";
|
||||||
|
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/lol-html/c-api/target/aarch64-apple-ios/release/";
|
||||||
|
"LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*][arch=arm64]" = "$(PROJECT_DIR)/lol-html/c-api/target/aarch64-apple-ios-sim/release/";
|
||||||
|
"LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*][arch=x86_64]" = "$(PROJECT_DIR)/lol-html/c-api/target/x86_64-apple-ios/release/";
|
||||||
|
"LIBRARY_SEARCH_PATHS[sdk=macosx*][arch=arm64]" = "$(PROJECT_DIR)/lol-html/c-api/target/aarch64-apple-ios-macabi/release";
|
||||||
|
"LIBRARY_SEARCH_PATHS[sdk=macosx*][arch=x86_64]" = "$(PROJECT_DIR)/lol-html/c-api/target/x86_64-apple-ios-macabi/release";
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
|
OTHER_LDFLAGS = "-llolhtml";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = net.shadowfacts.Reader;
|
PRODUCT_BUNDLE_IDENTIFIER = net.shadowfacts.Reader;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SUPPORTS_MACCATALYST = YES;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_OBJC_BRIDGING_HEADER = "Reader/Reader-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2,6";
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
|
@ -662,6 +1002,7 @@
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = ZPBBSK8L8B;
|
DEVELOPMENT_TEAM = ZPBBSK8L8B;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
HEADER_SEARCH_PATHS = "$(PROJECT_DIR)/lol-html/c-api/include/";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.2;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.2;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = net.shadowfacts.ReaderTests;
|
PRODUCT_BUNDLE_IDENTIFIER = net.shadowfacts.ReaderTests;
|
||||||
|
@ -682,6 +1023,7 @@
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = ZPBBSK8L8B;
|
DEVELOPMENT_TEAM = ZPBBSK8L8B;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
HEADER_SEARCH_PATHS = "$(PROJECT_DIR)/lol-html/c-api/include/";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.2;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.2;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = net.shadowfacts.ReaderTests;
|
PRODUCT_BUNDLE_IDENTIFIER = net.shadowfacts.ReaderTests;
|
||||||
|
@ -797,6 +1139,15 @@
|
||||||
/* End XCBuildConfiguration section */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
|
D6840910279486BF00E327D2 /* Build configuration list for PBXNativeTarget "ReaderMac" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
D684090E279486BF00E327D2 /* Debug */,
|
||||||
|
D684090F279486BF00E327D2 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
D6C687E3272CD27600874C10 /* Build configuration list for PBXProject "Reader" */ = {
|
D6C687E3272CD27600874C10 /* Build configuration list for PBXProject "Reader" */ = {
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
|
@ -844,6 +1195,25 @@
|
||||||
};
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
|
/* Begin XCRemoteSwiftPackageReference section */
|
||||||
|
D6E2436F278BE1250005E546 /* XCRemoteSwiftPackageReference "swift-html-entities" */ = {
|
||||||
|
isa = XCRemoteSwiftPackageReference;
|
||||||
|
repositoryURL = "https://github.com/Kitura/swift-html-entities.git";
|
||||||
|
requirement = {
|
||||||
|
kind = upToNextMajorVersion;
|
||||||
|
minimumVersion = 4.0.0;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/* End XCRemoteSwiftPackageReference section */
|
||||||
|
|
||||||
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
|
D6E24370278BE1250005E546 /* HTMLEntities */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = D6E2436F278BE1250005E546 /* XCRemoteSwiftPackageReference "swift-html-entities" */;
|
||||||
|
productName = HTMLEntities;
|
||||||
|
};
|
||||||
|
/* End XCSwiftPackageProductDependency section */
|
||||||
|
|
||||||
/* Begin XCVersionGroup section */
|
/* Begin XCVersionGroup section */
|
||||||
D6C687F4272CD27600874C10 /* Reader.xcdatamodeld */ = {
|
D6C687F4272CD27600874C10 /* Reader.xcdatamodeld */ = {
|
||||||
isa = XCVersionGroup;
|
isa = XCVersionGroup;
|
||||||
|
|
|
@ -0,0 +1,104 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "1320"
|
||||||
|
version = "1.3">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "D6C687E7272CD27600874C10"
|
||||||
|
BuildableName = "Reader.app"
|
||||||
|
BlueprintName = "Reader"
|
||||||
|
ReferencedContainer = "container:Reader.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "D6C68800272CD27700874C10"
|
||||||
|
BuildableName = "ReaderTests.xctest"
|
||||||
|
BlueprintName = "ReaderTests"
|
||||||
|
ReferencedContainer = "container:Reader.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "D6C6880A272CD27700874C10"
|
||||||
|
BuildableName = "ReaderUITests.xctest"
|
||||||
|
BlueprintName = "ReaderUITests"
|
||||||
|
ReferencedContainer = "container:Reader.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
</Testables>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "D6C687E7272CD27600874C10"
|
||||||
|
BuildableName = "Reader.app"
|
||||||
|
BlueprintName = "Reader"
|
||||||
|
ReferencedContainer = "container:Reader.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
<CommandLineArguments>
|
||||||
|
<CommandLineArgument
|
||||||
|
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
|
||||||
|
isEnabled = "YES">
|
||||||
|
</CommandLineArgument>
|
||||||
|
</CommandLineArguments>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "D6C687E7272CD27600874C10"
|
||||||
|
BuildableName = "Reader.app"
|
||||||
|
BlueprintName = "Reader"
|
||||||
|
ReferencedContainer = "container:Reader.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
|
@ -6,24 +6,63 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import CoreData
|
import WebKit
|
||||||
|
import OSLog
|
||||||
|
import Combine
|
||||||
|
|
||||||
@main
|
@main
|
||||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
|
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
private var readerMac: NSObject!
|
||||||
|
#endif
|
||||||
|
|
||||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||||
// Override point for customization after application launch.
|
swizzleWKWebView()
|
||||||
|
|
||||||
|
Preferences.shared.objectWillChange
|
||||||
|
.debounce(for: .milliseconds(250), scheduler: RunLoop.main)
|
||||||
|
.sink { _ in
|
||||||
|
Preferences.save()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
let macBundleURL = Bundle.main.builtInPlugInsURL!.appendingPathComponent("ReaderMac.bundle")
|
||||||
|
let bundle = Bundle(url: macBundleURL)!
|
||||||
|
do {
|
||||||
|
try bundle.loadAndReturnError()
|
||||||
|
|
||||||
|
let clazz = NSClassFromString("ReaderMac.ReaderMac")! as! NSObject.Type
|
||||||
|
readerMac = clazz.init()
|
||||||
|
readerMac.perform(Selector(("setup")))
|
||||||
|
} catch {
|
||||||
|
print("Unable to load ReaderMac bundle: \(error)")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(updateAppearance), name: .appearanceChanged, object: nil)
|
||||||
|
updateAppearance()
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: UISceneSession Lifecycle
|
// MARK: UISceneSession Lifecycle
|
||||||
|
|
||||||
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
|
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
|
||||||
// Called when a new scene session is being created.
|
let name: String
|
||||||
// Use this method to select a configuration to create the new scene with.
|
#if targetEnvironment(macCatalyst)
|
||||||
return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
|
if options.userActivities.first?.activityType == NSUserActivity.preferencesType {
|
||||||
|
name = "prefs"
|
||||||
|
} else {
|
||||||
|
name = "main"
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
name = "main"
|
||||||
|
#endif
|
||||||
|
return UISceneConfiguration(name: name, sessionRole: connectingSceneSession.role)
|
||||||
}
|
}
|
||||||
|
|
||||||
func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
|
func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
|
||||||
|
@ -31,51 +70,80 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
// If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
|
// If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
|
||||||
// Use this method to release any resources that were specific to the discarded scenes, as they will not return.
|
// Use this method to release any resources that were specific to the discarded scenes, as they will not return.
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Core Data stack
|
override func buildMenu(with builder: UIMenuBuilder) {
|
||||||
|
if builder.system == .main {
|
||||||
lazy var persistentContainer: NSPersistentContainer = {
|
builder.insertSibling(UIMenu(options: .displayInline, children: [
|
||||||
/*
|
UIKeyCommand(title: "Preferences…", action: #selector(showPreferences), input: ",", modifierFlags: .command)
|
||||||
The persistent container for the application. This implementation
|
]), afterMenu: .about)
|
||||||
creates and returns a container, having loaded the store for the
|
|
||||||
application to it. This property is optional since there are legitimate
|
var children = [UIMenuElement]()
|
||||||
error conditions that could cause the creation of the store to fail.
|
let accounts: [UIMenuElement] = LocalData.accounts.map { account in
|
||||||
*/
|
var title = account.instanceURL.host!
|
||||||
let container = NSPersistentContainer(name: "Reader")
|
if let port = account.instanceURL.port, port != 80 && port != 443 {
|
||||||
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
|
title += ":\(port)"
|
||||||
if let error = error as NSError? {
|
}
|
||||||
// Replace this implementation with code to handle the error appropriately.
|
|
||||||
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
|
let state: UIAction.State
|
||||||
|
if let activeScene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }),
|
||||||
/*
|
let sceneDelegate = activeScene.delegate as? SceneDelegate,
|
||||||
Typical reasons for an error here include:
|
sceneDelegate.fervorController?.account?.id == account.id {
|
||||||
* The parent directory does not exist, cannot be created, or disallows writing.
|
state = .on
|
||||||
* The persistent store is not accessible, due to permissions or data protection when the device is locked.
|
} else {
|
||||||
* The device is out of space.
|
state = .off
|
||||||
* The store could not be migrated to the current model version.
|
}
|
||||||
Check the error message to determine what the actual problem was.
|
return UIAction(title: title, attributes: [], state: state) { _ in
|
||||||
*/
|
let activity = NSUserActivity.activateAccount(account)
|
||||||
fatalError("Unresolved error \(error), \(error.userInfo)")
|
let options = UIScene.ActivationRequestOptions()
|
||||||
}
|
#if targetEnvironment(macCatalyst)
|
||||||
})
|
options.collectionJoinBehavior = .disallowed
|
||||||
return container
|
#endif
|
||||||
}()
|
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: activity, options: options, errorHandler: nil)
|
||||||
|
}
|
||||||
// MARK: - Core Data Saving support
|
|
||||||
|
|
||||||
func saveContext () {
|
|
||||||
let context = persistentContainer.viewContext
|
|
||||||
if context.hasChanges {
|
|
||||||
do {
|
|
||||||
try context.save()
|
|
||||||
} catch {
|
|
||||||
// Replace this implementation with code to handle the error appropriately.
|
|
||||||
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
|
|
||||||
let nserror = error as NSError
|
|
||||||
fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
|
|
||||||
}
|
}
|
||||||
|
children.append(UIMenu(options: .displayInline, children: accounts))
|
||||||
|
children.append(UIAction(title: "Add Account...", handler: { _ in
|
||||||
|
let activity = NSUserActivity.addAccount()
|
||||||
|
let options = UIScene.ActivationRequestOptions()
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
options.collectionJoinBehavior = .disallowed
|
||||||
|
#endif
|
||||||
|
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: activity, options: options, errorHandler: nil)
|
||||||
|
}))
|
||||||
|
let account = UIMenu(title: "Account", image: nil, identifier: nil, options: [], children: children)
|
||||||
|
builder.insertSibling(account, afterMenu: .file)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func swizzleWKWebView() {
|
||||||
|
let selector = Selector(("_updateScrollViewBackground"))
|
||||||
|
var originalIMP: IMP?
|
||||||
|
let imp = imp_implementationWithBlock({ (self: WKWebView) in
|
||||||
|
if let originalIMP = originalIMP {
|
||||||
|
let original = unsafeBitCast(originalIMP, to: (@convention(c) (WKWebView, Selector) -> Void).self)
|
||||||
|
original(self, selector)
|
||||||
|
} else {
|
||||||
|
os_log(.error, "Missing originalIMP for -[WKWebView _updateScrollViewBackground], did WebKit change?")
|
||||||
|
}
|
||||||
|
|
||||||
|
self.scrollView.indicatorStyle = .default
|
||||||
|
|
||||||
|
} as (@convention(block) (WKWebView) -> Void))
|
||||||
|
originalIMP = class_replaceMethod(WKWebView.self, selector, imp, "v@:")
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func showPreferences() {
|
||||||
|
let existing = UIApplication.shared.connectedScenes.first {
|
||||||
|
$0.session.configuration.name == "prefs"
|
||||||
|
}
|
||||||
|
UIApplication.shared.requestSceneSessionActivation(existing?.session, userActivity: .preferences(), options: nil, errorHandler: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func updateAppearance() {
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
readerMac.perform(Selector(("updateAppearance:")), with: Preferences.shared.appearance.rawValue)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,30 @@
|
||||||
|
//
|
||||||
|
// Feed+CoreDataClass.swift
|
||||||
|
// Reader
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 1/9/22.
|
||||||
|
//
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CoreData
|
||||||
|
import Fervor
|
||||||
|
|
||||||
|
@objc(Feed)
|
||||||
|
public class Feed: NSManagedObject {
|
||||||
|
|
||||||
|
func updateFromServer(_ serverFeed: Fervor.Feed) {
|
||||||
|
guard self.id == nil || self.id == serverFeed.id else { return }
|
||||||
|
self.id = serverFeed.id
|
||||||
|
self.title = serverFeed.title
|
||||||
|
self.url = serverFeed.url
|
||||||
|
self.lastUpdated = serverFeed.lastUpdated
|
||||||
|
// todo: check this
|
||||||
|
self.removeFromGroups(self.groups!.filtered(using: NSPredicate(format: "NOT id IN %@", serverFeed.groupIDs)) as NSSet)
|
||||||
|
let groupsToAddReq = Group.fetchRequest()
|
||||||
|
groupsToAddReq.predicate = NSPredicate(format: "id IN %@", serverFeed.groupIDs.filter { g in !self.groups!.contains { ($0 as! Group).id == g } })
|
||||||
|
let groupsToAdd = try! self.managedObjectContext!.fetch(groupsToAddReq)
|
||||||
|
self.addToGroups(NSSet(array: groupsToAdd))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,64 @@
|
||||||
|
//
|
||||||
|
// Feed+CoreDataProperties.swift
|
||||||
|
// Reader
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 1/9/22.
|
||||||
|
//
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
|
||||||
|
extension Feed {
|
||||||
|
|
||||||
|
@nonobjc public class func fetchRequest() -> NSFetchRequest<Feed> {
|
||||||
|
return NSFetchRequest<Feed>(entityName: "Feed")
|
||||||
|
}
|
||||||
|
|
||||||
|
@NSManaged public var id: String?
|
||||||
|
@NSManaged public var lastUpdated: Date?
|
||||||
|
@NSManaged public var title: String?
|
||||||
|
@NSManaged public var url: URL?
|
||||||
|
@NSManaged public var groups: NSSet?
|
||||||
|
@NSManaged public var items: NSSet?
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Generated accessors for groups
|
||||||
|
extension Feed {
|
||||||
|
|
||||||
|
@objc(addGroupsObject:)
|
||||||
|
@NSManaged public func addToGroups(_ value: Group)
|
||||||
|
|
||||||
|
@objc(removeGroupsObject:)
|
||||||
|
@NSManaged public func removeFromGroups(_ value: Group)
|
||||||
|
|
||||||
|
@objc(addGroups:)
|
||||||
|
@NSManaged public func addToGroups(_ values: NSSet)
|
||||||
|
|
||||||
|
@objc(removeGroups:)
|
||||||
|
@NSManaged public func removeFromGroups(_ values: NSSet)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Generated accessors for items
|
||||||
|
extension Feed {
|
||||||
|
|
||||||
|
@objc(addItemsObject:)
|
||||||
|
@NSManaged public func addToItems(_ value: Item)
|
||||||
|
|
||||||
|
@objc(removeItemsObject:)
|
||||||
|
@NSManaged public func removeFromItems(_ value: Item)
|
||||||
|
|
||||||
|
@objc(addItems:)
|
||||||
|
@NSManaged public func addToItems(_ values: NSSet)
|
||||||
|
|
||||||
|
@objc(removeItems:)
|
||||||
|
@NSManaged public func removeFromItems(_ values: NSSet)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Feed : Identifiable {
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,24 @@
|
||||||
|
//
|
||||||
|
// Group+CoreDataClass.swift
|
||||||
|
// Reader
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 1/9/22.
|
||||||
|
//
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CoreData
|
||||||
|
import Fervor
|
||||||
|
|
||||||
|
@objc(Group)
|
||||||
|
public class Group: NSManagedObject {
|
||||||
|
|
||||||
|
func updateFromServer(_ serverGroup: Fervor.Group) {
|
||||||
|
guard self.id == nil || self.id == serverGroup.id else { return }
|
||||||
|
self.id = serverGroup.id
|
||||||
|
self.title = serverGroup.title
|
||||||
|
// feeds relationships will be updated after feeds are created in PersistentContainer.sync
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,44 @@
|
||||||
|
//
|
||||||
|
// Group+CoreDataProperties.swift
|
||||||
|
// Reader
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 1/9/22.
|
||||||
|
//
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
|
||||||
|
extension Group {
|
||||||
|
|
||||||
|
@nonobjc public class func fetchRequest() -> NSFetchRequest<Group> {
|
||||||
|
return NSFetchRequest<Group>(entityName: "Group")
|
||||||
|
}
|
||||||
|
|
||||||
|
@NSManaged public var id: String?
|
||||||
|
@NSManaged public var title: String
|
||||||
|
@NSManaged public var feeds: NSSet?
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Generated accessors for feeds
|
||||||
|
extension Group {
|
||||||
|
|
||||||
|
@objc(addFeedsObject:)
|
||||||
|
@NSManaged public func addToFeeds(_ value: Feed)
|
||||||
|
|
||||||
|
@objc(removeFeedsObject:)
|
||||||
|
@NSManaged public func removeFromFeeds(_ value: Feed)
|
||||||
|
|
||||||
|
@objc(addFeeds:)
|
||||||
|
@NSManaged public func addToFeeds(_ values: NSSet)
|
||||||
|
|
||||||
|
@objc(removeFeeds:)
|
||||||
|
@NSManaged public func removeFromFeeds(_ values: NSSet)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Group : Identifiable {
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
//
|
||||||
|
// Item+CoreDataClass.swift
|
||||||
|
// Reader
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 1/9/22.
|
||||||
|
//
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CoreData
|
||||||
|
import Fervor
|
||||||
|
|
||||||
|
@objc(Item)
|
||||||
|
public class Item: NSManagedObject {
|
||||||
|
|
||||||
|
func updateFromServer(_ serverItem: Fervor.Item) {
|
||||||
|
guard self.id == nil || self.id == serverItem.id else { return }
|
||||||
|
self.id = serverItem.id
|
||||||
|
self.author = serverItem.author
|
||||||
|
self.content = serverItem.content
|
||||||
|
self.title = serverItem.title
|
||||||
|
self.read = serverItem.read ?? false
|
||||||
|
self.published = serverItem.published
|
||||||
|
self.url = serverItem.url
|
||||||
|
if self.feed?.id != serverItem.feedID {
|
||||||
|
let feedReq = Feed.fetchRequest()
|
||||||
|
feedReq.predicate = NSPredicate(format: "id = %@", serverItem.feedID)
|
||||||
|
self.feed = try! self.managedObjectContext!.fetch(feedReq).first!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,35 @@
|
||||||
|
//
|
||||||
|
// Item+CoreDataProperties.swift
|
||||||
|
// Reader
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 1/9/22.
|
||||||
|
//
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
|
||||||
|
extension Item {
|
||||||
|
|
||||||
|
@nonobjc public class func fetchRequest() -> NSFetchRequest<Item> {
|
||||||
|
return NSFetchRequest<Item>(entityName: "Item")
|
||||||
|
}
|
||||||
|
|
||||||
|
@NSManaged public var author: String?
|
||||||
|
@NSManaged public var content: String?
|
||||||
|
@NSManaged public var excerpt: String?
|
||||||
|
@NSManaged public var generatedExcerpt: Bool
|
||||||
|
@NSManaged public var id: String?
|
||||||
|
@NSManaged public var needsReadStateSync: Bool
|
||||||
|
@NSManaged public var published: Date?
|
||||||
|
@NSManaged public var read: Bool
|
||||||
|
@NSManaged public var title: String?
|
||||||
|
@NSManaged public var url: URL?
|
||||||
|
@NSManaged public var feed: Feed?
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Item : Identifiable {
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,141 @@
|
||||||
|
//
|
||||||
|
// PersistentContainer.swift
|
||||||
|
// Reader
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 12/24/21.
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreData
|
||||||
|
import Fervor
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
class PersistentContainer: NSPersistentContainer {
|
||||||
|
|
||||||
|
private static let managedObjectModel: NSManagedObjectModel = {
|
||||||
|
let url = Bundle.main.url(forResource: "Reader", withExtension: "momd")!
|
||||||
|
return NSManagedObjectModel(contentsOf: url)!
|
||||||
|
}()
|
||||||
|
|
||||||
|
private(set) lazy var backgroundContext: NSManagedObjectContext = {
|
||||||
|
let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
|
||||||
|
// todo: should the background context really be parented to the view context, or should they both be direct children of the PSC?
|
||||||
|
context.parent = self.viewContext
|
||||||
|
return context
|
||||||
|
}()
|
||||||
|
|
||||||
|
private weak var fervorController: FervorController?
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "PersistentContainer")
|
||||||
|
|
||||||
|
init(account: LocalData.Account, fervorController: FervorController) {
|
||||||
|
self.fervorController = fervorController
|
||||||
|
|
||||||
|
super.init(name: "\(account.id)", managedObjectModel: PersistentContainer.managedObjectModel)
|
||||||
|
|
||||||
|
loadPersistentStores { description, error in
|
||||||
|
if let error = error {
|
||||||
|
fatalError("Unable to load persistent store: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func saveViewContext() async throws {
|
||||||
|
if viewContext.hasChanges {
|
||||||
|
try viewContext.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func lastSyncDate() async throws -> Date? {
|
||||||
|
return try await backgroundContext.perform {
|
||||||
|
let state = try self.backgroundContext.fetch(SyncState.fetchRequest()).first
|
||||||
|
return state?.lastSync
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateLastSyncDate(_ date: Date) async throws {
|
||||||
|
try await backgroundContext.perform {
|
||||||
|
if let state = try self.backgroundContext.fetch(SyncState.fetchRequest()).first {
|
||||||
|
state.lastSync = date
|
||||||
|
} else {
|
||||||
|
let state = SyncState(context: self.backgroundContext)
|
||||||
|
state.lastSync = date
|
||||||
|
}
|
||||||
|
|
||||||
|
try self.backgroundContext.save()
|
||||||
|
}
|
||||||
|
try await self.saveViewContext()
|
||||||
|
}
|
||||||
|
|
||||||
|
func sync(serverGroups: [Fervor.Group], serverFeeds: [Fervor.Feed]) async throws {
|
||||||
|
try await backgroundContext.perform {
|
||||||
|
let existingGroups = try self.backgroundContext.fetch(Group.fetchRequest())
|
||||||
|
for group in serverGroups {
|
||||||
|
if let existing = existingGroups.first(where: { $0.id == group.id }) {
|
||||||
|
existing.updateFromServer(group)
|
||||||
|
} else {
|
||||||
|
let mo = Group(context: self.backgroundContext)
|
||||||
|
mo.updateFromServer(group)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for removed in existingGroups where !serverGroups.contains(where: { $0.id == removed.id }) {
|
||||||
|
self.backgroundContext.delete(removed)
|
||||||
|
}
|
||||||
|
|
||||||
|
let existingFeeds = try self.backgroundContext.fetch(Feed.fetchRequest())
|
||||||
|
for feed in serverFeeds {
|
||||||
|
if let existing = existingFeeds.first(where: { $0.id == feed.id }) {
|
||||||
|
existing.updateFromServer(feed)
|
||||||
|
} else {
|
||||||
|
let mo = Feed(context: self.backgroundContext)
|
||||||
|
mo.updateFromServer(feed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for removed in existingFeeds where !serverFeeds.contains(where: { $0.id == removed.id }) {
|
||||||
|
self.backgroundContext.delete(removed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.backgroundContext.hasChanges {
|
||||||
|
try self.backgroundContext.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try await self.saveViewContext()
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncItems(_ syncUpdate: ItemsSyncUpdate) async throws {
|
||||||
|
try await backgroundContext.perform {
|
||||||
|
self.logger.debug("syncItems: deleting \(syncUpdate.delete.count, privacy: .public) items")
|
||||||
|
let deleteReq = Item.fetchRequest()
|
||||||
|
deleteReq.predicate = NSPredicate(format: "id in %@", syncUpdate.delete)
|
||||||
|
let delete = NSBatchDeleteRequest(fetchRequest: deleteReq as! NSFetchRequest<NSFetchRequestResult>)
|
||||||
|
delete.resultType = .resultTypeObjectIDs
|
||||||
|
let result = try self.backgroundContext.execute(delete)
|
||||||
|
|
||||||
|
if let deleteResult = result as? NSBatchDeleteResult,
|
||||||
|
let objectIDs = deleteResult.result as? [NSManagedObjectID] {
|
||||||
|
NSManagedObjectContext.mergeChanges(fromRemoteContextSave: [NSDeletedObjectsKey: objectIDs], into: [self.viewContext])
|
||||||
|
// todo: does the background/view contexts need to get saved then?
|
||||||
|
}
|
||||||
|
|
||||||
|
let req = Item.fetchRequest()
|
||||||
|
req.predicate = NSPredicate(format: "id in %@", syncUpdate.upsert.map(\.id))
|
||||||
|
let existing = try self.backgroundContext.fetch(req)
|
||||||
|
self.logger.debug("syncItems: updating \(existing.count, privacy: .public) items, inserting \(syncUpdate.upsert.count - existing.count, privacy: .public)")
|
||||||
|
// todo: this feels like it'll be slow when there are many items
|
||||||
|
for item in syncUpdate.upsert {
|
||||||
|
if let existing = existing.first(where: { $0.id == item.id }) {
|
||||||
|
existing.updateFromServer(item)
|
||||||
|
} else {
|
||||||
|
let mo = Item(context: self.backgroundContext)
|
||||||
|
mo.updateFromServer(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.backgroundContext.hasChanges {
|
||||||
|
try self.backgroundContext.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try await self.saveViewContext()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,38 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="19574" systemVersion="21C52" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||||
|
<entity name="Feed" representedClassName="Feed" syncable="YES">
|
||||||
|
<attribute name="id" attributeType="String"/>
|
||||||
|
<attribute name="lastUpdated" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="title" attributeType="String"/>
|
||||||
|
<attribute name="url" optional="YES" attributeType="URI"/>
|
||||||
|
<relationship name="groups" toMany="YES" deletionRule="Nullify" destinationEntity="Group" inverseName="feeds" inverseEntity="Group"/>
|
||||||
|
<relationship name="items" toMany="YES" deletionRule="Cascade" destinationEntity="Item" inverseName="feed" inverseEntity="Item"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="Group" representedClassName="Group" syncable="YES">
|
||||||
|
<attribute name="id" attributeType="String"/>
|
||||||
|
<attribute name="title" attributeType="String"/>
|
||||||
|
<relationship name="feeds" toMany="YES" deletionRule="Nullify" destinationEntity="Feed" inverseName="groups" inverseEntity="Feed"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="Item" representedClassName="Item" syncable="YES">
|
||||||
|
<attribute name="author" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="content" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="excerpt" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="generatedExcerpt" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="id" attributeType="String"/>
|
||||||
|
<attribute name="needsReadStateSync" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="published" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="read" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="title" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="url" optional="YES" attributeType="URI"/>
|
||||||
|
<relationship name="feed" maxCount="1" deletionRule="Nullify" destinationEntity="Feed" inverseName="items" inverseEntity="Feed"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="SyncState" representedClassName="SyncState" syncable="YES" codeGenerationType="class">
|
||||||
|
<attribute name="lastSync" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
</entity>
|
||||||
|
<elements>
|
||||||
|
<element name="Feed" positionX="-54" positionY="9" width="128" height="119"/>
|
||||||
|
<element name="Group" positionX="-63" positionY="-18" width="128" height="74"/>
|
||||||
|
<element name="Item" positionX="-45" positionY="63" width="128" height="194"/>
|
||||||
|
<element name="SyncState" positionX="-63" positionY="90" width="128" height="44"/>
|
||||||
|
</elements>
|
||||||
|
</model>
|
|
@ -0,0 +1,141 @@
|
||||||
|
//
|
||||||
|
// ExcerptGenerator.swift
|
||||||
|
// Reader
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 1/13/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
// public so that it can be imported in ReaderTests even when Reader is compiled in release mode (w/ testing disabled)
|
||||||
|
public struct ExcerptGenerator {
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "ExcerptGenerator")
|
||||||
|
|
||||||
|
static func generateAll(_ fervorController: FervorController) {
|
||||||
|
let req = Item.fetchRequest()
|
||||||
|
req.predicate = NSPredicate(format: "generatedExcerpt = NO")
|
||||||
|
req.sortDescriptors = [NSSortDescriptor(key: "published", ascending: false)]
|
||||||
|
req.fetchBatchSize = 50
|
||||||
|
fervorController.persistentContainer.performBackgroundTask { ctx in
|
||||||
|
guard let items = try? ctx.fetch(req) else { return }
|
||||||
|
var count = 0
|
||||||
|
for item in items {
|
||||||
|
if let excerpt = excerpt(for: item) {
|
||||||
|
item.excerpt = excerpt
|
||||||
|
count += 1
|
||||||
|
if count % 50 == 0 {
|
||||||
|
logger.debug("Generated \(count, privacy: .public) excerpts")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item.generatedExcerpt = true
|
||||||
|
}
|
||||||
|
logger.log("Generated excerpts for \(count, privacy: .public) items")
|
||||||
|
if ctx.hasChanges {
|
||||||
|
do {
|
||||||
|
// get the updated objects now, because this set is empty after .save is called
|
||||||
|
let updated = ctx.updatedObjects
|
||||||
|
try ctx.save()
|
||||||
|
|
||||||
|
// make sure the view context has the newly added excerpts
|
||||||
|
NSManagedObjectContext.mergeChanges(fromRemoteContextSave: [
|
||||||
|
NSUpdatedObjectsKey: Array(updated)
|
||||||
|
], into: [fervorController.persistentContainer.viewContext])
|
||||||
|
} catch {
|
||||||
|
logger.error("Unable to save context: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func excerpt(for item: Item) -> String? {
|
||||||
|
guard let content = item.content else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return excerpt(from: content)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func excerpt(from html: String) -> String? {
|
||||||
|
var html = html
|
||||||
|
|
||||||
|
let builder = lol_html_rewriter_builder_new()!
|
||||||
|
let pSelector = lol_html_selector_parse("p", 1)!
|
||||||
|
var userData = UserData()
|
||||||
|
withUnsafeMutablePointer(to: &userData) { userDataPtr in
|
||||||
|
let rawPtr = UnsafeMutableRawPointer(userDataPtr)
|
||||||
|
let res = lol_html_rewriter_builder_add_element_content_handlers(builder, pSelector, elementHandler, rawPtr, nil, nil, textHandler, rawPtr)
|
||||||
|
guard res == 0 else {
|
||||||
|
lolHtmlError()
|
||||||
|
}
|
||||||
|
let memSettings = lol_html_memory_settings_t(preallocated_parsing_buffer_size: 1024, max_allowed_memory_usage: .max)
|
||||||
|
let rewriter = lol_html_rewriter_build(builder, "utf-8", 5, memSettings, outputSink, nil, true)
|
||||||
|
lol_html_rewriter_builder_free(builder)
|
||||||
|
lol_html_selector_free(pSelector)
|
||||||
|
|
||||||
|
guard let rewriter = rewriter else {
|
||||||
|
lolHtmlError()
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = html.withUTF8 { buffer in
|
||||||
|
buffer.withMemoryRebound(to: CChar.self) { buffer in
|
||||||
|
lol_html_rewriter_write(rewriter, buffer.baseAddress!, buffer.count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if userData.isInParagraph {
|
||||||
|
return userData.paragraphText.htmlUnescape().trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
// todo: steal css whitespace collapsing from tusker
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func lolHtmlError() -> Never {
|
||||||
|
let lastError = lol_html_take_last_error()
|
||||||
|
let message = String(bytesNoCopy: UnsafeMutableRawPointer(mutating: lastError.data!), length: lastError.len, encoding: .utf8, freeWhenDone: false)
|
||||||
|
fatalError(message ?? "Unknown lol-html error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct UserData {
|
||||||
|
var isInParagraph = false
|
||||||
|
var paragraphText = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private func elementHandler(element: OpaquePointer!, userData: UnsafeMutableRawPointer!) -> lol_html_rewriter_directive_t {
|
||||||
|
let userDataPtr = userData.assumingMemoryBound(to: UserData.self)
|
||||||
|
if userDataPtr.pointee.isInParagraph {
|
||||||
|
return LOL_HTML_STOP
|
||||||
|
} else {
|
||||||
|
let s = lol_html_element_tag_name_get(element)
|
||||||
|
let tagName = String(bytesNoCopy: UnsafeMutableRawPointer(mutating: s.data), length: s.len, encoding: .utf8, freeWhenDone: false)!
|
||||||
|
userDataPtr.pointee.isInParagraph = tagName == "p" || tagName == "P"
|
||||||
|
lol_html_str_free(s)
|
||||||
|
return LOL_HTML_CONTINUE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func textHandler(chunk: OpaquePointer!, userData: UnsafeMutableRawPointer!) -> lol_html_rewriter_directive_t {
|
||||||
|
let userDataPtr = userData.assumingMemoryBound(to: UserData.self)
|
||||||
|
if userDataPtr.pointee.isInParagraph {
|
||||||
|
let s = lol_html_text_chunk_content_get(chunk)
|
||||||
|
let content = String(bytesNoCopy: UnsafeMutableRawPointer(mutating: s.data), length: s.len, encoding: .utf8, freeWhenDone: false)!
|
||||||
|
userDataPtr.pointee.paragraphText += content
|
||||||
|
if userDataPtr.pointee.paragraphText.underestimatedCount >= 1024 {
|
||||||
|
// lol-html seems to get confused by img tags with hundreds of kilobytes of data in their src attributes
|
||||||
|
// and returns that data as text even though it's a tag
|
||||||
|
// if the text is over 1024 characters so far, we assume that's what's happened
|
||||||
|
// and abandon this attempt and try again at the next paragraph
|
||||||
|
userDataPtr.pointee.paragraphText = ""
|
||||||
|
userDataPtr.pointee.isInParagraph = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return LOL_HTML_CONTINUE
|
||||||
|
}
|
||||||
|
|
||||||
|
private func outputSink(chunk: UnsafePointer<CChar>!, chunkLen: Int, userData: UnsafeMutableRawPointer!) {
|
||||||
|
// no-op
|
||||||
|
}
|
|
@ -7,6 +7,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Fervor
|
import Fervor
|
||||||
|
import OSLog
|
||||||
|
|
||||||
class FervorController {
|
class FervorController {
|
||||||
|
|
||||||
|
@ -14,11 +15,16 @@ class FervorController {
|
||||||
|
|
||||||
let instanceURL: URL
|
let instanceURL: URL
|
||||||
|
|
||||||
private let client: FervorClient
|
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "FervorController")
|
||||||
|
|
||||||
|
let client: FervorClient
|
||||||
|
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 accessToken: String?
|
private(set) var accessToken: String?
|
||||||
|
|
||||||
|
private(set) var persistentContainer: PersistentContainer!
|
||||||
|
|
||||||
init(instanceURL: URL) {
|
init(instanceURL: URL) {
|
||||||
self.instanceURL = instanceURL
|
self.instanceURL = instanceURL
|
||||||
self.client = FervorClient(instanceURL: instanceURL, accessToken: nil)
|
self.client = FervorClient(instanceURL: instanceURL, accessToken: nil)
|
||||||
|
@ -26,9 +32,14 @@ class FervorController {
|
||||||
|
|
||||||
convenience init(account: LocalData.Account) {
|
convenience init(account: LocalData.Account) {
|
||||||
self.init(instanceURL: account.instanceURL)
|
self.init(instanceURL: account.instanceURL)
|
||||||
|
self.account = account
|
||||||
self.clientID = account.clientID
|
self.clientID = account.clientID
|
||||||
self.clientSecret = account.clientSecret
|
self.clientSecret = account.clientSecret
|
||||||
self.accessToken = account.accessToken
|
self.accessToken = account.accessToken
|
||||||
|
|
||||||
|
self.client.accessToken = account.accessToken
|
||||||
|
|
||||||
|
self.persistentContainer = PersistentContainer(account: account, fervorController: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
func register() async throws -> ClientRegistration {
|
func register() async throws -> ClientRegistration {
|
||||||
|
@ -44,4 +55,80 @@ class FervorController {
|
||||||
accessToken = token.accessToken
|
accessToken = token.accessToken
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func syncAll() async throws {
|
||||||
|
logger.info("Syncing groups and feeds")
|
||||||
|
async let groups = try client.groups()
|
||||||
|
async let feeds = try client.feeds()
|
||||||
|
try await persistentContainer.sync(serverGroups: groups, serverFeeds: feeds)
|
||||||
|
|
||||||
|
let lastSync = try await persistentContainer.lastSyncDate()
|
||||||
|
logger.info("Syncing items with last sync date: \(String(describing: lastSync), privacy: .public)")
|
||||||
|
let update = try await client.syncItems(lastSync: lastSync)
|
||||||
|
try await persistentContainer.syncItems(update)
|
||||||
|
try await persistentContainer.updateLastSyncDate(update.syncTimestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func syncReadToServer() async throws {
|
||||||
|
var count = 0
|
||||||
|
// todo: there should be a batch update api endpoint
|
||||||
|
for case let item as Item in persistentContainer.viewContext.updatedObjects {
|
||||||
|
let f = item.read ? client.read(item:) : client.unread(item:)
|
||||||
|
do {
|
||||||
|
let _ = try await f(item.id!)
|
||||||
|
count += 1
|
||||||
|
} catch {
|
||||||
|
logger.error("Failed to sync read state: \(error.localizedDescription, privacy: .public)")
|
||||||
|
item.needsReadStateSync = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// try to sync items which failed last time
|
||||||
|
let req = Item.fetchRequest()
|
||||||
|
req.predicate = NSPredicate(format: "needsReadStateSync = YES")
|
||||||
|
if let needsSync = try? persistentContainer.viewContext.fetch(req) {
|
||||||
|
for item in needsSync {
|
||||||
|
let f = item.read ? client.read(item:) : client.unread(item:)
|
||||||
|
do {
|
||||||
|
let _ = try await f(item.id!)
|
||||||
|
count += 1
|
||||||
|
item.needsReadStateSync = false
|
||||||
|
} catch {
|
||||||
|
logger.error("Failed to sync read state again: \(error.localizedDescription, privacy: .public)")
|
||||||
|
item.needsReadStateSync = true
|
||||||
|
// todo: this should probably fail after a certain number of attempts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Synced \(count, privacy: .public) read/unread to server")
|
||||||
|
|
||||||
|
do {
|
||||||
|
try persistentContainer.viewContext.save()
|
||||||
|
} catch {
|
||||||
|
logger.error("Failed to save view context: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func markItem(_ item: Item, read: Bool) async {
|
||||||
|
item.read = read
|
||||||
|
do {
|
||||||
|
let f = item.read ? client.read(item:) : client.unread(item:)
|
||||||
|
_ = try await f(item.id!)
|
||||||
|
item.needsReadStateSync = false
|
||||||
|
} catch {
|
||||||
|
logger.error("Failed to mark item (un)read: \(error.localizedDescription, privacy: .public)")
|
||||||
|
item.needsReadStateSync = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if persistentContainer.viewContext.hasChanges {
|
||||||
|
do {
|
||||||
|
try persistentContainer.viewContext.save()
|
||||||
|
} catch {
|
||||||
|
logger.error("Failed to save view context: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,20 +2,32 @@
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>NSUserActivityTypes</key>
|
||||||
|
<array>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.preferences</string>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.add-account</string>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.activate-account</string>
|
||||||
|
</array>
|
||||||
<key>UIApplicationSceneManifest</key>
|
<key>UIApplicationSceneManifest</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIApplicationSupportsMultipleScenes</key>
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
<false/>
|
<true/>
|
||||||
<key>UISceneConfigurations</key>
|
<key>UISceneConfigurations</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIWindowSceneSessionRoleApplication</key>
|
<key>UIWindowSceneSessionRoleApplication</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UISceneConfigurationName</key>
|
<key>UISceneConfigurationName</key>
|
||||||
<string>Default Configuration</string>
|
<string>main</string>
|
||||||
<key>UISceneDelegateClassName</key>
|
<key>UISceneDelegateClassName</key>
|
||||||
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
||||||
</dict>
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>UISceneDelegateClassName</key>
|
||||||
|
<string>$(PRODUCT_MODULE_NAME).PrefsSceneDelegate</string>
|
||||||
|
<key>UISceneConfigurationName</key>
|
||||||
|
<string>prefs</string>
|
||||||
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
|
|
|
@ -14,25 +14,54 @@ struct LocalData {
|
||||||
private static let encoder = JSONEncoder()
|
private static let encoder = JSONEncoder()
|
||||||
private static let decoder = JSONDecoder()
|
private static let decoder = JSONDecoder()
|
||||||
|
|
||||||
static var account: Account? {
|
static var accounts: [Account] {
|
||||||
get {
|
get {
|
||||||
guard let data = UserDefaults.standard.data(forKey: "account") else {
|
guard let data = UserDefaults.standard.data(forKey: "accounts"),
|
||||||
return nil
|
let accounts = try? decoder.decode([Account].self, from: data) else {
|
||||||
|
return []
|
||||||
}
|
}
|
||||||
return try? decoder.decode(Account.self, from: data)
|
return accounts
|
||||||
}
|
}
|
||||||
set {
|
set {
|
||||||
let data = try! encoder.encode(newValue)
|
let data = try! encoder.encode(newValue)
|
||||||
UserDefaults.standard.set(data, forKey: "account")
|
UserDefaults.standard.set(data, forKey: "accounts")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static var mostRecentAccountID: UUID? {
|
||||||
|
get {
|
||||||
|
guard let str = UserDefaults.standard.string(forKey: "mostRecentAccountID") else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return UUID(uuidString: str)
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
UserDefaults.standard.set(newValue?.uuidString, forKey: "mostRecentAccountID")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func mostRecentAccount() -> Account? {
|
||||||
|
guard let id = mostRecentAccountID else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return accounts.first(where: { $0.id == id })
|
||||||
|
}
|
||||||
|
|
||||||
struct Account: Codable {
|
struct Account: Codable {
|
||||||
|
let id: UUID
|
||||||
let instanceURL: URL
|
let instanceURL: URL
|
||||||
let clientID: String
|
let clientID: String
|
||||||
let clientSecret: String
|
let clientSecret: String
|
||||||
let accessToken: String
|
let accessToken: String
|
||||||
// todo: refresh tokens
|
// todo: refresh tokens
|
||||||
|
|
||||||
|
init(instanceURL: URL, clientID: String, clientSecret: String, accessToken: String) {
|
||||||
|
self.id = UUID()
|
||||||
|
self.instanceURL = instanceURL
|
||||||
|
self.clientID = clientID
|
||||||
|
self.clientSecret = clientSecret
|
||||||
|
self.accessToken = accessToken
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,69 @@
|
||||||
|
//
|
||||||
|
// Preferences.swift
|
||||||
|
// Reader
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 1/16/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
class Preferences: Codable, ObservableObject {
|
||||||
|
|
||||||
|
private static let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
|
||||||
|
private static let archiveURL = Preferences.documentsDirectory.appendingPathComponent("preferences").appendingPathExtension("plist")
|
||||||
|
private static let decoder = PropertyListDecoder()
|
||||||
|
private static let encoder = PropertyListEncoder()
|
||||||
|
|
||||||
|
static var shared = load()
|
||||||
|
|
||||||
|
private static func load() -> Preferences {
|
||||||
|
if let data = try? Data(contentsOf: archiveURL),
|
||||||
|
let prefs = try? decoder.decode(Preferences.self, from: data) {
|
||||||
|
return prefs
|
||||||
|
} else {
|
||||||
|
return Preferences()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func save() {
|
||||||
|
if let data = try? encoder.encode(shared) {
|
||||||
|
try? data.write(to: archiveURL, options: .noFileProtection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
}
|
||||||
|
|
||||||
|
required init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
self.appearance = try container.decode(Appearance.self, forKey: .appearance)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(appearance, forKey: .appearance)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var appearance = Appearance.unspecified {
|
||||||
|
didSet {
|
||||||
|
NotificationCenter.default.post(name: .appearanceChanged, object: nil, userInfo: [
|
||||||
|
"appearance": appearance.rawValue
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case appearance
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Appearance: Int, Codable {
|
||||||
|
case unspecified = 0
|
||||||
|
case light = 1
|
||||||
|
case dark = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Notification.Name {
|
||||||
|
static let appearanceChanged = Notification.Name("appearanceChanged")
|
||||||
|
}
|
|
@ -0,0 +1,68 @@
|
||||||
|
//
|
||||||
|
// PrefsSceneDelegate.swift
|
||||||
|
// Reader
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 1/16/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
import UIKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
class PrefsSceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||||
|
|
||||||
|
var window: UIWindow?
|
||||||
|
|
||||||
|
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
|
||||||
|
guard let windowScene = scene as? UIWindowScene else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
window = UIWindow(windowScene: windowScene)
|
||||||
|
window!.tintColor = .appTintColor
|
||||||
|
windowScene.sizeRestrictions?.minimumSize = CGSize(width: 640, height: 480)
|
||||||
|
windowScene.sizeRestrictions?.maximumSize = CGSize(width: 640, height: 480)
|
||||||
|
|
||||||
|
window!.rootViewController = UIHostingController(rootView: PrefsView())
|
||||||
|
|
||||||
|
if let titlebar = windowScene.titlebar {
|
||||||
|
titlebar.toolbarStyle = .preference
|
||||||
|
titlebar.toolbar = NSToolbar(identifier: .init("ReaderPrefsToolbar"))
|
||||||
|
titlebar.toolbar!.delegate = self
|
||||||
|
titlebar.toolbar!.allowsUserCustomization = false
|
||||||
|
}
|
||||||
|
|
||||||
|
window!.makeKeyAndVisible()
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(updateAppearance), name: .appearanceChanged, object: nil)
|
||||||
|
updateAppearance()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func updateAppearance() {
|
||||||
|
switch Preferences.shared.appearance {
|
||||||
|
case .unspecified:
|
||||||
|
window!.overrideUserInterfaceStyle = .unspecified
|
||||||
|
case .light:
|
||||||
|
window!.overrideUserInterfaceStyle = .light
|
||||||
|
case .dark:
|
||||||
|
window!.overrideUserInterfaceStyle = .dark
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PrefsSceneDelegate: NSToolbarDelegate {
|
||||||
|
func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
|
@ -0,0 +1,13 @@
|
||||||
|
//
|
||||||
|
// Reader-Bridging-Header.h
|
||||||
|
// Reader
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 1/12/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
#ifndef Reader_Bridging_Header_h
|
||||||
|
#define Reader_Bridging_Header_h
|
||||||
|
|
||||||
|
#import "lol_html.h"
|
||||||
|
|
||||||
|
#endif /* Reader_Bridging_Header_h */
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.app-sandbox</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.network.client</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
|
@ -1,4 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
||||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="1" systemVersion="11A491" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="false" userDefinedModelVersionIdentifier="">
|
|
||||||
<elements/>
|
|
||||||
</model>
|
|
|
@ -6,12 +6,16 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import OSLog
|
||||||
|
|
||||||
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||||
|
|
||||||
var window: UIWindow?
|
var window: UIWindow?
|
||||||
|
|
||||||
private(set) var fervorController: FervorController!
|
private(set) var fervorController: FervorController!
|
||||||
|
private(set) var toggleReadBarButtonItem: UIBarButtonItem?
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "SceneDelegate")
|
||||||
|
|
||||||
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
|
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
|
||||||
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
|
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
|
||||||
|
@ -20,8 +24,18 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||||
guard let windowScene = (scene as? UIWindowScene) else { return }
|
guard let windowScene = (scene as? UIWindowScene) else { return }
|
||||||
|
|
||||||
window = UIWindow(windowScene: windowScene)
|
window = UIWindow(windowScene: windowScene)
|
||||||
|
window!.tintColor = .appTintColor
|
||||||
|
|
||||||
if let account = LocalData.account {
|
let activity = connectionOptions.userActivities.first
|
||||||
|
if activity?.activityType == NSUserActivity.addAccountType {
|
||||||
|
let loginVC = LoginViewController()
|
||||||
|
loginVC.delegate = self
|
||||||
|
window!.rootViewController = loginVC
|
||||||
|
} else if activity?.activityType == NSUserActivity.activateAccountType,
|
||||||
|
let account = LocalData.accounts.first(where: { $0.id.uuidString == activity!.userInfo?["accountID"] as? String }) {
|
||||||
|
fervorController = FervorController(account: account)
|
||||||
|
createAppUI()
|
||||||
|
} else if let account = LocalData.mostRecentAccount() {
|
||||||
fervorController = FervorController(account: account)
|
fervorController = FervorController(account: account)
|
||||||
createAppUI()
|
createAppUI()
|
||||||
} else {
|
} else {
|
||||||
|
@ -30,7 +44,19 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||||
window!.rootViewController = loginVC
|
window!.rootViewController = loginVC
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
if let titlebar = windowScene.titlebar {
|
||||||
|
titlebar.toolbarStyle = .unifiedCompact
|
||||||
|
titlebar.toolbar = NSToolbar(identifier: .init("ReaderToolbar"))
|
||||||
|
titlebar.toolbar!.delegate = self
|
||||||
|
titlebar.toolbar!.allowsUserCustomization = false
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
window!.makeKeyAndVisible()
|
window!.makeKeyAndVisible()
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(updateAppearance), name: .appearanceChanged, object: nil)
|
||||||
|
updateAppearance()
|
||||||
}
|
}
|
||||||
|
|
||||||
func sceneDidDisconnect(_ scene: UIScene) {
|
func sceneDidDisconnect(_ scene: UIScene) {
|
||||||
|
@ -43,11 +69,25 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||||
func sceneDidBecomeActive(_ scene: UIScene) {
|
func sceneDidBecomeActive(_ scene: UIScene) {
|
||||||
// Called when the scene has moved from an inactive state to an active state.
|
// Called when the scene has moved from an inactive state to an active state.
|
||||||
// Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive.
|
// Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive.
|
||||||
|
|
||||||
|
UIMenuSystem.main.setNeedsRebuild()
|
||||||
|
|
||||||
|
syncFromServer()
|
||||||
}
|
}
|
||||||
|
|
||||||
func sceneWillResignActive(_ scene: UIScene) {
|
func sceneWillResignActive(_ scene: UIScene) {
|
||||||
// Called when the scene will move from an active state to an inactive state.
|
// Called when the scene will move from an active state to an inactive state.
|
||||||
// This may occur due to temporary interruptions (ex. an incoming phone call).
|
// This may occur due to temporary interruptions (ex. an incoming phone call).
|
||||||
|
|
||||||
|
if let fervorController = fervorController {
|
||||||
|
Task(priority: .userInitiated) {
|
||||||
|
do {
|
||||||
|
try await fervorController.syncReadToServer()
|
||||||
|
} catch {
|
||||||
|
logger.error("Unable to sync read state to server: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sceneWillEnterForeground(_ scene: UIScene) {
|
func sceneWillEnterForeground(_ scene: UIScene) {
|
||||||
|
@ -59,23 +99,91 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||||
// Called as the scene transitions from the foreground to the background.
|
// Called as the scene transitions from the foreground to the background.
|
||||||
// Use this method to save data, release shared resources, and store enough scene-specific state information
|
// Use this method to save data, release shared resources, and store enough scene-specific state information
|
||||||
// to restore the scene back to its current state.
|
// to restore the scene back to its current state.
|
||||||
|
|
||||||
// Save changes in the application's managed object context when the application transitions to the background.
|
|
||||||
(UIApplication.shared.delegate as? AppDelegate)?.saveContext()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func createAppUI() {
|
private func createAppUI() {
|
||||||
let home = HomeViewController(fervorController: fervorController)
|
window!.rootViewController = AppSplitViewController(fervorController: fervorController)
|
||||||
let nav = UINavigationController(rootViewController: home)
|
}
|
||||||
window!.rootViewController = nav
|
|
||||||
|
private func syncFromServer() {
|
||||||
|
guard let fervorController = fervorController else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Task(priority: .userInitiated) {
|
||||||
|
do {
|
||||||
|
try await self.fervorController.syncAll()
|
||||||
|
} catch {
|
||||||
|
logger.error("Unable to sync from server: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
|
||||||
|
ExcerptGenerator.generateAll(fervorController)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func updateAppearance() {
|
||||||
|
switch Preferences.shared.appearance {
|
||||||
|
case .unspecified:
|
||||||
|
window!.overrideUserInterfaceStyle = .unspecified
|
||||||
|
case .light:
|
||||||
|
window!.overrideUserInterfaceStyle = .light
|
||||||
|
case .dark:
|
||||||
|
window!.overrideUserInterfaceStyle = .dark
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension SceneDelegate: LoginViewControllerDelegate {
|
extension SceneDelegate: LoginViewControllerDelegate {
|
||||||
func didLogin(with controller: FervorController) {
|
func didLogin(with controller: FervorController) {
|
||||||
LocalData.account = .init(instanceURL: controller.instanceURL, clientID: controller.clientID!, clientSecret: controller.clientSecret!, accessToken: controller.accessToken!)
|
let account = LocalData.Account(instanceURL: controller.instanceURL, clientID: controller.clientID!, clientSecret: controller.clientSecret!, accessToken: controller.accessToken!)
|
||||||
fervorController = controller
|
LocalData.accounts.append(account)
|
||||||
|
LocalData.mostRecentAccountID = account.id
|
||||||
|
fervorController = FervorController(account: account)
|
||||||
|
|
||||||
createAppUI()
|
createAppUI()
|
||||||
|
syncFromServer()
|
||||||
|
|
||||||
|
UIMenuSystem.main.setNeedsRebuild()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension SceneDelegate: HomeViewControllerDelegate {
|
||||||
|
func switchToAccount(_ account: LocalData.Account) {
|
||||||
|
LocalData.mostRecentAccountID = account.id
|
||||||
|
fervorController = FervorController(account: account)
|
||||||
|
createAppUI()
|
||||||
|
syncFromServer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
extension NSToolbarItem.Identifier {
|
||||||
|
static let toggleItemRead = NSToolbarItem.Identifier("ToggleItemRead")
|
||||||
|
static let shareItem = NSToolbarItem.Identifier("ShareItem")
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SceneDelegate: NSToolbarDelegate {
|
||||||
|
func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? {
|
||||||
|
if itemIdentifier == .toggleItemRead {
|
||||||
|
// need an item bar button item to make the size of the image match the share button
|
||||||
|
let item = NSToolbarItem(itemIdentifier: .toggleItemRead, barButtonItem: UIBarButtonItem(image: nil, style: .plain, target: nil, action: nil))
|
||||||
|
item.image = UIImage(systemName: "checkmark.circle")
|
||||||
|
item.target = nil
|
||||||
|
item.action = #selector(ReadViewController.toggleItemRead(_:))
|
||||||
|
return item
|
||||||
|
} else if itemIdentifier == .shareItem {
|
||||||
|
return NSSharingServicePickerToolbarItem(itemIdentifier: .shareItem)
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
|
||||||
|
return [.shareItem, .toggleItemRead]
|
||||||
|
}
|
||||||
|
|
||||||
|
func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
|
||||||
|
return [.shareItem, .toggleItemRead]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
|
@ -0,0 +1,129 @@
|
||||||
|
//
|
||||||
|
// AppNavigationController.swift
|
||||||
|
// Reader
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 1/10/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
class AppNavigationController: UINavigationController, UINavigationControllerDelegate {
|
||||||
|
|
||||||
|
private var statusBarBlockingView: UIView!
|
||||||
|
|
||||||
|
static let panRecognizerName = "AppNavPanRecognizer"
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
let appearance = UINavigationBarAppearance()
|
||||||
|
appearance.configureWithOpaqueBackground()
|
||||||
|
navigationBar.scrollEdgeAppearance = appearance
|
||||||
|
|
||||||
|
interactivePopGestureRecognizer?.isEnabled = false
|
||||||
|
let recognizer = UIPanGestureRecognizer(target: self, action: #selector(panGestureRecognized))
|
||||||
|
recognizer.allowedScrollTypesMask = .continuous
|
||||||
|
recognizer.name = AppNavigationController.panRecognizerName
|
||||||
|
view.addGestureRecognizer(recognizer)
|
||||||
|
|
||||||
|
isNavigationBarHidden = true
|
||||||
|
|
||||||
|
statusBarBlockingView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
|
||||||
|
statusBarBlockingView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
statusBarBlockingView.layer.zPosition = 101
|
||||||
|
view.addSubview(statusBarBlockingView)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
statusBarBlockingView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
statusBarBlockingView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
statusBarBlockingView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
statusBarBlockingView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||||||
|
])
|
||||||
|
|
||||||
|
delegate = self
|
||||||
|
}
|
||||||
|
|
||||||
|
func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
|
||||||
|
statusBarBlockingView.isHidden = viewController.prefersStatusBarHidden
|
||||||
|
}
|
||||||
|
|
||||||
|
private var poppingViewController: UIViewController?
|
||||||
|
private var prevNavBarHidden = false
|
||||||
|
private var dimmingView: UIView = {
|
||||||
|
let v = UIView()
|
||||||
|
v.backgroundColor = .black
|
||||||
|
return v
|
||||||
|
}()
|
||||||
|
|
||||||
|
@objc private func panGestureRecognized(_ recognizer: UIPanGestureRecognizer) {
|
||||||
|
let translation = recognizer.translation(in: view)
|
||||||
|
let translationProgress = max(0, translation.x) / view.bounds.width
|
||||||
|
|
||||||
|
switch recognizer.state {
|
||||||
|
case .began:
|
||||||
|
guard viewControllers.count > 1 else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
prevNavBarHidden = isNavigationBarHidden
|
||||||
|
poppingViewController = popViewController(animated: false)
|
||||||
|
view.addSubview(poppingViewController!.view)
|
||||||
|
poppingViewController!.view.transform = CGAffineTransform(translationX: max(0, translation.x), y: 0)
|
||||||
|
poppingViewController!.view.layer.zPosition = 100
|
||||||
|
dimmingView.frame = view.bounds
|
||||||
|
dimmingView.layer.opacity = Float(1 - translationProgress) * 0.075
|
||||||
|
dimmingView.layer.zPosition = 99
|
||||||
|
view.addSubview(dimmingView)
|
||||||
|
// changing the transform directly on topViewController.view doesn't work for some reason, have to go 2 superviews up
|
||||||
|
topViewController!.view.superview?.superview?.transform = CGAffineTransform(translationX: (1 - translationProgress) * -0.3 * view.bounds.width, y: 0)
|
||||||
|
|
||||||
|
case .changed:
|
||||||
|
guard let poppingViewController = poppingViewController else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
poppingViewController.view.transform = CGAffineTransform(translationX: max(0, translation.x), y: 0)
|
||||||
|
dimmingView.layer.opacity = Float(1 - max(0, translation.x) / view.bounds.width) * 0.075
|
||||||
|
topViewController!.view.superview?.superview?.transform = CGAffineTransform(translationX: (1 - translationProgress) * -0.3 * view.bounds.width, y: 0)
|
||||||
|
|
||||||
|
case .ended:
|
||||||
|
guard let poppingViewController = poppingViewController else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
let velocity = recognizer.velocity(in: view)
|
||||||
|
let shouldComplete = translation.x >= view.bounds.width / 2 || velocity.x >= 500
|
||||||
|
|
||||||
|
UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseInOut) {
|
||||||
|
if shouldComplete {
|
||||||
|
poppingViewController.view.transform = CGAffineTransform(translationX: self.view.bounds.width, y: 0)
|
||||||
|
self.topViewController!.view.superview?.superview?.transform = .identity
|
||||||
|
} else {
|
||||||
|
poppingViewController.view.transform = .identity
|
||||||
|
self.topViewController!.view.superview?.superview?.transform = CGAffineTransform(translationX: -0.3 * self.view.bounds.width, y: 0)
|
||||||
|
}
|
||||||
|
self.dimmingView.layer.opacity = 0
|
||||||
|
} completion: { _ in
|
||||||
|
self.topViewController!.view.superview?.superview?.transform = .identity
|
||||||
|
|
||||||
|
if shouldComplete {
|
||||||
|
poppingViewController.beginAppearanceTransition(false, animated: true)
|
||||||
|
poppingViewController.willMove(toParent: nil)
|
||||||
|
poppingViewController.removeFromParent()
|
||||||
|
poppingViewController.view.removeFromSuperview()
|
||||||
|
poppingViewController.endAppearanceTransition()
|
||||||
|
} else {
|
||||||
|
self.pushViewController(poppingViewController, animated: false)
|
||||||
|
self.isNavigationBarHidden = self.prevNavBarHidden
|
||||||
|
}
|
||||||
|
|
||||||
|
poppingViewController.view.layer.zPosition = 0
|
||||||
|
self.poppingViewController = nil
|
||||||
|
self.dimmingView.removeFromSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,63 @@
|
||||||
|
//
|
||||||
|
// AppSplitViewController.swift
|
||||||
|
// Reader
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 1/14/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
class AppSplitViewController: UISplitViewController {
|
||||||
|
|
||||||
|
private let fervorController: FervorController
|
||||||
|
|
||||||
|
private var secondaryNav: UINavigationController!
|
||||||
|
|
||||||
|
init(fervorController: FervorController) {
|
||||||
|
self.fervorController = fervorController
|
||||||
|
|
||||||
|
super.init(style: .doubleColumn)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
preferredDisplayMode = .oneBesideSecondary
|
||||||
|
preferredSplitBehavior = .tile
|
||||||
|
presentsWithGesture = true
|
||||||
|
showsSecondaryOnlyButton = true
|
||||||
|
primaryBackgroundStyle = .sidebar
|
||||||
|
maximumPrimaryColumnWidth = 500
|
||||||
|
|
||||||
|
let sidebarHome = HomeViewController(fervorController: fervorController)
|
||||||
|
sidebarHome.enableStretchyMenu = false
|
||||||
|
sidebarHome.itemsDelegate = self
|
||||||
|
let sidebarNav = UINavigationController(rootViewController: sidebarHome)
|
||||||
|
sidebarNav.navigationBar.prefersLargeTitles = true
|
||||||
|
setViewController(sidebarNav, for: .primary)
|
||||||
|
|
||||||
|
secondaryNav = UINavigationController()
|
||||||
|
secondaryNav.isNavigationBarHidden = true
|
||||||
|
secondaryNav.view.backgroundColor = .appBackground
|
||||||
|
setViewController(secondaryNav, for: .secondary)
|
||||||
|
|
||||||
|
let home = HomeViewController(fervorController: fervorController)
|
||||||
|
let nav = AppNavigationController(rootViewController: home)
|
||||||
|
setViewController(nav, for: .compact)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AppSplitViewController: ItemsViewControllerDelegate {
|
||||||
|
func showReadItem(_ item: Item) {
|
||||||
|
secondaryNav.setViewControllers([ReadViewController(item: item, fervorController: fervorController)], animated: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,24 @@
|
||||||
|
//
|
||||||
|
// HomeCollectionViewCell.swift
|
||||||
|
// Reader
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 1/9/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
class HomeCollectionViewCell: UICollectionViewListCell {
|
||||||
|
|
||||||
|
#if !targetEnvironment(macCatalyst)
|
||||||
|
override func updateConfiguration(using state: UICellConfigurationState) {
|
||||||
|
var backgroundConfig = UIBackgroundConfiguration.listGroupedCell().updated(for: state)
|
||||||
|
if state.isHighlighted || state.isSelected {
|
||||||
|
backgroundConfig.backgroundColor = .appCellHighlightBackground
|
||||||
|
} else {
|
||||||
|
backgroundConfig.backgroundColor = .appBackground
|
||||||
|
}
|
||||||
|
self.backgroundConfiguration = backgroundConfig
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
}
|
|
@ -6,11 +6,26 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
protocol HomeViewControllerDelegate: AnyObject {
|
||||||
|
func switchToAccount(_ account: LocalData.Account)
|
||||||
|
}
|
||||||
|
|
||||||
class HomeViewController: UIViewController {
|
class HomeViewController: UIViewController {
|
||||||
|
|
||||||
|
weak var delegate: HomeViewControllerDelegate?
|
||||||
|
weak var itemsDelegate: ItemsViewControllerDelegate?
|
||||||
|
|
||||||
let fervorController: FervorController
|
let fervorController: FervorController
|
||||||
|
|
||||||
|
var enableStretchyMenu = true
|
||||||
|
|
||||||
|
private var collectionView: UICollectionView!
|
||||||
|
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||||
|
private var groupResultsController: NSFetchedResultsController<Group>!
|
||||||
|
private var feedResultsController: NSFetchedResultsController<Feed>!
|
||||||
|
|
||||||
init(fervorController: FervorController) {
|
init(fervorController: FervorController) {
|
||||||
self.fervorController = fervorController
|
self.fervorController = fervorController
|
||||||
|
|
||||||
|
@ -24,16 +39,271 @@ class HomeViewController: UIViewController {
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
|
||||||
view.backgroundColor = .systemBackground
|
// todo: account info
|
||||||
|
title = "Reader"
|
||||||
|
|
||||||
let label = UILabel()
|
if enableStretchyMenu {
|
||||||
label.translatesAutoresizingMaskIntoConstraints = false
|
view.addInteraction(StretchyMenuInteraction(delegate: self))
|
||||||
label.text = "Logged in to \(fervorController.instanceURL.host!)"
|
}
|
||||||
view.addSubview(label)
|
|
||||||
NSLayoutConstraint.activate([
|
if UIDevice.current.userInterfaceIdiom != .mac {
|
||||||
label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
view.backgroundColor = .appBackground
|
||||||
label.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
}
|
||||||
])
|
|
||||||
|
var config = UICollectionLayoutListConfiguration(appearance: UIDevice.current.userInterfaceIdiom == .mac ? .sidebar : .grouped)
|
||||||
|
config.headerMode = .supplementary
|
||||||
|
config.backgroundColor = .clear
|
||||||
|
config.separatorConfiguration.topSeparatorVisibility = .visible
|
||||||
|
config.separatorConfiguration.topSeparatorInsets = NSDirectionalEdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 0)
|
||||||
|
config.separatorConfiguration.bottomSeparatorVisibility = .hidden
|
||||||
|
config.itemSeparatorHandler = { indexPath, defaultConfig in
|
||||||
|
var config = defaultConfig
|
||||||
|
if indexPath.section == 0 && indexPath.row == 0 {
|
||||||
|
config.topSeparatorVisibility = .hidden
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
let layout = UICollectionViewCompositionalLayout.list(using: config)
|
||||||
|
collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
|
||||||
|
collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||||
|
collectionView.delegate = self
|
||||||
|
view.addSubview(collectionView)
|
||||||
|
|
||||||
|
dataSource = createDataSource()
|
||||||
|
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||||
|
snapshot.appendSections([.all, .groups, .feeds])
|
||||||
|
snapshot.appendItems([.unread, .all], toSection: .all)
|
||||||
|
dataSource.apply(snapshot, animatingDifferences: false)
|
||||||
|
|
||||||
|
let groupReq = Group.fetchRequest()
|
||||||
|
groupReq.sortDescriptors = [NSSortDescriptor(key: "title", ascending: true)]
|
||||||
|
groupResultsController = NSFetchedResultsController(fetchRequest: groupReq, managedObjectContext: fervorController.persistentContainer.viewContext, sectionNameKeyPath: nil, cacheName: nil)
|
||||||
|
groupResultsController.delegate = self
|
||||||
|
try! groupResultsController.performFetch()
|
||||||
|
|
||||||
|
let feedReq = Feed.fetchRequest()
|
||||||
|
feedReq.sortDescriptors = [NSSortDescriptor(key: "title", ascending: true)]
|
||||||
|
feedResultsController = NSFetchedResultsController(fetchRequest: feedReq, managedObjectContext: fervorController.persistentContainer.viewContext, sectionNameKeyPath: nil, cacheName: nil)
|
||||||
|
feedResultsController.delegate = self
|
||||||
|
try! feedResultsController.performFetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
|
||||||
|
if let indexPaths = collectionView.indexPathsForSelectedItems {
|
||||||
|
for indexPath in indexPaths {
|
||||||
|
collectionView.deselectItem(at: indexPath, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var snapshot = dataSource.snapshot()
|
||||||
|
// reconfigure so that unread counts update
|
||||||
|
snapshot.reconfigureItems(snapshot.itemIdentifiers)
|
||||||
|
dataSource.apply(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
|
||||||
|
let sectionHeaderCell = UICollectionView.SupplementaryRegistration<UICollectionViewListCell>(elementKind: UICollectionView.elementKindSectionHeader) { supplementaryView, elementKind, indexPath in
|
||||||
|
let section = self.dataSource.sectionIdentifier(for: indexPath.section)!
|
||||||
|
var config = supplementaryView.defaultContentConfiguration()
|
||||||
|
config.text = section.title
|
||||||
|
supplementaryView.contentConfiguration = config
|
||||||
|
}
|
||||||
|
let listCell = UICollectionView.CellRegistration<HomeCollectionViewCell, Item> { cell, indexPath, item in
|
||||||
|
var config = UIListContentConfiguration.valueCell()
|
||||||
|
config.text = item.title
|
||||||
|
if let req = item.countFetchRequest,
|
||||||
|
let count = try? self.fervorController.persistentContainer.viewContext.count(for: req) {
|
||||||
|
config.secondaryText = "\(count)"
|
||||||
|
config.secondaryTextProperties.color = .tintColor
|
||||||
|
}
|
||||||
|
cell.contentConfiguration = config
|
||||||
|
|
||||||
|
cell.accessories = [.disclosureIndicator()]
|
||||||
|
}
|
||||||
|
let dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { collectionView, indexPath, item in
|
||||||
|
return collectionView.dequeueConfiguredReusableCell(using: listCell, for: indexPath, item: item)
|
||||||
|
}
|
||||||
|
dataSource.supplementaryViewProvider = { collectionView, elementKind, indexPath in
|
||||||
|
if elementKind == UICollectionView.elementKindSectionHeader {
|
||||||
|
return collectionView.dequeueConfiguredReusableSupplementary(using: sectionHeaderCell, for: indexPath)
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dataSource
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension HomeViewController {
|
||||||
|
enum Section: Hashable {
|
||||||
|
case all
|
||||||
|
case groups
|
||||||
|
case feeds
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .all:
|
||||||
|
return ""
|
||||||
|
case .groups:
|
||||||
|
return "Groups"
|
||||||
|
case .feeds:
|
||||||
|
return "Feeds"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
enum Item: Hashable {
|
||||||
|
case unread
|
||||||
|
case all
|
||||||
|
case group(Group)
|
||||||
|
case feed(Feed)
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .unread:
|
||||||
|
return "Unread Articles"
|
||||||
|
case .all:
|
||||||
|
return "All Articles"
|
||||||
|
case let .group(group):
|
||||||
|
return group.title
|
||||||
|
case let .feed(feed):
|
||||||
|
return feed.title!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
||||||
|
let req = NSFetchRequest<NSManagedObjectID>(entityName: "Item")
|
||||||
|
req.resultType = .managedObjectIDResultType
|
||||||
|
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 countFetchRequest: NSFetchRequest<Reader.Item>? {
|
||||||
|
let req = Reader.Item.fetchRequest()
|
||||||
|
switch self {
|
||||||
|
case .unread:
|
||||||
|
req.predicate = NSPredicate(format: "read = NO")
|
||||||
|
case .all:
|
||||||
|
return nil
|
||||||
|
case .group(let group):
|
||||||
|
req.predicate = NSPredicate(format: "read = NO AND feed in %@", group.feeds!)
|
||||||
|
case .feed(let feed):
|
||||||
|
req.predicate = NSPredicate(format: "read = NO AND feed = %@", feed)
|
||||||
|
}
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension HomeViewController: NSFetchedResultsControllerDelegate {
|
||||||
|
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {
|
||||||
|
var snapshot = dataSource.snapshot()
|
||||||
|
if controller == groupResultsController {
|
||||||
|
if snapshot.sectionIdentifiers.contains(.groups) {
|
||||||
|
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .groups))
|
||||||
|
}
|
||||||
|
snapshot.appendItems(controller.fetchedObjects!.map { .group($0 as! Group) }, toSection: .groups)
|
||||||
|
} else if controller == feedResultsController {
|
||||||
|
if snapshot.sectionIdentifiers.contains(.feeds) {
|
||||||
|
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .feeds))
|
||||||
|
}
|
||||||
|
snapshot.appendItems(controller.fetchedObjects!.map { .feed($0 as! Feed) }, toSection: .feeds)
|
||||||
|
}
|
||||||
|
dataSource.apply(snapshot, animatingDifferences: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension HomeViewController: UICollectionViewDelegate {
|
||||||
|
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||||
|
guard let item = dataSource.itemIdentifier(for: indexPath) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let vc = ItemsViewController(fetchRequest: item.idFetchRequest, fervorController: fervorController)
|
||||||
|
vc.title = item.title
|
||||||
|
vc.delegate = itemsDelegate
|
||||||
|
show(vc, sender: nil)
|
||||||
|
UISelectionFeedbackGenerator().selectionChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
||||||
|
guard let item = dataSource.itemIdentifier(for: indexPath) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return UIContextMenuConfiguration(identifier: nil, previewProvider: {
|
||||||
|
return ItemsViewController(fetchRequest: item.idFetchRequest, fervorController: self.fervorController)
|
||||||
|
}, actionProvider: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
|
||||||
|
guard let vc = animator.previewViewController else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
animator.preferredCommitStyle = .pop
|
||||||
|
animator.addCompletion {
|
||||||
|
self.show(vc, sender: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension HomeViewController: StretchyMenuInteractionDelegate {
|
||||||
|
func stretchyMenuTitle() -> String? {
|
||||||
|
return "Switch Accounts"
|
||||||
|
}
|
||||||
|
func stretchyMenuItems() -> [StretchyMenuItem] {
|
||||||
|
var items: [StretchyMenuItem] = LocalData.accounts.map { account in
|
||||||
|
var title = account.instanceURL.host!
|
||||||
|
if let port = account.instanceURL.port, port != 80 && port != 443 {
|
||||||
|
title += ":\(port)"
|
||||||
|
}
|
||||||
|
let subtitle = account.id == fervorController.account!.id ? "Currently logged in" : nil
|
||||||
|
return StretchyMenuItem(title: title, subtitle: subtitle) { [unowned self] in
|
||||||
|
guard account.id != self.fervorController.account!.id else { return }
|
||||||
|
self.delegate?.switchToAccount(account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items.append(StretchyMenuItem(title: "Add Account", subtitle: nil, action: { [unowned self] in
|
||||||
|
let login = LoginViewController()
|
||||||
|
login.delegate = self
|
||||||
|
login.navigationItem.leftBarButtonItem = UIBarButtonItem(systemItem: .cancel, primaryAction: UIAction(handler: { (_) in
|
||||||
|
self.dismiss(animated: true)
|
||||||
|
}), menu: nil)
|
||||||
|
let nav = UINavigationController(rootViewController: login)
|
||||||
|
self.present(nav, animated: true)
|
||||||
|
}))
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension HomeViewController: LoginViewControllerDelegate {
|
||||||
|
func didLogin(with controller: FervorController) {
|
||||||
|
self.dismiss(animated: true)
|
||||||
|
(view.window!.windowScene!.delegate as! SceneDelegate).didLogin(with: controller)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,172 @@
|
||||||
|
//
|
||||||
|
// ItemCollectionViewCell.swift
|
||||||
|
// Reader
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 1/9/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
protocol ItemCollectionViewCellDelegate: AnyObject {
|
||||||
|
var fervorController: FervorController { get }
|
||||||
|
|
||||||
|
func itemCellSelected(cell: ItemCollectionViewCell, item: Item)
|
||||||
|
}
|
||||||
|
|
||||||
|
class ItemCollectionViewCell: UICollectionViewListCell {
|
||||||
|
|
||||||
|
weak var delegate: ItemCollectionViewCellDelegate?
|
||||||
|
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let feedTitleLabel = UILabel()
|
||||||
|
private let contentLabel = UILabel()
|
||||||
|
private var shouldInteractOnNextTouch = true
|
||||||
|
var scrollView: UIScrollView?
|
||||||
|
|
||||||
|
private var item: Item!
|
||||||
|
|
||||||
|
private lazy var feedbackGenerator = UISelectionFeedbackGenerator()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
|
||||||
|
backgroundConfiguration = .clear()
|
||||||
|
|
||||||
|
let descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .title3).withSymbolicTraits(.traitBold)!.withDesign(.serif)!
|
||||||
|
titleLabel.font = UIFont(descriptor: descriptor, size: 0)
|
||||||
|
titleLabel.numberOfLines = 0
|
||||||
|
|
||||||
|
feedTitleLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .subheadline), size: 0)
|
||||||
|
|
||||||
|
contentLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).withDesign(.serif)!, size: 0)
|
||||||
|
contentLabel.numberOfLines = 0
|
||||||
|
|
||||||
|
let stack = UIStackView(arrangedSubviews: [
|
||||||
|
titleLabel,
|
||||||
|
feedTitleLabel,
|
||||||
|
contentLabel,
|
||||||
|
])
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
stack.spacing = 8
|
||||||
|
stack.axis = .vertical
|
||||||
|
addSubview(stack)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20),
|
||||||
|
stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20),
|
||||||
|
stack.topAnchor.constraint(equalTo: topAnchor, constant: 8),
|
||||||
|
stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8),
|
||||||
|
separatorLayoutGuide.leadingAnchor.constraint(equalTo: stack.leadingAnchor),
|
||||||
|
])
|
||||||
|
|
||||||
|
let doubleTap = DoubleTapRecognizer(target: self, action: #selector(cellDoubleTapped))
|
||||||
|
doubleTap.onTouchesBegan = onDoubleTapBegan
|
||||||
|
addGestureRecognizer(doubleTap)
|
||||||
|
|
||||||
|
let singleTap = UITapGestureRecognizer(target: self, action: #selector(cellSingleTapped))
|
||||||
|
singleTap.require(toFail: doubleTap)
|
||||||
|
addGestureRecognizer(singleTap)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUI(item: Item) {
|
||||||
|
self.item = item
|
||||||
|
|
||||||
|
titleLabel.text = item.title
|
||||||
|
feedTitleLabel.text = item.feed!.title ?? item.feed!.url?.host
|
||||||
|
if let excerpt = item.excerpt {
|
||||||
|
contentLabel.text = excerpt
|
||||||
|
contentLabel.isHidden = false
|
||||||
|
} else {
|
||||||
|
contentLabel.isHidden = true
|
||||||
|
}
|
||||||
|
|
||||||
|
updateColors()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setRead(_ read: Bool, animated: Bool) {
|
||||||
|
guard item.read != read else { return }
|
||||||
|
|
||||||
|
item.read = read
|
||||||
|
|
||||||
|
if animated {
|
||||||
|
// i don't know why .transition works but .animate doesn't
|
||||||
|
UIView.transition(with: self, duration: 0.2, options: .transitionCrossDissolve) {
|
||||||
|
self.updateColors()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
updateColors()
|
||||||
|
}
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await delegate?.fervorController.markItem(item, read: read)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateColors() {
|
||||||
|
if item.read {
|
||||||
|
titleLabel.textColor = .secondaryLabel
|
||||||
|
feedTitleLabel.textColor = .secondaryLabel
|
||||||
|
contentLabel.textColor = .secondaryLabel
|
||||||
|
} else {
|
||||||
|
titleLabel.textColor = .label
|
||||||
|
feedTitleLabel.textColor = .tintColor
|
||||||
|
contentLabel.textColor = .appContentPreviewLabel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func onDoubleTapBegan() {
|
||||||
|
guard shouldInteractOnNextTouch else { return }
|
||||||
|
shouldInteractOnNextTouch = false
|
||||||
|
feedbackGenerator.prepare()
|
||||||
|
|
||||||
|
// couldn't manage to do this with just the recognizers
|
||||||
|
if scrollView?.isDragging == false && scrollView?.isDecelerating == false {
|
||||||
|
UIView.animateKeyframes(withDuration: 0.4, delay: 0, options: .allowUserInteraction) {
|
||||||
|
UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.5) {
|
||||||
|
self.backgroundColor = .appCellHighlightBackground
|
||||||
|
}
|
||||||
|
UIView.addKeyframe(withRelativeStartTime: 0.5, relativeDuration: 0.5) {
|
||||||
|
self.backgroundColor = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func cellDoubleTapped() {
|
||||||
|
setRead(!item.read, animated: true)
|
||||||
|
shouldInteractOnNextTouch = true
|
||||||
|
feedbackGenerator.selectionChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func cellSingleTapped() {
|
||||||
|
delegate?.itemCellSelected(cell: self, item: item)
|
||||||
|
shouldInteractOnNextTouch = true
|
||||||
|
feedbackGenerator.selectionChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private class DoubleTapRecognizer: UITapGestureRecognizer {
|
||||||
|
var onTouchesBegan: (() -> Void)!
|
||||||
|
|
||||||
|
override init(target: Any?, action: Selector?) {
|
||||||
|
super.init(target: target, action: action)
|
||||||
|
numberOfTapsRequired = 2
|
||||||
|
delaysTouchesBegan = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
|
||||||
|
super.touchesBegan(touches, with: event)
|
||||||
|
onTouchesBegan()
|
||||||
|
// shorten the delay before the single tap is recognized
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(400)) { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
if self.state != .recognized {
|
||||||
|
self.state = .failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,231 @@
|
||||||
|
//
|
||||||
|
// ItemsViewController.swift
|
||||||
|
// Reader
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 1/9/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import CoreData
|
||||||
|
import SafariServices
|
||||||
|
|
||||||
|
protocol ItemsViewControllerDelegate: AnyObject {
|
||||||
|
func showReadItem(_ item: Item)
|
||||||
|
}
|
||||||
|
|
||||||
|
class ItemsViewController: UIViewController {
|
||||||
|
|
||||||
|
weak var delegate: ItemsViewControllerDelegate?
|
||||||
|
|
||||||
|
let fervorController: FervorController
|
||||||
|
let fetchRequest: NSFetchRequest<NSManagedObjectID>
|
||||||
|
|
||||||
|
private var collectionView: UICollectionView!
|
||||||
|
private var dataSource: UICollectionViewDiffableDataSource<Section, NSManagedObjectID>!
|
||||||
|
|
||||||
|
private var batchUpdates: [() -> Void] = []
|
||||||
|
|
||||||
|
init(fetchRequest: NSFetchRequest<NSManagedObjectID>, fervorController: FervorController) {
|
||||||
|
precondition(fetchRequest.entityName == "Item")
|
||||||
|
|
||||||
|
self.fervorController = fervorController
|
||||||
|
self.fetchRequest = fetchRequest
|
||||||
|
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
if UIDevice.current.userInterfaceIdiom != .mac {
|
||||||
|
view.backgroundColor = .appBackground
|
||||||
|
}
|
||||||
|
|
||||||
|
var configuration = UICollectionLayoutListConfiguration(appearance: .plain)
|
||||||
|
configuration.backgroundColor = .clear
|
||||||
|
let layout = UICollectionViewCompositionalLayout.list(using: configuration)
|
||||||
|
collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
|
||||||
|
collectionView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(collectionView)
|
||||||
|
|
||||||
|
dataSource = createDataSource()
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
collectionView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
||||||
|
collectionView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
||||||
|
collectionView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
])
|
||||||
|
|
||||||
|
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "published", ascending: false)]
|
||||||
|
do {
|
||||||
|
let ids = try fervorController.persistentContainer.viewContext.fetch(fetchRequest)
|
||||||
|
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<Section, NSManagedObjectID>()
|
||||||
|
snapshot.appendSections([.items])
|
||||||
|
snapshot.appendItems(ids, toSection: .items)
|
||||||
|
dataSource.apply(snapshot, animatingDifferences: false)
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: fervorController.persistentContainer.viewContext)
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
let alert = UIAlertController(title: "Error fetching items", message: error.localizedDescription, preferredStyle: .alert)
|
||||||
|
alert.addAction(UIAlertAction(title: "Ok", style: .default, handler: nil))
|
||||||
|
present(alert, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, NSManagedObjectID> {
|
||||||
|
let itemCell = UICollectionView.CellRegistration<ItemCollectionViewCell, NSManagedObjectID> { [unowned self] cell, indexPath, itemIdentifier in
|
||||||
|
cell.delegate = self
|
||||||
|
cell.scrollView = self.collectionView
|
||||||
|
let item = self.fervorController.persistentContainer.viewContext.object(with: itemIdentifier) as! Item
|
||||||
|
cell.updateUI(item: item)
|
||||||
|
}
|
||||||
|
return UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
|
||||||
|
return collectionView.dequeueConfiguredReusableCell(using: itemCell, for: indexPath, item: itemIdentifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func managedObjectsDidChange(_ notification: Notification) {
|
||||||
|
let inserted = notification.userInfo?[NSInsertedObjectsKey] as? NSSet
|
||||||
|
let updated = notification.userInfo?[NSUpdatedObjectsKey] as? NSSet
|
||||||
|
let deleted = notification.userInfo?[NSDeletedObjectsKey] as? NSSet
|
||||||
|
|
||||||
|
// managed objectss from the notification are tied to the thread it was delivered on
|
||||||
|
// so get the published dates and evaluate the predicate here
|
||||||
|
let insertedItems = inserted?.compactMap { $0 as? Item }.filter { fetchRequest.predicate?.evaluate(with: $0) ?? true }.map { ($0, $0.published) }
|
||||||
|
|
||||||
|
var snapshot = self.dataSource.snapshot()
|
||||||
|
|
||||||
|
if let updated = updated {
|
||||||
|
snapshot.reconfigureItems(updated.compactMap { ($0 as? Item)?.objectID })
|
||||||
|
}
|
||||||
|
if let deleted = deleted {
|
||||||
|
snapshot.deleteItems(deleted.compactMap { ($0 as? Item)?.objectID })
|
||||||
|
}
|
||||||
|
|
||||||
|
if let insertedItems = insertedItems {
|
||||||
|
self.fervorController.persistentContainer.performBackgroundTask { ctx in
|
||||||
|
|
||||||
|
// for newly inserted items, the ctx doesn't have the published date so we check the data we got from the notification
|
||||||
|
func publishedForItem(_ id: NSManagedObjectID) -> Date? {
|
||||||
|
if let (_, pub) = insertedItems.first(where: { $0.0.objectID == id }) {
|
||||||
|
return pub
|
||||||
|
} else {
|
||||||
|
return (ctx.object(with: id) as! Item).published
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo: this feels inefficient
|
||||||
|
for (inserted, insertedPublished) in insertedItems {
|
||||||
|
// todo: uhh, what does sql do if the published date is nil?
|
||||||
|
guard let insertedPublished = insertedPublished else {
|
||||||
|
snapshot.insertItems([inserted.objectID], beforeItem: snapshot.itemIdentifiers.first!)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var index = 0
|
||||||
|
while index < snapshot.itemIdentifiers.count,
|
||||||
|
let pub = publishedForItem(snapshot.itemIdentifiers[index]),
|
||||||
|
insertedPublished < pub {
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// index is the index of the first item which the inserted one was published after
|
||||||
|
// (i.e., the item that should appear immediately after inserted in the list)
|
||||||
|
|
||||||
|
if index == snapshot.itemIdentifiers.count {
|
||||||
|
snapshot.appendItems([inserted.objectID], toSection: .items)
|
||||||
|
} else {
|
||||||
|
snapshot.insertItems([inserted.objectID], beforeItem: snapshot.itemIdentifiers[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.dataSource.apply(snapshot, animatingDifferences: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.dataSource.apply(snapshot, animatingDifferences: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ItemsViewController {
|
||||||
|
enum Section: Hashable {
|
||||||
|
case items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ItemsViewController: UICollectionViewDelegate {
|
||||||
|
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
||||||
|
guard let id = dataSource.itemIdentifier(for: indexPath) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let item = fervorController.persistentContainer.viewContext.object(with: id) as! Item
|
||||||
|
// let item = resultsController.fetchedObjects![indexPath.row]
|
||||||
|
return UIContextMenuConfiguration(identifier: nil, previewProvider: {
|
||||||
|
ReadViewController(item: item, fervorController: self.fervorController)
|
||||||
|
}, actionProvider: { _ in
|
||||||
|
var children: [UIMenuElement] = []
|
||||||
|
if let url = item.url {
|
||||||
|
children.append(UIAction(title: "Open in Safari", image: UIImage(systemName: "safari"), handler: { [weak self] _ in
|
||||||
|
let vc = SFSafariViewController(url: url)
|
||||||
|
vc.preferredControlTintColor = .appTintColor
|
||||||
|
self?.present(vc, animated: true)
|
||||||
|
}))
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
self.activityItemsConfiguration = UIActivityItemsConfiguration(objects: [url as NSURL])
|
||||||
|
children.append(UICommand(title: "Share…", action: Selector(("unused")), propertyList: UICommandTagShare))
|
||||||
|
#else
|
||||||
|
children.append(UIAction(title: "Share", image: UIImage(systemName: "square.and.arrow.up"), handler: { [weak self] _ in
|
||||||
|
self?.present(UIActivityViewController(activityItems: [url], applicationActivities: nil), animated: true)
|
||||||
|
}))
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
if item.read {
|
||||||
|
children.append(UIAction(title: "Mark as Unread", image: UIImage(systemName: "checkmark.circle"), handler: { [unowned self] _ in
|
||||||
|
Task {
|
||||||
|
await self.fervorController.markItem(item, read: false)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
children.append(UIAction(title: "Mark as Read", image: UIImage(systemName: "checkmark.circle.fill"), handler: { [unowned self] _ in
|
||||||
|
Task {
|
||||||
|
await self.fervorController.markItem(item, read: true)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return UIMenu(children: children)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
|
||||||
|
guard let vc = animator.previewViewController else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
animator.preferredCommitStyle = .pop
|
||||||
|
animator.addCompletion {
|
||||||
|
self.show(vc, sender: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ItemsViewController: ItemCollectionViewCellDelegate {
|
||||||
|
func itemCellSelected(cell: ItemCollectionViewCell, item: Item) {
|
||||||
|
cell.setRead(true, animated: true)
|
||||||
|
if let delegate = delegate {
|
||||||
|
delegate.showReadItem(item)
|
||||||
|
} else {
|
||||||
|
show(ReadViewController(item: item, fervorController: fervorController), sender: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -9,6 +9,7 @@ import UIKit
|
||||||
import AuthenticationServices
|
import AuthenticationServices
|
||||||
import Fervor
|
import Fervor
|
||||||
|
|
||||||
|
@MainActor
|
||||||
protocol LoginViewControllerDelegate: AnyObject {
|
protocol LoginViewControllerDelegate: AnyObject {
|
||||||
func didLogin(with controller: FervorController)
|
func didLogin(with controller: FervorController)
|
||||||
}
|
}
|
||||||
|
@ -18,6 +19,7 @@ class LoginViewController: UIViewController {
|
||||||
weak var delegate: LoginViewControllerDelegate?
|
weak var delegate: LoginViewControllerDelegate?
|
||||||
|
|
||||||
private var textField: UITextField!
|
private var textField: UITextField!
|
||||||
|
private var activityIndicator: UIActivityIndicatorView!
|
||||||
|
|
||||||
private var authSession: ASWebAuthenticationSession?
|
private var authSession: ASWebAuthenticationSession?
|
||||||
|
|
||||||
|
@ -37,10 +39,18 @@ class LoginViewController: UIViewController {
|
||||||
textField.placeholder = "example.com"
|
textField.placeholder = "example.com"
|
||||||
textField.addTarget(self, action: #selector(doEnteredURL), for: .primaryActionTriggered)
|
textField.addTarget(self, action: #selector(doEnteredURL), for: .primaryActionTriggered)
|
||||||
view.addSubview(textField)
|
view.addSubview(textField)
|
||||||
|
|
||||||
|
activityIndicator = UIActivityIndicatorView(style: .medium)
|
||||||
|
activityIndicator.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(activityIndicator)
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
textField.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
textField.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
||||||
textField.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
textField.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||||
textField.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.75),
|
textField.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.75),
|
||||||
|
|
||||||
|
activityIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||||
|
activityIndicator.topAnchor.constraint(equalTo: textField.bottomAnchor, constant: 8),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -59,14 +69,22 @@ class LoginViewController: UIViewController {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
textField.isEnabled = false
|
||||||
|
activityIndicator.startAnimating()
|
||||||
|
|
||||||
let controller = FervorController(instanceURL: components.url!)
|
let controller = FervorController(instanceURL: components.url!)
|
||||||
|
|
||||||
let registration: ClientRegistration
|
let registration: ClientRegistration
|
||||||
do {
|
do {
|
||||||
registration = try await controller.register()
|
registration = try await controller.register()
|
||||||
} catch {
|
} catch {
|
||||||
let alert = UIAlertController(title: "Unable to Register Client", message: error.localizedDescription, preferredStyle: .alert)
|
let alert = UIAlertController(title: "Unable to register client", message: error.localizedDescription, preferredStyle: .alert)
|
||||||
|
alert.addAction(UIAlertAction(title: "Ok", style: .default, handler: nil))
|
||||||
present(alert, animated: true)
|
present(alert, animated: true)
|
||||||
|
|
||||||
|
textField.isEnabled = true
|
||||||
|
activityIndicator.stopAnimating()
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -81,13 +99,29 @@ 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 {
|
||||||
fatalError()
|
DispatchQueue.main.async {
|
||||||
|
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))
|
||||||
|
self.present(alert, animated: true)
|
||||||
|
|
||||||
|
self.textField.isEnabled = true
|
||||||
|
self.activityIndicator.stopAnimating()
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
Task {
|
Task { @MainActor in
|
||||||
try! await controller.getToken(authCode: codeValue)
|
do {
|
||||||
DispatchQueue.main.async {
|
try await controller.getToken(authCode: codeValue)
|
||||||
self.delegate?.didLogin(with: controller)
|
} catch {
|
||||||
|
let alert = UIAlertController(title: "Unable to retrieve access token", message: error.localizedDescription, preferredStyle: .alert)
|
||||||
|
alert.addAction(UIAlertAction(title: "Ok", style: .default, handler: nil))
|
||||||
|
self.present(alert, animated: true)
|
||||||
|
|
||||||
|
self.textField.isEnabled = true
|
||||||
|
self.activityIndicator.stopAnimating()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
self.delegate?.didLogin(with: controller)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,38 @@
|
||||||
|
//
|
||||||
|
// PrefsView.swift
|
||||||
|
// Reader
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 1/16/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct PrefsView: View {
|
||||||
|
@ObservedObject private var preferences = Preferences.shared
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack {
|
||||||
|
GroupBox {
|
||||||
|
VStack {
|
||||||
|
appearance
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var appearance: some View {
|
||||||
|
Picker("Appearance", selection: $preferences.appearance) {
|
||||||
|
Text("System").tag(Appearance.unspecified)
|
||||||
|
Text("Dark").tag(Appearance.dark)
|
||||||
|
Text("Light").tag(Appearance.light)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PrefsView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
PrefsView()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,239 @@
|
||||||
|
//
|
||||||
|
// ReadViewController.swift
|
||||||
|
// Reader
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 1/9/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import WebKit
|
||||||
|
import HTMLEntities
|
||||||
|
import SafariServices
|
||||||
|
|
||||||
|
class ReadViewController: UIViewController {
|
||||||
|
|
||||||
|
private static let publishedFormatter: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateStyle = .medium
|
||||||
|
f.timeStyle = .medium
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
let fervorController: FervorController
|
||||||
|
let item: Item
|
||||||
|
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
private var itemReadObservation: NSKeyValueObservation?
|
||||||
|
#endif
|
||||||
|
|
||||||
|
override var prefersStatusBarHidden: Bool {
|
||||||
|
navigationController?.isNavigationBarHidden ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
|
||||||
|
.slide
|
||||||
|
}
|
||||||
|
|
||||||
|
init(item: Item, fervorController: FervorController) {
|
||||||
|
self.fervorController = fervorController
|
||||||
|
self.item = item
|
||||||
|
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
navigationItem.largeTitleDisplayMode = .never
|
||||||
|
|
||||||
|
view.backgroundColor = .appBackground
|
||||||
|
view.addInteraction(StretchyMenuInteraction(delegate: self))
|
||||||
|
|
||||||
|
let webView = WKWebView()
|
||||||
|
webView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
webView.navigationDelegate = self
|
||||||
|
webView.uiDelegate = self
|
||||||
|
// transparent background required to prevent white flash in dark mode, just using .appBackground doesn't work
|
||||||
|
webView.isOpaque = false
|
||||||
|
webView.backgroundColor = .clear
|
||||||
|
if let content = itemContentHTML() {
|
||||||
|
webView.loadHTMLString(content, baseURL: item.url)
|
||||||
|
}
|
||||||
|
view.addSubview(webView)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
webView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
webView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
webView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
webView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
])
|
||||||
|
|
||||||
|
if let url = item.url {
|
||||||
|
activityItemsConfiguration = UIActivityItemsConfiguration(objects: [url as NSURL])
|
||||||
|
}
|
||||||
|
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
itemReadObservation = item.observe(\.read) { [unowned self] _, _ in
|
||||||
|
self.updateToggleReadToolbarImage()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let css = try! String(contentsOf: Bundle.main.url(forResource: "read", withExtension: "css")!)
|
||||||
|
private static let js = try! String(contentsOf: Bundle.main.url(forResource: "read", withExtension: "js")!)
|
||||||
|
|
||||||
|
private func itemContentHTML() -> String? {
|
||||||
|
guard let content = item.content else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var info = ""
|
||||||
|
if let title = item.title, !title.isEmpty {
|
||||||
|
info += "<h1 id=\"item-title\">"
|
||||||
|
if let url = item.url {
|
||||||
|
info += "<a href=\"\(url.absoluteString)\">"
|
||||||
|
}
|
||||||
|
info += title.htmlEscape()
|
||||||
|
if item.url != nil {
|
||||||
|
info += "</a>"
|
||||||
|
}
|
||||||
|
info += "</h1>"
|
||||||
|
}
|
||||||
|
if let feedTitle = item.feed!.title, !feedTitle.isEmpty {
|
||||||
|
info += "<h2 id=\"item-feed-title\">\(feedTitle.htmlEscape())</h2>"
|
||||||
|
}
|
||||||
|
if let author = item.author, !author.isEmpty {
|
||||||
|
info += "<h3 id=\"item-author\">\(author)</h3>"
|
||||||
|
}
|
||||||
|
if let published = item.published {
|
||||||
|
let formatted = ReadViewController.publishedFormatter.string(from: published)
|
||||||
|
info += "<h3 id=\"item-published\">\(formatted)</h3>"
|
||||||
|
}
|
||||||
|
return """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no" />
|
||||||
|
<style>\(ReadViewController.css)</style>
|
||||||
|
<script async>\(ReadViewController.js)</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="item-info">
|
||||||
|
\(info)
|
||||||
|
</div>
|
||||||
|
<div id="item-content">
|
||||||
|
\(content)
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createSafariVC(url: URL) -> SFSafariViewController {
|
||||||
|
let vc = SFSafariViewController(url: url)
|
||||||
|
vc.preferredControlTintColor = .appTintColor
|
||||||
|
return vc
|
||||||
|
}
|
||||||
|
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
@objc func toggleItemRead(_ item: NSToolbarItem) {
|
||||||
|
Task {
|
||||||
|
await fervorController.markItem(self.item, read: !self.item.read)
|
||||||
|
updateToggleReadToolbarImage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateToggleReadToolbarImage() {
|
||||||
|
guard let titlebar = view.window?.windowScene?.titlebar,
|
||||||
|
let item = titlebar.toolbar?.items.first(where: { $0.itemIdentifier == .toggleItemRead }) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
item.image = UIImage(systemName: self.item.read ? "checkmark.circle.fill" : "checkmark.circle")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ReadViewController: WKNavigationDelegate {
|
||||||
|
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy {
|
||||||
|
if navigationAction.navigationType == .linkActivated {
|
||||||
|
let url = navigationAction.request.url!
|
||||||
|
if url.fragment != nil {
|
||||||
|
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)!
|
||||||
|
components.fragment = nil
|
||||||
|
if components.url == item.url {
|
||||||
|
return .allow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
present(createSafariVC(url: url), animated: true)
|
||||||
|
return .cancel
|
||||||
|
} else {
|
||||||
|
return .allow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ReadViewController: WKUIDelegate {
|
||||||
|
func webView(_ webView: WKWebView, contextMenuConfigurationFor elementInfo: WKContextMenuElementInfo) async -> UIContextMenuConfiguration? {
|
||||||
|
guard let url = elementInfo.linkURL,
|
||||||
|
["http", "https"].contains(url.scheme?.lowercased()) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return UIContextMenuConfiguration(identifier: nil) { [unowned self] in
|
||||||
|
self.createSafariVC(url: url)
|
||||||
|
} actionProvider: { _ in
|
||||||
|
return UIMenu(children: [
|
||||||
|
UIAction(title: "Open in Safari", image: UIImage(systemName: "safari"), handler: { [weak self] _ in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.present(self.createSafariVC(url: url), animated: true)
|
||||||
|
}),
|
||||||
|
UIAction(title: "Share", image: UIImage(systemName: "square.and.arrow.up"), handler: { [weak self] _ in
|
||||||
|
self?.present(UIActivityViewController(activityItems: [url], applicationActivities: nil), animated: true)
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, contextMenuForElement elementInfo: WKContextMenuElementInfo, willCommitWithAnimator animator: UIContextMenuInteractionCommitAnimating) {
|
||||||
|
if let vc = animator.previewViewController as? SFSafariViewController {
|
||||||
|
animator.preferredCommitStyle = .pop
|
||||||
|
animator.addCompletion {
|
||||||
|
self.present(vc, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ReadViewController: StretchyMenuInteractionDelegate {
|
||||||
|
func stretchyMenuTitle() -> String? {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func stretchyMenuItems() -> [StretchyMenuItem] {
|
||||||
|
guard let url = item.url else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
var items = [
|
||||||
|
StretchyMenuItem(title: "Open in Safari", subtitle: nil, action: { [unowned self] in
|
||||||
|
self.present(createSafariVC(url: url), animated: true)
|
||||||
|
}),
|
||||||
|
StretchyMenuItem(title: item.read ? "Mark as Unread" : "Mark as Read", subtitle: nil, action: { [unowned self] in
|
||||||
|
Task {
|
||||||
|
await self.fervorController.markItem(item, read: !item.read)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
#if !targetEnvironment(macCatalyst)
|
||||||
|
items.insert(StretchyMenuItem(title: "Share", subtitle: nil, action: { [unowned self] in
|
||||||
|
self.present(UIActivityViewController(activityItems: [url], applicationActivities: nil), animated: true)
|
||||||
|
}), at: 1)
|
||||||
|
#endif
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,406 @@
|
||||||
|
//
|
||||||
|
// StretchyMenuInteraction.swift
|
||||||
|
// Reader
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 1/11/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
struct StretchyMenuItem {
|
||||||
|
let title: String
|
||||||
|
let subtitle: String?
|
||||||
|
let action: () -> Void
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol StretchyMenuInteractionDelegate: AnyObject {
|
||||||
|
func stretchyMenuTitle() -> String?
|
||||||
|
func stretchyMenuItems() -> [StretchyMenuItem]
|
||||||
|
}
|
||||||
|
|
||||||
|
class StretchyMenuInteraction: NSObject, UIInteraction {
|
||||||
|
|
||||||
|
weak var delegate: StretchyMenuInteractionDelegate?
|
||||||
|
|
||||||
|
private(set) weak var view: UIView? = nil
|
||||||
|
|
||||||
|
private let menuHintView = MenuHintView()
|
||||||
|
fileprivate let feedbackGenerator = UIImpactFeedbackGenerator(style: .medium)
|
||||||
|
private var snapshot: UIView?
|
||||||
|
private var menuView: MenuView?
|
||||||
|
|
||||||
|
private let menuOpenThreshold: CGFloat = 100
|
||||||
|
|
||||||
|
fileprivate var isShowingMenu = false
|
||||||
|
|
||||||
|
init(delegate: StretchyMenuInteractionDelegate) {
|
||||||
|
self.delegate = delegate
|
||||||
|
|
||||||
|
menuHintView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func willMove(to view: UIView?) {
|
||||||
|
if self.view != nil {
|
||||||
|
fatalError("removing StretchyMenuInteraction from view is unsupported")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func didMove(to view: UIView?) {
|
||||||
|
self.view = view
|
||||||
|
|
||||||
|
guard let view = view else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(panGestureRecognized))
|
||||||
|
panRecognizer.delegate = self
|
||||||
|
panRecognizer.allowedScrollTypesMask = [.continuous]
|
||||||
|
view.addGestureRecognizer(panRecognizer)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private var prevTranslation: CGFloat = 0
|
||||||
|
|
||||||
|
@objc private func panGestureRecognized(_ recognizer: UIPanGestureRecognizer) {
|
||||||
|
guard let view = view,
|
||||||
|
!isShowingMenu,
|
||||||
|
let delegate = delegate else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let prevTranslation = self.prevTranslation
|
||||||
|
let translation = recognizer.translation(in: view)
|
||||||
|
self.prevTranslation = translation.x
|
||||||
|
|
||||||
|
switch recognizer.state {
|
||||||
|
case .began:
|
||||||
|
snapshot = view.snapshotView(afterScreenUpdates: false)
|
||||||
|
let effectiveTranslation = 0.5 * max(0, -translation.x)
|
||||||
|
snapshot!.transform = CGAffineTransform(translationX: -effectiveTranslation, y: 0)
|
||||||
|
view.addSubview(snapshot!)
|
||||||
|
view.insertSubview(menuHintView, belowSubview: snapshot!)
|
||||||
|
menuHintView.backgroundColor = view.backgroundColor
|
||||||
|
menuHintView.frame = CGRect(x: view.bounds.width - effectiveTranslation, y: 0, width: effectiveTranslation, height: view.bounds.height)
|
||||||
|
menuHintView.updateForProgress(0, animate: false)
|
||||||
|
feedbackGenerator.prepare()
|
||||||
|
|
||||||
|
case .changed:
|
||||||
|
let effectiveTranslation = 0.5 * max(0, -translation.x)
|
||||||
|
snapshot!.transform = CGAffineTransform(translationX: -effectiveTranslation, y: 0)
|
||||||
|
if -prevTranslation < menuOpenThreshold && -translation.x >= menuOpenThreshold {
|
||||||
|
feedbackGenerator.impactOccurred()
|
||||||
|
}
|
||||||
|
menuHintView.frame = CGRect(x: view.bounds.width - effectiveTranslation, y: 0, width: effectiveTranslation, height: view.bounds.height)
|
||||||
|
menuHintView.updateForProgress(-translation.x / menuOpenThreshold, animate: true)
|
||||||
|
|
||||||
|
case .ended:
|
||||||
|
UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseInOut) {
|
||||||
|
self.snapshot!.transform = .identity
|
||||||
|
} completion: { _ in
|
||||||
|
self.snapshot!.removeFromSuperview()
|
||||||
|
self.menuHintView.removeFromSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if -translation.x > menuOpenThreshold {
|
||||||
|
guard let rootView = view.window?.rootViewController?.view else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let menuView = MenuView(title: delegate.stretchyMenuTitle(), items: delegate.stretchyMenuItems(), owner: self)
|
||||||
|
self.menuView = menuView
|
||||||
|
menuView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
menuView.layer.zPosition = 102
|
||||||
|
rootView.addSubview(menuView)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
menuView.leadingAnchor.constraint(equalTo: rootView.leadingAnchor),
|
||||||
|
menuView.trailingAnchor.constraint(equalTo: rootView.trailingAnchor),
|
||||||
|
menuView.topAnchor.constraint(equalTo: rootView.topAnchor),
|
||||||
|
menuView.bottomAnchor.constraint(equalTo: rootView.bottomAnchor),
|
||||||
|
])
|
||||||
|
rootView.layoutIfNeeded()
|
||||||
|
menuView.animateIn()
|
||||||
|
|
||||||
|
isShowingMenu = true
|
||||||
|
feedbackGenerator.prepare()
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hideMenu(completion: (() -> Void)? = nil) {
|
||||||
|
guard let menuView = menuView else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
menuView.animateOut() {
|
||||||
|
menuView.removeFromSuperview()
|
||||||
|
self.isShowingMenu = false
|
||||||
|
completion?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension StretchyMenuInteraction: UIGestureRecognizerDelegate {
|
||||||
|
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||||
|
return otherGestureRecognizer.name == AppNavigationController.panRecognizerName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class MenuHintView: UIView {
|
||||||
|
private let pill = UIView()
|
||||||
|
private var progress: CGFloat = 0
|
||||||
|
|
||||||
|
private var animator: UIViewPropertyAnimator?
|
||||||
|
|
||||||
|
init() {
|
||||||
|
super.init(frame: .zero)
|
||||||
|
|
||||||
|
pill.frame = CGRect(x: 0, y: 0, width: 5, height: 50)
|
||||||
|
pill.backgroundColor = .systemGray
|
||||||
|
pill.layer.cornerRadius = 2.5
|
||||||
|
addSubview(pill)
|
||||||
|
|
||||||
|
pill.backgroundColor = .systemGray
|
||||||
|
pill.frame.size.height = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
|
||||||
|
let width: CGFloat = 5
|
||||||
|
pill.frame.origin.x = bounds.midX - width / 2
|
||||||
|
pill.frame.origin.y = bounds.midY - pill.frame.height / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateForProgress(_ progress: CGFloat, animate: Bool) {
|
||||||
|
let oldCompleted = self.progress >= 1
|
||||||
|
let completed = progress >= 1
|
||||||
|
|
||||||
|
self.progress = progress
|
||||||
|
|
||||||
|
if oldCompleted != completed {
|
||||||
|
func updatePill() {
|
||||||
|
pill.backgroundColor = completed ? .tintColor : .systemGray
|
||||||
|
let height: CGFloat = completed ? 75 : 50
|
||||||
|
pill.frame.origin.y = bounds.midY - height / 2
|
||||||
|
pill.frame.size.height = height
|
||||||
|
}
|
||||||
|
|
||||||
|
animator?.stopAnimation(true)
|
||||||
|
if animate {
|
||||||
|
animator = UIViewPropertyAnimator(duration: 0.5, dampingRatio: 0.5, animations: updatePill)
|
||||||
|
animator!.startAnimation()
|
||||||
|
} else {
|
||||||
|
updatePill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class MenuView: UIView {
|
||||||
|
|
||||||
|
weak var owner: StretchyMenuInteraction?
|
||||||
|
|
||||||
|
private let blurEffect = UIBlurEffect(style: .systemUltraThinMaterialDark)
|
||||||
|
private var blurView: UIView!
|
||||||
|
private let optionsStack = UIStackView()
|
||||||
|
|
||||||
|
private let items: [StretchyMenuItem]
|
||||||
|
|
||||||
|
init(title: String?, items: [StretchyMenuItem], owner: StretchyMenuInteraction) {
|
||||||
|
self.items = items
|
||||||
|
self.owner = owner
|
||||||
|
|
||||||
|
super.init(frame: .zero)
|
||||||
|
|
||||||
|
blurView = UIVisualEffectView(effect: blurEffect)
|
||||||
|
blurView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
addSubview(blurView)
|
||||||
|
|
||||||
|
optionsStack.axis = .vertical
|
||||||
|
optionsStack.alignment = .fill
|
||||||
|
optionsStack.spacing = 2
|
||||||
|
optionsStack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
for item in items {
|
||||||
|
optionsStack.addArrangedSubview(MenuItemView(item: item, owner: owner))
|
||||||
|
}
|
||||||
|
addSubview(optionsStack)
|
||||||
|
|
||||||
|
if let title = title {
|
||||||
|
let titleLabel = UILabel()
|
||||||
|
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
titleLabel.text = title
|
||||||
|
titleLabel.textAlignment = .right
|
||||||
|
titleLabel.font = .preferredFont(forTextStyle: .title1)
|
||||||
|
let vibrancyView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: blurEffect, style: .label))
|
||||||
|
vibrancyView.contentView.addSubview(titleLabel)
|
||||||
|
optionsStack.insertArrangedSubview(vibrancyView, at: 0)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
titleLabel.leadingAnchor.constraint(equalToSystemSpacingAfter: vibrancyView.contentView.leadingAnchor, multiplier: 1),
|
||||||
|
vibrancyView.contentView.trailingAnchor.constraint(equalToSystemSpacingAfter: titleLabel.trailingAnchor, multiplier: 1),
|
||||||
|
titleLabel.topAnchor.constraint(equalTo: vibrancyView.contentView.topAnchor),
|
||||||
|
titleLabel.bottomAnchor.constraint(equalTo: vibrancyView.contentView.bottomAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
blurView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
|
blurView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
|
blurView.topAnchor.constraint(equalTo: topAnchor),
|
||||||
|
blurView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||||
|
|
||||||
|
optionsStack.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
|
optionsStack.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
|
optionsStack.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||||
|
])
|
||||||
|
|
||||||
|
blurView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(blurTapped)))
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func animateIn() {
|
||||||
|
blurView.layer.opacity = 0
|
||||||
|
let count = optionsStack.arrangedSubviews.count
|
||||||
|
for (index, item) in optionsStack.arrangedSubviews.enumerated() {
|
||||||
|
let multiplier = (1 + CGFloat(index) * 1 / CGFloat(count - 1))
|
||||||
|
item.transform = CGAffineTransform(translationX: bounds.width * multiplier, y: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
UIView.animate(withDuration: 0.35, delay: 0, options: .curveEaseInOut) {
|
||||||
|
self.blurView.layer.opacity = 1
|
||||||
|
for item in self.optionsStack.arrangedSubviews {
|
||||||
|
item.transform = .identity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func animateOut(completion: @escaping () -> Void) {
|
||||||
|
UIView.animate(withDuration: 0.35, delay: 0, options: .curveEaseInOut) {
|
||||||
|
self.blurView.layer.opacity = 0
|
||||||
|
let count = self.optionsStack.arrangedSubviews.count
|
||||||
|
for (index, item) in self.optionsStack.arrangedSubviews.enumerated() {
|
||||||
|
let multiplier = (1 + CGFloat(index) * 1 / CGFloat(count - 1))
|
||||||
|
item.transform = CGAffineTransform(translationX: self.bounds.width * -multiplier, y: 0)
|
||||||
|
}
|
||||||
|
} completion: { _ in
|
||||||
|
completion()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func blurTapped() {
|
||||||
|
owner?.hideMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private class MenuItemView: UIView {
|
||||||
|
weak var owner: StretchyMenuInteraction?
|
||||||
|
|
||||||
|
private let item: StretchyMenuItem
|
||||||
|
|
||||||
|
init(item: StretchyMenuItem, owner: StretchyMenuInteraction) {
|
||||||
|
self.item = item
|
||||||
|
self.owner = owner
|
||||||
|
|
||||||
|
super.init(frame: .zero)
|
||||||
|
|
||||||
|
backgroundColor = .appBackground
|
||||||
|
|
||||||
|
let titleLabel = UILabel()
|
||||||
|
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
titleLabel.text = item.title
|
||||||
|
titleLabel.textColor = .tintColor
|
||||||
|
titleLabel.textAlignment = .right
|
||||||
|
titleLabel.font = .preferredFont(forTextStyle: .title2)
|
||||||
|
addSubview(titleLabel)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 4),
|
||||||
|
titleLabel.leadingAnchor.constraint(equalToSystemSpacingAfter: leadingAnchor, multiplier: 1),
|
||||||
|
trailingAnchor.constraint(equalToSystemSpacingAfter: titleLabel.trailingAnchor, multiplier: 1),
|
||||||
|
|
||||||
|
heightAnchor.constraint(greaterThanOrEqualToConstant: 44),
|
||||||
|
])
|
||||||
|
|
||||||
|
if let subtitle = item.subtitle {
|
||||||
|
let subtitleLabel = UILabel()
|
||||||
|
subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
subtitleLabel.text = subtitle
|
||||||
|
subtitleLabel.textColor = .secondaryLabel
|
||||||
|
subtitleLabel.textAlignment = .right
|
||||||
|
addSubview(subtitleLabel)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
subtitleLabel.leadingAnchor.constraint(equalToSystemSpacingAfter: leadingAnchor, multiplier: 1),
|
||||||
|
trailingAnchor.constraint(equalToSystemSpacingAfter: subtitleLabel.trailingAnchor, multiplier: 1),
|
||||||
|
subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor),
|
||||||
|
bottomAnchor.constraint(equalTo: subtitleLabel.bottomAnchor, constant: 4),
|
||||||
|
])
|
||||||
|
} else {
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
bottomAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(itemTapped)))
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func itemTapped() {
|
||||||
|
owner?.feedbackGenerator.impactOccurred()
|
||||||
|
|
||||||
|
UIView.animateKeyframes(withDuration: 0.15, delay: 0, options: []) {
|
||||||
|
UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.5) {
|
||||||
|
self.backgroundColor = .appSecondaryBackground
|
||||||
|
}
|
||||||
|
UIView.addKeyframe(withRelativeStartTime: 0.5, relativeDuration: 0.5) {
|
||||||
|
self.backgroundColor = .appBackground
|
||||||
|
}
|
||||||
|
} completion: { _ in
|
||||||
|
self.owner?.hideMenu() {
|
||||||
|
self.item.action()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addBorders(top: Bool, bottom: Bool) {
|
||||||
|
if top {
|
||||||
|
let view = UIView()
|
||||||
|
view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.backgroundColor = .systemGray
|
||||||
|
addSubview(view)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
view.heightAnchor.constraint(equalToConstant: 1),
|
||||||
|
view.topAnchor.constraint(equalTo: topAnchor),
|
||||||
|
view.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
|
view.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
if bottom {
|
||||||
|
let view = UIView()
|
||||||
|
view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.backgroundColor = .systemGray
|
||||||
|
addSubview(view)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
view.heightAnchor.constraint(equalToConstant: 1),
|
||||||
|
view.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||||
|
view.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
|
view.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,58 @@
|
||||||
|
//
|
||||||
|
// UIColor+App.swift
|
||||||
|
// Reader
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 1/9/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
extension UIColor {
|
||||||
|
|
||||||
|
static let appBackground = UIColor { traitCollection in
|
||||||
|
switch traitCollection.userInterfaceStyle {
|
||||||
|
case .dark:
|
||||||
|
return UIColor(red: 25/255, green: 25/255, blue: 25/255, alpha: 1)
|
||||||
|
case .unspecified, .light:
|
||||||
|
fallthrough
|
||||||
|
@unknown default:
|
||||||
|
return .white
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static let appSecondaryBackground = UIColor { traitCollection in
|
||||||
|
switch traitCollection.userInterfaceStyle {
|
||||||
|
case .dark:
|
||||||
|
return UIColor(white: 0.2, alpha: 1)
|
||||||
|
case .unspecified, .light:
|
||||||
|
fallthrough
|
||||||
|
@unknown default:
|
||||||
|
return UIColor(white: 0.8, alpha: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static let appCellHighlightBackground = UIColor { traitCollection in
|
||||||
|
switch traitCollection.userInterfaceStyle {
|
||||||
|
case .dark:
|
||||||
|
return UIColor(white: 0.15, alpha: 1)
|
||||||
|
case .unspecified, .light:
|
||||||
|
fallthrough
|
||||||
|
@unknown default:
|
||||||
|
return UIColor(white: 0.9, alpha: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static let appContentPreviewLabel = UIColor { traitCollection in
|
||||||
|
switch traitCollection.userInterfaceStyle {
|
||||||
|
case .dark:
|
||||||
|
return .lightGray
|
||||||
|
case .unspecified, .light:
|
||||||
|
fallthrough
|
||||||
|
@unknown default:
|
||||||
|
return .darkGray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static let appTintColor = UIColor.systemRed
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,32 @@
|
||||||
|
//
|
||||||
|
// UserActivities.swift
|
||||||
|
// Reader
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 1/15/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension NSUserActivity {
|
||||||
|
|
||||||
|
static let preferencesType = "net.shadowfacts.Reader.activity.preferences"
|
||||||
|
static let addAccountType = "net.shadowfacts.Reader.activity.add-account"
|
||||||
|
static let activateAccountType = "net.shadowfacts.Reader.activity.activate-account"
|
||||||
|
|
||||||
|
static func preferences() -> NSUserActivity {
|
||||||
|
return NSUserActivity(activityType: preferencesType)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func addAccount() -> NSUserActivity {
|
||||||
|
return NSUserActivity(activityType: addAccountType)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func activateAccount(_ account: LocalData.Account) -> NSUserActivity {
|
||||||
|
let activity = NSUserActivity(activityType: activateAccountType)
|
||||||
|
activity.userInfo = [
|
||||||
|
"accountID": account.id.uuidString
|
||||||
|
]
|
||||||
|
return activity
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,106 @@
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
|
||||||
|
--tint-color: rgb(255, 59, 48); /* .systemRed */
|
||||||
|
--dark-tint-color: rgb(255, 69, 58); /* .systemRed */
|
||||||
|
--secondary-text-color: rgb(85, 85, 85); /* .darkGray */
|
||||||
|
--dark-secondary-text-color: rgb(170, 170, 170); /* .lightGray */
|
||||||
|
--background-color: white; /* .appBackground */
|
||||||
|
--dark-background-color: rgb(25, 25, 25); /* .appBackground */
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 12px;
|
||||||
|
font-family: ui-serif;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--tint-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
figure {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
figcaption {
|
||||||
|
color: var(--secondary-text-color);
|
||||||
|
font-style: italic;
|
||||||
|
font-family: ui-sans-serif;
|
||||||
|
font-size: 12pt;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre, code {
|
||||||
|
/* ui-monospace doesn't work for some reason, even though the other ui- fonts do */
|
||||||
|
font-family: ui-monospace, "SF Mono";
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
overflow-wrap: normal;
|
||||||
|
overflow-x: auto;
|
||||||
|
tab-size: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
border-left: 4px solid var(--tint-color);
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
padding: 0 20px;
|
||||||
|
color: gray;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-content-table {
|
||||||
|
overflow-x: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
#item-info {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#item-title {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#item-title a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
#item-feed-title,
|
||||||
|
#item-author,
|
||||||
|
#item-published {
|
||||||
|
margin: 0.25em 0;
|
||||||
|
font-family: ui-sans-serif;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
#item-feed-title {
|
||||||
|
color: var(--tint-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
#item-author,
|
||||||
|
#item-published {
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--secondary-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
#item-content {
|
||||||
|
font-size: 14pt;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--tint-color: var(--dark-tint-color);
|
||||||
|
--secondary-text-color: var(--dark-secondary-text-color);
|
||||||
|
--background-color: var(--dark-background-color);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
for (const table of document.querySelectorAll("table")) {
|
||||||
|
// wrap tables in divs to which we can apply overflow-x: scroll;
|
||||||
|
table.outerHTML = `<div class="item-content-table">${table.outerHTML}</div>`;
|
||||||
|
}
|
||||||
|
});
|
|
@ -0,0 +1,40 @@
|
||||||
|
//
|
||||||
|
// ReaderMac.swift
|
||||||
|
// ReaderMac
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 1/16/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
class ReaderMac: NSObject {
|
||||||
|
|
||||||
|
private(set) static var shared: ReaderMac!
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
if ReaderMac.shared != nil {
|
||||||
|
fatalError()
|
||||||
|
}
|
||||||
|
|
||||||
|
super.init()
|
||||||
|
|
||||||
|
ReaderMac.shared = self
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func setup() {
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func updateAppearance(_ appearance: NSNumber) {
|
||||||
|
switch appearance {
|
||||||
|
case 0:
|
||||||
|
NSApp.appearance = nil
|
||||||
|
case 1:
|
||||||
|
NSApp.appearance = NSAppearance(named: .aqua)
|
||||||
|
case 2:
|
||||||
|
NSApp.appearance = NSAppearance(named: .darkAqua)
|
||||||
|
default:
|
||||||
|
fatalError("unexpected appeaerance value: \(appearance)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
File diff suppressed because one or more lines are too long
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit f32bd14b229ed1088c25725cce242817ea2fe43a
|
Loading…
Reference in New Issue