Compare commits

...

54 Commits

Author SHA1 Message Date
Shadowfacts e242510c5e Don't use NSFetchedResultsController for items list 2022-01-19 19:09:54 -05:00
Shadowfacts ed0a2f1ba3 Fix remotely deleted feeds not being removed 2022-01-19 18:30:49 -05:00
Shadowfacts c89df7604b Fix updates merging changes into wrong coredata context 2022-01-19 17:40:42 -05:00
Shadowfacts 53d4b0bae8 Remove unused var from build script 2022-01-19 17:40:28 -05:00
Shadowfacts dbfc76fc6a only embed mac plugin on mac 2022-01-16 15:20:05 -05:00
Shadowfacts b8a415b6fd Add AppKit bundle, fix light appearance 2022-01-16 14:40:48 -05:00
Shadowfacts 75be4141dd Add preferences 2022-01-16 14:40:48 -05:00
Shadowfacts df00108dae Add separator to accounts menu 2022-01-16 14:40:48 -05:00
Shadowfacts 0450fe2c0e Increase max sidebar width 2022-01-16 14:40:48 -05:00
Shadowfacts be579b849d Universal catalyst builds 2022-01-16 14:40:48 -05:00
Shadowfacts c276bbdea6 Fix monospace text 2022-01-15 19:46:06 -05:00
Shadowfacts 6fbda7dc78 Fix excerpt generator getting confused by massive img srcs 2022-01-15 19:31:04 -05:00
Shadowfacts 1726a7c711 Allow navigating to the same url with a different fragment 2022-01-15 15:13:16 -05:00
Shadowfacts 36fda4d51f More catalyst toolbar stuff 2022-01-15 14:49:29 -05:00
Shadowfacts 1d2e666c00 Mark items read on the server immediately instead of waiting for sync 2022-01-15 14:22:13 -05:00
Shadowfacts 0be678063b More catalyst stuff 2022-01-15 14:09:30 -05:00
Shadowfacts 949f2bca01 Swizzle WKWebView to fix scroll indicator in dark mode 2022-01-14 18:59:51 -05:00
Shadowfacts c3d0174f23 Enable Catalyst 2022-01-14 18:55:58 -05:00
Shadowfacts dbd274f57c Fix inconsistent separator insets on home screen 2022-01-14 18:17:00 -05:00
Shadowfacts 4b0bda88b8 Prevent tables from overflowing 2022-01-14 16:26:28 -05:00
Shadowfacts 83c3bc927e Improve highlight animation for item cells 2022-01-14 16:11:46 -05:00
Shadowfacts f8026125cc Generate item excerpts in the background, use lol-html 2022-01-14 16:11:46 -05:00
Shadowfacts 30ec5e54e0 Use old-style collection view datasource so we can use fetchBatchSize to
avoid loading every single item into memory
2022-01-14 16:11:46 -05:00
Shadowfacts a368bc4365 Fix Fervor item schema 2022-01-12 21:53:00 -05:00
Shadowfacts be788bd0a6 Prevent sync errors from crashing the app 2022-01-12 21:53:00 -05:00
Shadowfacts 9deafa4b33 Multiple accounts 2022-01-12 21:53:00 -05:00
Shadowfacts 4c4044c382 Better login error handling 2022-01-12 13:30:49 -05:00
Shadowfacts b2a8174099 Add context menu actions to item list 2022-01-12 12:56:02 -05:00
Shadowfacts b2b99c6a11 Fix crash when login scene resigns 2022-01-12 12:48:23 -05:00
Shadowfacts 503d35f301 Add stretchy menus 2022-01-12 11:36:12 -05:00
Shadowfacts 8e6bf219c8 Fix opening safari vc on iframe load 2022-01-12 11:26:24 -05:00
Shadowfacts 415340882e Set safari VC tint color 2022-01-12 10:56:11 -05:00
Shadowfacts e1296223fe Prevent web view flashing white when in loading in dark mode 2022-01-11 14:58:23 -05:00
Shadowfacts 85819ea6aa Use correct base URL for web view content 2022-01-11 14:40:17 -05:00
Shadowfacts 755f98a2e2 Add blockquote style 2022-01-11 14:34:36 -05:00
Shadowfacts 703b936676 Add haptic feedback to cell selection 2022-01-11 14:29:33 -05:00
Shadowfacts c9b12a6b70 Update unread counts when returning to home 2022-01-11 14:23:19 -05:00
Shadowfacts 12e0e3cdfd Sync item read state to server 2022-01-11 14:09:55 -05:00
Shadowfacts 3d07069ee5 Mark items read on selection 2022-01-11 11:42:41 -05:00
Shadowfacts a1cf4a5789 More context menus 2022-01-11 11:37:41 -05:00
Shadowfacts 736e8283e1 Mark items as read 2022-01-10 23:32:13 -05:00
Shadowfacts 96255b2a1f Read view link handling 2022-01-10 22:34:29 -05:00
Shadowfacts b4d288bd29 Remove unused template code 2022-01-10 22:24:31 -05:00
Shadowfacts ba186fd1b2 Remove debug code 2022-01-10 22:24:31 -05:00
Shadowfacts 220fbf7b75 Custom nav controller 2022-01-10 22:24:31 -05:00
Shadowfacts f53f198071 Add read view 2022-01-09 23:38:44 -05:00
Shadowfacts dab4d6075a Cosmetic changes 2022-01-09 19:23:22 -05:00
Shadowfacts 61f073109c Fix CoreData concurrency things 2022-01-09 18:46:42 -05:00
Shadowfacts dd71c06257 Add item cell 2022-01-09 18:34:17 -05:00
Shadowfacts 8d385e61f5 Add counts to home cells 2022-01-09 18:01:19 -05:00
Shadowfacts eded49b266 Use manual codegen for CoreData models 2022-01-09 17:25:54 -05:00
Shadowfacts 2b38b883fe Sync and show items 2022-01-09 17:13:30 -05:00
Shadowfacts 8acc303a80 Display groups and feeds 2022-01-09 11:17:36 -05:00
Shadowfacts 3ca42e9916 Sync groups and feeds 2021-12-25 14:04:45 -05:00
46 changed files with 3716 additions and 111 deletions

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "lol-html"]
path = lol-html
url = https://github.com/cloudflare/lol-html.git

View File

@ -10,7 +10,7 @@ import Foundation
public struct Feed: Decodable {
public let id: FervorID
public let title: String
public let url: URL
public let url: URL?
public let serviceURL: URL?
public let feedURL: URL
public let lastUpdated: Date
@ -21,7 +21,7 @@ public struct Feed: Decodable {
self.id = try container.decode(FervorID.self, forKey: .id)
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.feedURL = try container.decode(URL.self, forKey: .feedURL)
self.lastUpdated = try container.decode(Date.self, forKey: .lastUpdated)

View File

@ -8,4 +8,4 @@
import Foundation
// todo: fervor: ids should be strings
public typealias FervorID = Int
public typealias FervorID = String

View File

@ -13,7 +13,26 @@ public class FervorClient {
let session: URLSession
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) {
self.instanceURL = instanceURL
@ -21,9 +40,10 @@ public class FervorClient {
self.session = session
}
private func buildURL(path: String) -> URL {
private func buildURL(path: String, queryItems: [URLQueryItem] = []) -> URL {
var components = URLComponents(url: instanceURL, resolvingAgainstBaseURL: false)!
components.path = path
components.queryItems = queryItems
return components.url!
}
@ -32,7 +52,10 @@ public class FervorClient {
if let accessToken = accessToken {
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)
return decoded
}
@ -61,6 +84,53 @@ public class FervorClient {
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 let accessToken: String
public let refreshToken: String?
@ -69,6 +139,11 @@ public class FervorClient {
public enum Error: Swift.Error {
case urlSession(Swift.Error)
case decode(Swift.Error)
case notFound
}
}
private struct DateDecodingError: Error {
}

View File

@ -8,25 +8,25 @@
import Foundation
public struct Item: Decodable {
let id: FervorID
let feedID: FervorID
let title: String
let author: String
let published: Date?
let createdAt: Date?
let content: String?
let summary: String?
let url: URL
let serviceURL: URL?
let read: Bool?
public let id: FervorID
public let feedID: FervorID
public let title: String?
public let author: String?
public let published: Date?
public let createdAt: Date?
public let content: String?
public let summary: String?
public let url: URL
public let serviceURL: URL?
public let read: Bool?
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(FervorID.self, forKey: .id)
self.feedID = try container.decode(FervorID.self, forKey: .feedID)
self.title = try container.decode(String.self, forKey: .title)
self.author = try container.decode(String.self, forKey: .author)
self.title = try container.decodeIfPresent(String.self, forKey: .title)
self.author = try container.decodeIfPresent(String.self, forKey: .author)
self.published = try container.decodeIfPresent(Date.self, forKey: .published)
self.createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt)
self.content = try container.decodeIfPresent(String.self, forKey: .content)

View File

@ -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
}
}

11
README.md Normal file
View File

@ -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.

View File

@ -15,6 +15,16 @@
D65B18BC27504FE7004A9448 /* Token.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B18BB27504FE7004A9448 /* Token.swift */; };
D65B18BE275051A1004A9448 /* LocalData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B18BD275051A1004A9448 /* LocalData.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 */; };
D6C687EE272CD27600874C10 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C687ED272CD27600874C10 /* SceneDelegate.swift */; };
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 */; };
D6C68856272CD7C600874C10 /* Item.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C68855272CD7C600874C10 /* Item.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 */
/* Begin PBXContainerItemProxy section */
D6840915279487DC00E327D2 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = D6C687E0272CD27600874C10 /* Project object */;
proxyType = 1;
remoteGlobalIDString = D684090C279486BF00E327D2;
remoteInfo = ReaderMac;
};
D6C68802272CD27700874C10 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = D6C687E0272CD27600874C10 /* Project object */;
@ -58,6 +91,17 @@
/* End PBXContainerItemProxy 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 */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
@ -80,6 +124,19 @@
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>"; };
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; };
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>"; };
@ -99,14 +156,37 @@
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>"; };
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 */
/* Begin PBXFrameworksBuildPhase section */
D684090A279486BF00E327D2 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
D6C687E5272CD27600874C10 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
D6C68829272CD2BA00874C10 /* Fervor.framework in Frameworks */,
D6E24371278BE1250005E546 /* HTMLEntities in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -137,8 +217,13 @@
D65B18AF2750468B004A9448 /* Screens */ = {
isa = PBXGroup;
children = (
D6EB531C278C89C300AD2E61 /* AppNavigationController.swift */,
D68B303F2792729A00E8B3FA /* AppSplitViewController.swift */,
D65B18BF2750533E004A9448 /* Home */,
D65B18B027504691004A9448 /* Login */,
D6E2434A278B455C0005E546 /* Items */,
D6E2436C278BD80B0005E546 /* Read */,
D68408E927947E3800E327D2 /* Preferences */,
);
path = Screens;
sourceTree = "<group>";
@ -155,18 +240,60 @@
isa = PBXGroup;
children = (
D65B18C027505348004A9448 /* HomeViewController.swift */,
D6E2436A278BB1880005E546 /* HomeCollectionViewCell.swift */,
);
path = Home;
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 = {
isa = PBXGroup;
children = (
D6C687EA272CD27600874C10 /* Reader */,
D6C68804272CD27700874C10 /* ReaderTests */,
D6C6880E272CD27700874C10 /* ReaderUITests */,
D6840911279486C400E327D2 /* ReaderMac */,
D6C68824272CD2BA00874C10 /* Fervor */,
D6C687E9272CD27600874C10 /* Products */,
D68B302E278FDCE200E8B3FA /* Frameworks */,
);
sourceTree = "<group>";
};
@ -177,6 +304,7 @@
D6C68801272CD27700874C10 /* ReaderTests.xctest */,
D6C6880B272CD27700874C10 /* ReaderUITests.xctest */,
D6C68823272CD2BA00874C10 /* Fervor.framework */,
D684090D279486BF00E327D2 /* ReaderMac.bundle */,
);
name = Products;
sourceTree = "<group>";
@ -184,15 +312,25 @@
D6C687EA272CD27600874C10 /* Reader */ = {
isa = PBXGroup;
children = (
D68B303E27923C0000E8B3FA /* Reader.entitlements */,
D68B3032278FDD1A00E8B3FA /* Reader-Bridging-Header.h */,
D6C687EB272CD27600874C10 /* AppDelegate.swift */,
D6C687ED272CD27600874C10 /* SceneDelegate.swift */,
D68408E727947D0800E327D2 /* PrefsSceneDelegate.swift */,
D65B18B527504920004A9448 /* FervorController.swift */,
D65B18BD275051A1004A9448 /* LocalData.swift */,
D6E24368278BABB40005E546 /* UIColor+App.swift */,
D6EB531E278E4A7500AD2E61 /* StretchyMenuInteraction.swift */,
D68B303527907D9200E8B3FA /* ExcerptGenerator.swift */,
D68B304127932ED500E8B3FA /* UserActivities.swift */,
D68408EE2794808E00E327D2 /* Preferences.swift */,
D6A8A33527766E9300CCEC72 /* CoreData */,
D65B18AF2750468B004A9448 /* Screens */,
D6C687F7272CD27700874C10 /* Assets.xcassets */,
D6C687F9272CD27700874C10 /* LaunchScreen.storyboard */,
D6C687FC272CD27700874C10 /* Info.plist */,
D6C687F4272CD27600874C10 /* Reader.xcdatamodeld */,
D6E24372278BE2B80005E546 /* read.css */,
D68B303C2792204B00E8B3FA /* read.js */,
);
path = Reader;
sourceTree = "<group>";
@ -226,11 +364,29 @@
D6C68857272CD8CD00874C10 /* Group.swift */,
D6C6882F272CD2CF00874C10 /* Instance.swift */,
D6C68855272CD7C600874C10 /* Item.swift */,
D6E24351278B6DF90005E546 /* ItemsSyncUpdate.swift */,
D65B18BB27504FE7004A9448 /* Token.swift */,
);
path = Fervor;
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 */
/* Begin PBXHeadersBuildPhase section */
@ -245,21 +401,44 @@
/* End PBXHeadersBuildPhase 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 */ = {
isa = PBXNativeTarget;
buildConfigurationList = D6C68815272CD27700874C10 /* Build configuration list for PBXNativeTarget "Reader" */;
buildPhases = (
D68B303B2791D2A900E8B3FA /* Compile lol-html c-api */,
D6C687E4272CD27600874C10 /* Sources */,
D6C687E5272CD27600874C10 /* Frameworks */,
D6C687E6272CD27600874C10 /* Resources */,
D6C6882E272CD2BA00874C10 /* Embed Frameworks */,
D6840917279487DD00E327D2 /* Embed PlugIns */,
);
buildRules = (
);
dependencies = (
D6C68828272CD2BA00874C10 /* PBXTargetDependency */,
D6840916279487DC00E327D2 /* PBXTargetDependency */,
);
name = Reader;
packageProductDependencies = (
D6E24370278BE1250005E546 /* HTMLEntities */,
);
productName = Reader;
productReference = D6C687E8272CD27600874C10 /* Reader.app */;
productType = "com.apple.product-type.application";
@ -328,6 +507,10 @@
LastSwiftUpdateCheck = 1320;
LastUpgradeCheck = 1320;
TargetAttributes = {
D684090C279486BF00E327D2 = {
CreatedOnToolsVersion = 13.2;
LastSwiftMigration = 1320;
};
D6C687E7272CD27600874C10 = {
CreatedOnToolsVersion = 13.2;
};
@ -354,6 +537,9 @@
Base,
);
mainGroup = D6C687DF272CD27600874C10;
packageReferences = (
D6E2436F278BE1250005E546 /* XCRemoteSwiftPackageReference "swift-html-entities" */,
);
productRefGroup = D6C687E9272CD27600874C10 /* Products */;
projectDirPath = "";
projectRoot = "";
@ -362,17 +548,27 @@
D6C68800272CD27700874C10 /* ReaderTests */,
D6C6880A272CD27700874C10 /* ReaderUITests */,
D6C68822272CD2BA00874C10 /* Fervor */,
D684090C279486BF00E327D2 /* ReaderMac */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
D684090B279486BF00E327D2 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
D6C687E6272CD27600874C10 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D6C687FB272CD27700874C10 /* LaunchScreen.storyboard in Resources */,
D6C687F8272CD27700874C10 /* Assets.xcassets in Resources */,
D68B303D2792204B00E8B3FA /* read.js in Resources */,
D6E24373278BE2B80005E546 /* read.css in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -399,16 +595,66 @@
};
/* 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 */
D6840909279486BF00E327D2 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D68409132794870000E327D2 /* ReaderMac.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
D6C687E4272CD27600874C10 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D6A8A33427766C2800CCEC72 /* PersistentContainer.swift in Sources */,
D6E24357278B96E40005E546 /* Feed+CoreDataClass.swift in Sources */,
D65B18B627504920004A9448 /* FervorController.swift in Sources */,
D68B304227932ED500E8B3FA /* UserActivities.swift in Sources */,
D6C687EC272CD27600874C10 /* AppDelegate.swift 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 */,
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 */,
D6C687EE272CD27600874C10 /* SceneDelegate.swift in Sources */,
);
@ -438,6 +684,7 @@
D65B18BC27504FE7004A9448 /* Token.swift in Sources */,
D6C68856272CD7C600874C10 /* Item.swift in Sources */,
D6C68830272CD2CF00874C10 /* Instance.swift in Sources */,
D6E24352278B6DF90005E546 /* ItemsSyncUpdate.swift in Sources */,
D6C68832272CD40600874C10 /* Feed.swift in Sources */,
D6C68858272CD8CD00874C10 /* Group.swift in Sources */,
D6C68834272CD44900874C10 /* Fervor.swift in Sources */,
@ -450,6 +697,12 @@
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
D6840916279487DC00E327D2 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
platformFilter = maccatalyst;
target = D684090C279486BF00E327D2 /* ReaderMac */;
targetProxy = D6840915279487DC00E327D2 /* PBXContainerItemProxy */;
};
D6C68803272CD27700874C10 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = D6C687E7272CD27600874C10 /* Reader */;
@ -479,6 +732,65 @@
/* End PBXVariantGroup 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 */ = {
isa = XCBuildConfiguration;
buildSettings = {
@ -530,6 +842,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
HEADER_SEARCH_PATHS = "";
IPHONEOS_DEPLOYMENT_TARGET = 15.2;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
@ -537,6 +850,7 @@
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_OPTIMIZE_OBJECT_LIFETIME = YES;
};
name = Debug;
};
@ -585,12 +899,14 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
HEADER_SEARCH_PATHS = "";
IPHONEOS_DEPLOYMENT_TARGET = 15.2;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_OPTIMIZE_OBJECT_LIFETIME = YES;
VALIDATE_PRODUCT = YES;
};
name = Release;
@ -601,10 +917,13 @@
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Reader/Reader.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = ZPBBSK8L8B;
EXCLUDED_ARCHS = "";
GENERATE_INFOPLIST_FILE = YES;
HEADER_SEARCH_PATHS = "$(PROJECT_DIR)/lol-html/c-api/include/";
INFOPLIST_FILE = Reader/Info.plist;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
@ -615,12 +934,21 @@
"$(inherited)",
"@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;
OTHER_LDFLAGS = "-llolhtml";
PRODUCT_BUNDLE_IDENTIFIER = net.shadowfacts.Reader;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTS_MACCATALYST = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OBJC_BRIDGING_HEADER = "Reader/Reader-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TARGETED_DEVICE_FAMILY = "1,2,6";
};
name = Debug;
};
@ -630,10 +958,13 @@
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Reader/Reader.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = ZPBBSK8L8B;
EXCLUDED_ARCHS = "";
GENERATE_INFOPLIST_FILE = YES;
HEADER_SEARCH_PATHS = "$(PROJECT_DIR)/lol-html/c-api/include/";
INFOPLIST_FILE = Reader/Info.plist;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
@ -644,12 +975,21 @@
"$(inherited)",
"@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;
OTHER_LDFLAGS = "-llolhtml";
PRODUCT_BUNDLE_IDENTIFIER = net.shadowfacts.Reader;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTS_MACCATALYST = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OBJC_BRIDGING_HEADER = "Reader/Reader-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TARGETED_DEVICE_FAMILY = "1,2,6";
};
name = Release;
};
@ -662,6 +1002,7 @@
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = ZPBBSK8L8B;
GENERATE_INFOPLIST_FILE = YES;
HEADER_SEARCH_PATHS = "$(PROJECT_DIR)/lol-html/c-api/include/";
IPHONEOS_DEPLOYMENT_TARGET = 15.2;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = net.shadowfacts.ReaderTests;
@ -682,6 +1023,7 @@
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = ZPBBSK8L8B;
GENERATE_INFOPLIST_FILE = YES;
HEADER_SEARCH_PATHS = "$(PROJECT_DIR)/lol-html/c-api/include/";
IPHONEOS_DEPLOYMENT_TARGET = 15.2;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = net.shadowfacts.ReaderTests;
@ -797,6 +1139,15 @@
/* End XCBuildConfiguration 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" */ = {
isa = XCConfigurationList;
buildConfigurations = (
@ -844,6 +1195,25 @@
};
/* 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 */
D6C687F4272CD27600874C10 /* Reader.xcdatamodeld */ = {
isa = XCVersionGroup;

View File

@ -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>

View File

@ -6,24 +6,63 @@
//
import UIKit
import CoreData
import WebKit
import OSLog
import Combine
@main
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 {
// 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
}
// MARK: UISceneSession Lifecycle
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
// Called when a new scene session is being created.
// Use this method to select a configuration to create the new scene with.
return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
let name: String
#if targetEnvironment(macCatalyst)
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>) {
@ -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.
// Use this method to release any resources that were specific to the discarded scenes, as they will not return.
}
// MARK: - Core Data stack
lazy var persistentContainer: NSPersistentContainer = {
/*
The persistent container for the application. This implementation
creates and returns a container, having loaded the store for the
application to it. This property is optional since there are legitimate
error conditions that could cause the creation of the store to fail.
*/
let container = NSPersistentContainer(name: "Reader")
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
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.
/*
Typical reasons for an error here include:
* The parent directory does not exist, cannot be created, or disallows writing.
* The persistent store is not accessible, due to permissions or data protection when the device is locked.
* The device is out of space.
* The store could not be migrated to the current model version.
Check the error message to determine what the actual problem was.
*/
fatalError("Unresolved error \(error), \(error.userInfo)")
}
})
return container
}()
// 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)")
override func buildMenu(with builder: UIMenuBuilder) {
if builder.system == .main {
builder.insertSibling(UIMenu(options: .displayInline, children: [
UIKeyCommand(title: "Preferences…", action: #selector(showPreferences), input: ",", modifierFlags: .command)
]), afterMenu: .about)
var children = [UIMenuElement]()
let accounts: [UIMenuElement] = LocalData.accounts.map { account in
var title = account.instanceURL.host!
if let port = account.instanceURL.port, port != 80 && port != 443 {
title += ":\(port)"
}
let state: UIAction.State
if let activeScene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }),
let sceneDelegate = activeScene.delegate as? SceneDelegate,
sceneDelegate.fervorController?.account?.id == account.id {
state = .on
} else {
state = .off
}
return UIAction(title: title, attributes: [], state: state) { _ in
let activity = NSUserActivity.activateAccount(account)
let options = UIScene.ActivationRequestOptions()
#if targetEnvironment(macCatalyst)
options.collectionJoinBehavior = .disallowed
#endif
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: activity, options: options, errorHandler: nil)
}
}
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
}
}

View File

@ -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))
}
}

View File

@ -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 {
}

View File

@ -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
}
}

View File

@ -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 {
}

View File

@ -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!
}
}
}

View File

@ -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 {
}

View File

@ -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()
}
}

View File

@ -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>

View File

@ -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
}

View File

@ -7,6 +7,7 @@
import Foundation
import Fervor
import OSLog
class FervorController {
@ -14,11 +15,16 @@ class FervorController {
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 clientSecret: String?
private(set) var accessToken: String?
private(set) var persistentContainer: PersistentContainer!
init(instanceURL: URL) {
self.instanceURL = instanceURL
self.client = FervorClient(instanceURL: instanceURL, accessToken: nil)
@ -26,9 +32,14 @@ class FervorController {
convenience init(account: LocalData.Account) {
self.init(instanceURL: account.instanceURL)
self.account = account
self.clientID = account.clientID
self.clientSecret = account.clientSecret
self.accessToken = account.accessToken
self.client.accessToken = account.accessToken
self.persistentContainer = PersistentContainer(account: account, fervorController: self)
}
func register() async throws -> ClientRegistration {
@ -44,4 +55,80 @@ class FervorController {
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)")
}
}
}
}

View File

@ -2,20 +2,32 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<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>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<true/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneConfigurationName</key>
<string>Default Configuration</string>
<string>main</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
</dict>
<dict>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).PrefsSceneDelegate</string>
<key>UISceneConfigurationName</key>
<string>prefs</string>
</dict>
</array>
</dict>
</dict>

View File

@ -14,25 +14,54 @@ struct LocalData {
private static let encoder = JSONEncoder()
private static let decoder = JSONDecoder()
static var account: Account? {
static var accounts: [Account] {
get {
guard let data = UserDefaults.standard.data(forKey: "account") else {
return nil
guard let data = UserDefaults.standard.data(forKey: "accounts"),
let accounts = try? decoder.decode([Account].self, from: data) else {
return []
}
return try? decoder.decode(Account.self, from: data)
return accounts
}
set {
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 {
let id: UUID
let instanceURL: URL
let clientID: String
let clientSecret: String
let accessToken: String
// 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
}
}
}

69
Reader/Preferences.swift Normal file
View File

@ -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")
}

View File

@ -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

View File

@ -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 */

View File

@ -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>

View File

@ -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>

View File

@ -6,12 +6,16 @@
//
import UIKit
import OSLog
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
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) {
// 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 }
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)
createAppUI()
} else {
@ -30,7 +44,19 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
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()
NotificationCenter.default.addObserver(self, selector: #selector(updateAppearance), name: .appearanceChanged, object: nil)
updateAppearance()
}
func sceneDidDisconnect(_ scene: UIScene) {
@ -43,11 +69,25 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
func sceneDidBecomeActive(_ scene: UIScene) {
// 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.
UIMenuSystem.main.setNeedsRebuild()
syncFromServer()
}
func sceneWillResignActive(_ scene: UIScene) {
// 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).
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) {
@ -59,23 +99,91 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
// 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
// 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() {
let home = HomeViewController(fervorController: fervorController)
let nav = UINavigationController(rootViewController: home)
window!.rootViewController = nav
window!.rootViewController = AppSplitViewController(fervorController: fervorController)
}
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 {
func didLogin(with controller: FervorController) {
LocalData.account = .init(instanceURL: controller.instanceURL, clientID: controller.clientID!, clientSecret: controller.clientSecret!, accessToken: controller.accessToken!)
fervorController = controller
let account = LocalData.Account(instanceURL: controller.instanceURL, clientID: controller.clientID!, clientSecret: controller.clientSecret!, accessToken: controller.accessToken!)
LocalData.accounts.append(account)
LocalData.mostRecentAccountID = account.id
fervorController = FervorController(account: account)
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

View File

@ -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
}
}
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -6,11 +6,26 @@
//
import UIKit
import CoreData
protocol HomeViewControllerDelegate: AnyObject {
func switchToAccount(_ account: LocalData.Account)
}
class HomeViewController: UIViewController {
weak var delegate: HomeViewControllerDelegate?
weak var itemsDelegate: ItemsViewControllerDelegate?
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) {
self.fervorController = fervorController
@ -24,16 +39,271 @@ class HomeViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
// todo: account info
title = "Reader"
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.text = "Logged in to \(fervorController.instanceURL.host!)"
view.addSubview(label)
NSLayoutConstraint.activate([
label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
label.centerYAnchor.constraint(equalTo: view.centerYAnchor),
])
if enableStretchyMenu {
view.addInteraction(StretchyMenuInteraction(delegate: self))
}
if UIDevice.current.userInterfaceIdiom != .mac {
view.backgroundColor = .appBackground
}
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)
}
}

View File

@ -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
}
}
}
}

View File

@ -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)
}
}
}

View File

@ -9,6 +9,7 @@ import UIKit
import AuthenticationServices
import Fervor
@MainActor
protocol LoginViewControllerDelegate: AnyObject {
func didLogin(with controller: FervorController)
}
@ -18,6 +19,7 @@ class LoginViewController: UIViewController {
weak var delegate: LoginViewControllerDelegate?
private var textField: UITextField!
private var activityIndicator: UIActivityIndicatorView!
private var authSession: ASWebAuthenticationSession?
@ -37,10 +39,18 @@ class LoginViewController: UIViewController {
textField.placeholder = "example.com"
textField.addTarget(self, action: #selector(doEnteredURL), for: .primaryActionTriggered)
view.addSubview(textField)
activityIndicator = UIActivityIndicatorView(style: .medium)
activityIndicator.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(activityIndicator)
NSLayoutConstraint.activate([
textField.centerYAnchor.constraint(equalTo: view.centerYAnchor),
textField.centerXAnchor.constraint(equalTo: view.centerXAnchor),
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
}
textField.isEnabled = false
activityIndicator.startAnimating()
let controller = FervorController(instanceURL: components.url!)
let registration: ClientRegistration
do {
registration = try await controller.register()
} 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)
textField.isEnabled = true
activityIndicator.stopAnimating()
return
}
@ -81,13 +99,29 @@ class LoginViewController: UIViewController {
let components = URLComponents(url: callbackURL!, resolvingAgainstBaseURL: false)
guard let codeItem = components?.queryItems?.first(where: { $0.name == "code" }),
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 {
try! await controller.getToken(authCode: codeValue)
DispatchQueue.main.async {
self.delegate?.didLogin(with: controller)
Task { @MainActor in
do {
try await controller.getToken(authCode: codeValue)
} 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)
}
}

View File

@ -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()
}
}

View File

@ -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
}
}

View File

@ -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),
])
}
}
}

58
Reader/UIColor+App.swift Normal file
View File

@ -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
}

View File

@ -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
}
}

106
Reader/read.css Normal file
View File

@ -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);
}
}

6
Reader/read.js Normal file
View File

@ -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>`;
}
});

40
ReaderMac/ReaderMac.swift Normal file
View File

@ -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

1
lol-html Submodule

@ -0,0 +1 @@
Subproject commit f32bd14b229ed1088c25725cce242817ea2fe43a