Replace RSCore with several local modules. Update code as needed.

This commit is contained in:
Brent Simmons 2024-03-20 20:49:15 -07:00
parent d0760f3d12
commit 2461e937bf
240 changed files with 6052 additions and 384 deletions

View File

@ -11,27 +11,29 @@ let package = Package(
targets: ["Account"]),
],
dependencies: [
.package(url: "https://github.com/Ranchero-Software/RSCore.git", .upToNextMinor(from: "1.0.0")),
.package(url: "https://github.com/Ranchero-Software/RSParser.git", .upToNextMajor(from: "2.0.2")),
.package(url: "https://github.com/Ranchero-Software/RSWeb.git", .upToNextMajor(from: "1.0.0")),
.package(path: "../Articles"),
.package(path: "../ArticlesDatabase"),
.package(path: "../Secrets"),
.package(path: "../Database"),
.package(path: "../SyncDatabase")
.package(path: "../SyncDatabase"),
.package(path: "../Core"),
.package(path: "../CloudKitExtras")
],
targets: [
.target(
name: "Account",
dependencies: [
"RSCore",
"RSParser",
"RSWeb",
"Articles",
"ArticlesDatabase",
"Secrets",
"SyncDatabase",
"Database"
"Database",
"Core",
"CloudKitExtras"
]),
.testTarget(
name: "AccountTests",

View File

@ -11,7 +11,6 @@ import UIKit
#endif
import Foundation
import RSCore
import Articles
import RSParser
import Database
@ -19,6 +18,7 @@ import ArticlesDatabase
import RSWeb
import os.log
import Secrets
import Core
// Main thread only.

View File

@ -7,7 +7,6 @@
//
import Foundation
import RSCore
import RSWeb
import Articles
import ArticlesDatabase

View File

@ -8,7 +8,7 @@
import Foundation
import os.log
import RSCore
import Core
final class AccountMetadataFile {

View File

@ -11,12 +11,13 @@ import CloudKit
import SystemConfiguration
import os.log
import SyncDatabase
import RSCore
import RSParser
import Articles
import ArticlesDatabase
import RSWeb
import Secrets
import Core
import CloudKitExtras
enum CloudKitAccountDelegateError: LocalizedError {
case invalidParameter

View File

@ -8,10 +8,11 @@
import Foundation
import os.log
import RSCore
import RSWeb
import RSParser
import CloudKit
import FoundationExtras
import CloudKitExtras
enum CloudKitAccountZoneError: LocalizedError {
case unknown

View File

@ -10,8 +10,8 @@ import Foundation
import os.log
import RSWeb
import CloudKit
import RSCore
import Articles
import CloudKitExtras
class CloudKitAcountZoneDelegate: CloudKitZoneDelegate {

View File

@ -8,12 +8,12 @@
import Foundation
import os.log
import RSCore
import RSParser
import RSWeb
import CloudKit
import Articles
import SyncDatabase
import CloudKitExtras
final class CloudKitArticlesZone: CloudKitZone {

View File

@ -8,7 +8,6 @@
import Foundation
import os.log
import RSCore
import RSParser
import RSWeb
import CloudKit
@ -16,6 +15,7 @@ import SyncDatabase
import Articles
import ArticlesDatabase
import Database
import CloudKitExtras
class CloudKitArticlesZoneDelegate: CloudKitZoneDelegate {

View File

@ -8,7 +8,7 @@
import Foundation
import os.log
import RSCore
import Core
class CloudKitReceiveStatusOperation: MainThreadOperation {

View File

@ -7,9 +7,8 @@
//
import Foundation
import os.log
import RSCore
import Core
class CloudKitRemoteNotificationOperation: MainThreadOperation {

View File

@ -9,10 +9,11 @@
import Foundation
import Articles
import os.log
import RSCore
import RSWeb
import SyncDatabase
import Database
import Core
import CloudKitExtras
class CloudKitSendStatusOperation: MainThreadOperation {

View File

@ -8,7 +8,6 @@
//
import Foundation
import RSCore
import Articles
extension Notification.Name {

View File

@ -7,9 +7,9 @@
//
import Foundation
import RSCore
import RSWeb
import Articles
import Core
public final class Feed: Renamable, DisplayNameProvider, UnreadCountProvider, Hashable {

View File

@ -9,7 +9,6 @@
import Foundation
import RSParser
import RSWeb
import RSCore
class FeedFinder {

View File

@ -8,7 +8,7 @@
import Foundation
import os.log
import RSCore
import Core
final class FeedMetadataFile {

View File

@ -7,13 +7,13 @@
//
import Articles
import RSCore
import Database
import RSParser
import RSWeb
import SyncDatabase
import os.log
import Secrets
import Core
public enum FeedbinAccountDelegateError: String, Error {
case invalidParameter = "There was an invalid parameter passed."

View File

@ -8,7 +8,6 @@
import Foundation
import RSParser
import RSCore
final class FeedbinEntry: Decodable {

View File

@ -7,7 +7,6 @@
//
import Foundation
import RSCore
import RSParser
struct FeedbinSubscription: Hashable, Codable {

View File

@ -7,7 +7,6 @@
//
import Foundation
import RSCore
import RSWeb
import Secrets

View File

@ -7,12 +7,12 @@
//
import Articles
import RSCore
import RSParser
import RSWeb
import SyncDatabase
import os.log
import Secrets
import Core
final class FeedlyAccountDelegate: AccountDelegate {

View File

@ -8,8 +8,8 @@
import Foundation
import AuthenticationServices
import RSCore
import Secrets
import Core
public protocol OAuthAccountAuthorizationOperationDelegate: AnyObject {
func oauthAccountAuthorizationOperation(_ operation: OAuthAccountAuthorizationOperation, didCreate account: Account)

View File

@ -9,8 +9,8 @@
import Foundation
import os.log
import RSWeb
import RSCore
import Secrets
import Core
class FeedlyAddExistingFeedOperation: FeedlyOperation, FeedlyOperationDelegate, FeedlyCheckpointOperationDelegate {

View File

@ -10,8 +10,8 @@ import Foundation
import os.log
import SyncDatabase
import RSWeb
import RSCore
import Secrets
import Core
class FeedlyAddNewFeedOperation: FeedlyOperation, FeedlyOperationDelegate, FeedlySearchOperationDelegate, FeedlyCheckpointOperationDelegate {

View File

@ -8,6 +8,7 @@
import Foundation
import os.log
import Core
/// Single responsibility is to accurately reflect Collections and their Feeds as Folders and their Feeds.
final class FeedlyCreateFeedsForCollectionFoldersOperation: FeedlyOperation {

View File

@ -8,8 +8,8 @@
import Foundation
import os.log
import RSCore
import RSWeb
import Core
class FeedlyDownloadArticlesOperation: FeedlyOperation {

View File

@ -8,7 +8,7 @@
import Foundation
import RSWeb
import RSCore
import Core
protocol FeedlyOperationDelegate: AnyObject {
func feedlyOperation(_ operation: FeedlyOperation, didFailWith error: Error)

View File

@ -10,8 +10,8 @@ import Foundation
import os.log
import SyncDatabase
import RSWeb
import RSCore
import Secrets
import Core
/// Compose the operations necessary to get the entire set of articles, feeds and folders with the statuses the user expects between now and a certain date in the past.
final class FeedlySyncAllOperation: FeedlyOperation {

View File

@ -9,9 +9,9 @@
import Foundation
import os.log
import RSParser
import RSCore
import RSWeb
import Secrets
import Core
final class FeedlySyncStreamContentsOperation: FeedlyOperation, FeedlyOperationDelegate, FeedlyGetStreamContentsOperationDelegate, FeedlyCheckpointOperationDelegate {

View File

@ -8,7 +8,7 @@
import Foundation
import Articles
import RSCore
import Core
public final class Folder: Renamable, Container, DisplayNameProvider, UnreadCountProvider, Hashable {

View File

@ -8,12 +8,12 @@
import Foundation
import os.log
import RSCore
import RSParser
import Articles
import ArticlesDatabase
import RSWeb
import Secrets
import Core
public enum LocalAccountDelegateError: String, Error {
case invalidParameter = "An invalid parameter was used."

View File

@ -7,11 +7,11 @@
//
import Foundation
import RSCore
import RSParser
import RSWeb
import Articles
import ArticlesDatabase
import FoundationExtras
protocol LocalAccountRefresherDelegate {
func localAccountRefresher(_ refresher: LocalAccountRefresher, requestCompletedFor: Feed)

View File

@ -8,12 +8,12 @@
//
import Articles
import RSCore
import Database
import RSParser
import RSWeb
import SyncDatabase
import os.log
import Core
extension NewsBlurAccountDelegate {

View File

@ -7,7 +7,6 @@
//
import Foundation
import RSCore
import RSParser
typealias NewsBlurFolder = NewsBlurFeedsResponse.Folder

View File

@ -7,7 +7,6 @@
//
import Foundation
import RSCore
import RSParser
typealias NewsBlurStory = NewsBlurStoriesResponse.Story

View File

@ -7,7 +7,6 @@
//
import Foundation
import RSCore
import RSParser
typealias NewsBlurStoryHash = NewsBlurStoryHashesResponse.StoryHash

View File

@ -7,7 +7,6 @@
//
import Articles
import RSCore
import Database
import RSParser
import RSWeb

View File

@ -8,8 +8,8 @@
import Foundation
import os.log
import RSCore
import RSParser
import Core
final class OPMLFile {

View File

@ -7,13 +7,13 @@
//
import Articles
import RSCore
import RSParser
import RSWeb
import SyncDatabase
import os.log
import Secrets
import Database
import Core
public enum ReaderAPIAccountDelegateError: LocalizedError {
case unknown

View File

@ -8,7 +8,6 @@
import Foundation
import RSParser
import RSCore
struct ReaderAPIEntryWrapper: Codable {
let id: String

View File

@ -7,7 +7,6 @@
//
import Foundation
import RSCore
import RSParser
/*

View File

@ -8,7 +8,6 @@
import XCTest
@testable import Account
import RSCore
class FeedlyCheckpointOperationTests: XCTestCase {

View File

@ -8,7 +8,6 @@
import XCTest
@testable import Account
import RSCore
class FeedlyCreateFeedsForCollectionFoldersOperationTests: XCTestCase {

View File

@ -9,7 +9,6 @@
import XCTest
@testable import Account
import os.log
import RSCore
class FeedlyGetCollectionsOperationTests: XCTestCase {

View File

@ -8,7 +8,6 @@
import XCTest
@testable import Account
import RSCore
class FeedlyGetStreamContentsOperationTests: XCTestCase {

View File

@ -8,7 +8,6 @@
import XCTest
@testable import Account
import RSCore
class FeedlyGetStreamIdsOperationTests: XCTestCase {

View File

@ -8,7 +8,6 @@
import XCTest
@testable import Account
import RSCore
import Secrets
class FeedlyLogoutOperationTests: XCTestCase {

View File

@ -8,7 +8,6 @@
import XCTest
@testable import Account
import RSCore
class FeedlyMirrorCollectionsAsFoldersOperationTests: XCTestCase {

View File

@ -9,7 +9,6 @@
import XCTest
@testable import Account
import RSWeb
import RSCore
class FeedlyOperationTests: XCTestCase {

View File

@ -9,7 +9,6 @@
import XCTest
@testable import Account
import RSParser
import RSCore
class FeedlyOrganiseParsedItemsByFeedOperationTests: XCTestCase {

View File

@ -9,7 +9,6 @@
import XCTest
@testable import Account
import RSWeb
import RSCore
import Secrets
class FeedlyRefreshAccessTokenOperationTests: XCTestCase {

View File

@ -10,7 +10,6 @@ import XCTest
@testable import Account
import SyncDatabase
import Articles
import RSCore
class FeedlySendArticleStatusesOperationTests: XCTestCase {

View File

@ -8,7 +8,6 @@
import XCTest
@testable import Account
import RSCore
class FeedlySyncStreamContentsOperationTests: XCTestCase {

8
AppKitExtras/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
.DS_Store
/.build
/Packages
xcuserdata/
DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

View File

@ -0,0 +1,30 @@
// swift-tools-version: 5.10
import PackageDescription
let package = Package(
name: "AppKitExtras",
platforms: [.macOS(.v14), .iOS(.v17)],
products: [
.library(
name: "AppKitExtras",
targets: ["AppKitExtras"]),
],
dependencies: [
.package(path: "../FoundationExtras")
],
targets: [
.target(
name: "AppKitExtras",
dependencies: [
"FoundationExtras",
],
swiftSettings: [
.enableExperimentalFeature("StrictConcurrency")
]
),
.testTarget(
name: "AppKitExtrasTests",
dependencies: ["AppKitExtras"]),
]
)

View File

@ -0,0 +1,2 @@
// The Swift Programming Language
// https://docs.swift.org/swift-book

View File

@ -0,0 +1,37 @@
//
// FourCharCode.swift
// RSCore
//
// Created by Olof Hellman on 1/7/18.
// Copyright © 2018 Olof Hellman. All rights reserved.
//
import Foundation
public extension String {
/// Converts a string to a `FourCharCode`.
///
/// `FourCharCode` values like `OSType`, `DescType` or `AEKeyword` are really just
/// 4-byte values commonly represented as values like `'odoc'` where each byte is
/// represented as its ASCII character. This property turns a Swift string into
/// its `FourCharCode` equivalent, as Swift doesn't recognize `FourCharCode` types
/// natively just yet. With this extension, one can use `"odoc".fourCharCode`
/// where one would really want to use `'odoc'`.
var fourCharCode: FourCharCode {
precondition(count == 4)
var sum: UInt32 = 0
for scalar in self.unicodeScalars {
sum = (sum * 256) + scalar.value
}
return sum
}
}
public extension Int {
var fourCharCode: FourCharCode {
return UInt32(self)
}
}

View File

@ -0,0 +1,147 @@
//
// Keyboard.swift
// RSCore
//
// Created by Brent Simmons on 12/19/17.
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.
//
#if os(macOS)
import AppKit
// To get, for instance, the keyboard integer value for "\r": "\r".keyboardIntegerValue (returns 13)
public struct KeyboardConstant {
public static let lineFeedKey = "\n".keyboardIntegerValue
public static let returnKey = "\r".keyboardIntegerValue
public static let spaceKey = " ".keyboardIntegerValue
}
public extension String {
var keyboardIntegerValue: Int? {
if isEmpty {
return nil
}
let utf16String = utf16
let startIndex = utf16String.startIndex
if startIndex == utf16String.endIndex {
return nil
}
return Int(utf16String[startIndex])
}
}
@MainActor public struct KeyboardShortcut: Hashable {
public let key: KeyboardKey
public let actionString: String
public init?(dictionary: [String: Any]) {
guard let key = KeyboardKey(dictionary: dictionary) else {
return nil
}
guard let actionString = dictionary["action"] as? String else {
return nil
}
self.key = key
self.actionString = actionString
}
public func perform(with view: NSView) {
let action = NSSelectorFromString(actionString)
NSApplication.shared.sendAction(action, to: nil, from: view)
}
public static func findMatchingShortcut(in shortcuts: Set<KeyboardShortcut>, key: KeyboardKey) -> KeyboardShortcut? {
for shortcut in shortcuts {
if shortcut.key == key {
return shortcut
}
}
return nil
}
}
public struct KeyboardKey: Hashable, Sendable {
public let shiftKeyDown: Bool
public let optionKeyDown: Bool
public let commandKeyDown: Bool
public let controlKeyDown: Bool
public let integerValue: Int // unmodified character as Int
init(integerValue: Int, shiftKeyDown: Bool, optionKeyDown: Bool, commandKeyDown: Bool, controlKeyDown: Bool) {
self.integerValue = integerValue
self.shiftKeyDown = shiftKeyDown
self.optionKeyDown = optionKeyDown
self.commandKeyDown = commandKeyDown
self.controlKeyDown = controlKeyDown
}
static let deleteKeyCode = 127
public init(with event: NSEvent) {
let flags = event.modifierFlags
let shiftKeyDown = flags.contains(.shift)
let optionKeyDown = flags.contains(.option)
let commandKeyDown = flags.contains(.command)
let controlKeyDown = flags.contains(.control)
let integerValue = event.charactersIgnoringModifiers?.keyboardIntegerValue ?? 0
self.init(integerValue: integerValue, shiftKeyDown: shiftKeyDown, optionKeyDown: optionKeyDown, commandKeyDown: commandKeyDown, controlKeyDown: controlKeyDown)
}
public init?(dictionary: [String: Any]) {
guard let s = dictionary["key"] as? String else {
return nil
}
var integerValue = 0
switch(s) {
case "[space]":
integerValue = " ".keyboardIntegerValue!
case "[uparrow]":
integerValue = NSUpArrowFunctionKey
case "[downarrow]":
integerValue = NSDownArrowFunctionKey
case "[leftarrow]":
integerValue = NSLeftArrowFunctionKey
case "[rightarrow]":
integerValue = NSRightArrowFunctionKey
case "[return]":
integerValue = NSCarriageReturnCharacter
case "[enter]":
integerValue = NSEnterCharacter
case "[delete]":
integerValue = KeyboardKey.deleteKeyCode
case "[deletefunction]":
integerValue = NSDeleteFunctionKey
case "[tab]":
integerValue = NSTabCharacter
default:
guard let unwrappedIntegerValue = s.keyboardIntegerValue else {
return nil
}
integerValue = unwrappedIntegerValue
}
let shiftKeyDown = dictionary["shiftModifier"] as? Bool ?? false
let optionKeyDown = dictionary["optionModifier"] as? Bool ?? false
let commandKeyDown = dictionary["commandModifier"] as? Bool ?? false
let controlKeyDown = dictionary["controlModifier"] as? Bool ?? false
self.init(integerValue: integerValue, shiftKeyDown: shiftKeyDown, optionKeyDown: optionKeyDown, commandKeyDown: commandKeyDown, controlKeyDown: controlKeyDown)
}
}
#endif

View File

@ -0,0 +1,18 @@
//
// KeyboardDelegateProtocol.swift
// NetNewsWire
//
// Created by Brent Simmons on 10/11/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
#if os(macOS)
import AppKit
//let keypadEnter: unichar = 3
@objc public protocol KeyboardDelegate: AnyObject {
// Return true if handled.
@MainActor func keydown(_: NSEvent, in view: NSView) -> Bool
}
#endif

View File

@ -0,0 +1,24 @@
//
// NSAppearance+RSCore.swift
// RSCore
//
// Created by Daniel Jalkut on 8/28/18.
// Copyright © 2018 Ranchero Software, LLC. All rights reserved.
//
#if os(macOS)
import AppKit
extension NSAppearance {
@objc(rsIsDarkMode)
public var isDarkMode: Bool {
if #available(macOS 10.14, *) {
return self.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
}
else {
return false
}
}
}
#endif

View File

@ -0,0 +1,30 @@
//
// NSAppleEventDescriptor+RSCore.swift
// RSCore
//
// Created by Nate Weaver on 2020-01-02.
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
//
#if os(macOS)
import AppKit
public extension NSAppleEventDescriptor {
/// An NSAppleEventDescriptor describing a running application.
///
/// - Parameter runningApplication: A running application to associate with the descriptor.
///
/// - Returns: An instance of `NSAppleEventDescriptor` that refers to the running application,
/// or `nil` if the running application has no process ID.
convenience init?(runningApplication: NSRunningApplication) {
let pid = runningApplication.processIdentifier
if pid == -1 {
return nil
}
self.init(processIdentifier: pid)
}
}
#endif

View File

@ -0,0 +1,29 @@
//
// NSImage+RSCore.swift
// RSCore
//
// Created by Brent Simmons on 12/16/17.
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.
//
#if os(macOS)
import AppKit
public extension NSImage {
func tinted(with color: NSColor) -> NSImage {
let image = self.copy() as! NSImage
image.lockFocus()
color.set()
let rect = NSRect(x: 0, y: 0, width: image.size.width, height: image.size.height)
rect.fill(using: .sourceAtop)
image.unlockFocus()
image.isTemplate = false
return image
}
}
#endif

View File

@ -0,0 +1,31 @@
//
// NSMenu+Extensions.swift
// RSCore
//
// Created by Brent Simmons on 2/9/18.
// Copyright © 2018 Ranchero Software, LLC. All rights reserved.
//
#if os(macOS)
import AppKit
public extension NSMenu {
func takeItems(from menu: NSMenu) {
// The passed-in menu gets all its items removed.
let items = menu.items
menu.removeAllItems()
for menuItem in items {
addItem(menuItem)
}
}
/// Add a separator if there are multiple menu items and the last one is not a separator.
func addSeparatorIfNeeded() {
if items.count > 0 && !items.last!.isSeparatorItem {
addItem(NSMenuItem.separator())
}
}
}
#endif

View File

@ -0,0 +1,183 @@
//
// NSOutlineView+Extensions.swift
// RSCore
//
// Created by Brent Simmons on 9/6/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
#if os(macOS)
import AppKit
public extension NSOutlineView {
var selectedItems: [AnyObject] {
if selectionIsEmpty {
return [AnyObject]()
}
return selectedRowIndexes.compactMap { (oneIndex) -> AnyObject? in
return item(atRow: oneIndex) as AnyObject
}
}
var firstSelectedRow: Int? {
if selectionIsEmpty {
return nil
}
return selectedRowIndexes.first
}
var lastSelectedRow: Int? {
if selectionIsEmpty {
return nil
}
return selectedRowIndexes.last
}
@IBAction func selectPreviousRow(_ sender: Any?) {
guard var row = firstSelectedRow else {
return
}
if row < 1 {
return
}
while true {
row -= 1
if row < 0 {
return
}
if canSelect(row) {
selectRowAndScrollToVisible(row)
return
}
}
}
@IBAction func selectNextRow(_ sender: Any?) {
// If no selectedRow, end up at first selectable row.
var row = lastSelectedRow ?? -1
while true {
row += 1
if let _ = item(atRow: row) {
if canSelect(row) {
selectRowAndScrollToVisible(row)
return
}
}
else {
return // if there are no more items, were out of rows
}
}
}
@IBAction func collapseSelectedRows(_ sender: Any?) {
for item in selectedItems {
if isExpandable(item) && isItemExpanded(item) {
animator().collapseItem(item)
}
}
}
@IBAction func expandSelectedRows(_ sender: Any?) {
for item in selectedItems {
if isExpandable(item) && !isItemExpanded(item) {
animator().expandItem(item)
}
}
}
@IBAction func expandAll(_ sender: Any?) {
expandAllChildren(of: nil)
}
@IBAction func collapseAllExceptForGroupItems(_ sender: Any?) {
collapseAllChildren(of: nil, exceptForGroupItems: true)
}
func expandAllChildren(of item: Any?) {
guard let childItems = children(of: item) else {
return
}
for child in childItems {
if !isItemExpanded(child) && isExpandable(child) {
animator().expandItem(child, expandChildren: true)
}
expandAllChildren(of: child)
}
}
func collapseAllChildren(of item: Any?, exceptForGroupItems: Bool) {
guard let childItems = children(of: item) else {
return
}
for child in childItems {
collapseAllChildren(of: child, exceptForGroupItems: exceptForGroupItems)
if exceptForGroupItems && isGroupItem(child) {
continue
}
if isItemExpanded(child) {
animator().collapseItem(child, collapseChildren: true)
}
}
}
func children(of item: Any?) -> [Any]? {
var children = [Any]()
for indexOfItem in 0..<numberOfChildren(ofItem: item) {
if let child = child(indexOfItem, ofItem: item) {
children.append(child)
}
}
return children.isEmpty ? nil : children
}
func isGroupItem(_ item: Any) -> Bool {
return delegate?.outlineView?(self, isGroupItem: item) ?? false
}
func canSelect(_ row: Int) -> Bool {
guard let item = item(atRow: row) else {
return false
}
return canSelectItem(item)
}
func canSelectItem(_ item: Any) -> Bool {
let isSelectable = delegate?.outlineView?(self, shouldSelectItem: item) ?? true
return isSelectable
}
func selectItemAndScrollToVisible(_ item: Any) {
guard canSelectItem(item) else {
return
}
let rowToSelect = row(forItem: item)
guard rowToSelect != -1 else {
return
}
selectRowAndScrollToVisible(rowToSelect)
}
}
#endif

View File

@ -0,0 +1,63 @@
//
// NSPasteboard+RSCore.swift
// RSCore
//
// Created by Brent Simmons on 2/11/18.
// Copyright © 2018 Ranchero Software, LLC. All rights reserved.
//
#if os(macOS)
import AppKit
public extension NSPasteboard {
func copyObjects(_ objects: [Any]) {
guard let writers = writersFor(objects) else {
return
}
clearContents()
writeObjects(writers)
}
func canCopyAtLeastOneObject(_ objects: [Any]) -> Bool {
for object in objects {
if object is PasteboardWriterOwner {
return true
}
}
return false
}
}
public extension NSPasteboard {
static func urlString(from pasteboard: NSPasteboard) -> String? {
return pasteboard.urlString
}
private var urlString: String? {
guard let type = self.availableType(from: [.string]) else {
return nil
}
guard let str = self.string(forType: type), !str.isEmpty else {
return nil
}
return str.mayBeURL ? str : nil
}
}
private extension NSPasteboard {
func writersFor(_ objects: [Any]) -> [NSPasteboardWriting]? {
let writers = objects.compactMap { ($0 as? PasteboardWriterOwner)?.pasteboardWriter }
return writers.isEmpty ? nil : writers
}
}
#endif

View File

@ -0,0 +1,31 @@
//
// NSResponder-Extensions.swift
// RSCore
//
// Created by Brent Simmons on 10/10/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
#if os(macOS)
import AppKit
public extension NSResponder {
func hasAncestor(_ ancestor: NSResponder) -> Bool {
var nomad: NSResponder = self
while(true) {
if nomad === ancestor {
return true
}
if let _ = nomad.nextResponder {
nomad = nomad.nextResponder!
}
else {
break
}
}
return false
}
}
#endif

View File

@ -0,0 +1,108 @@
//
// NSTableView+Extensions.swift
// RSCore
//
// Created by Brent Simmons on 9/6/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
#if os(macOS)
import AppKit
public extension NSTableView {
var selectionIsEmpty: Bool {
return selectedRowIndexes.startIndex == selectedRowIndexes.endIndex
}
func indexesOfAvailableRowsPassingTest(_ test: (Int) -> Bool) -> IndexSet? {
// Checks visible and in-flight rows.
var indexes = IndexSet()
enumerateAvailableRowViews { (_, row) in
if test(row) {
indexes.insert(row)
}
}
return indexes.isEmpty ? nil : indexes
}
func indexesOfAvailableRows() -> IndexSet? {
var indexes = IndexSet()
enumerateAvailableRowViews { indexes.insert($1) }
return indexes.isEmpty ? nil : indexes
}
func scrollTo(row: Int, extraHeight: Int = 150) {
guard let scrollView = self.enclosingScrollView else {
return
}
let documentVisibleRect = scrollView.documentVisibleRect
let r = rect(ofRow: row)
if NSContainsRect(documentVisibleRect, r) {
return
}
let rMidY = NSMidY(r)
var scrollPoint = NSZeroPoint;
scrollPoint.y = floor(rMidY - (documentVisibleRect.size.height / 2.0)) + CGFloat(extraHeight)
scrollPoint.y = max(scrollPoint.y, 0)
let maxScrollPointY = frame.size.height - documentVisibleRect.size.height
scrollPoint.y = min(maxScrollPointY, scrollPoint.y)
let clipView = scrollView.contentView
let rClipView = NSMakeRect(scrollPoint.x, scrollPoint.y, NSWidth(clipView.bounds), NSHeight(clipView.bounds))
clipView.animator().bounds = rClipView
}
func scrollToRowIfNotVisible(_ row: Int) {
if let followingRow = rowView(atRow: row, makeIfNecessary: false) {
if !(visibleRowViews()?.contains(followingRow) ?? false) {
scrollTo(row: row, extraHeight: 0)
}
} else {
scrollTo(row: row, extraHeight: 0)
}
}
func visibleRowViews() -> [NSTableRowView]? {
guard let scrollView = self.enclosingScrollView, numberOfRows > 0 else {
return nil
}
let range = rows(in: scrollView.documentVisibleRect)
let ixMax = numberOfRows - 1
let ixStart = min(range.location, ixMax)
let ixEnd = min(((range.location + range.length) - 1), ixMax)
var visibleRows = [NSTableRowView]()
for ixRow in ixStart...ixEnd {
if let oneRowView = rowView(atRow: ixRow, makeIfNecessary: false) {
visibleRows += [oneRowView]
}
}
return visibleRows.isEmpty ? nil : visibleRows
}
func selectRow(_ row: Int) {
self.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false)
}
func selectRowAndScrollToVisible(_ row: Int) {
self.selectRow(row)
self.scrollRowToVisible(row)
}
}
#endif

View File

@ -0,0 +1,17 @@
//
// NSToolbar+RSCore.swift
// RSCore
//
// Created by Brent Simmons on 2/17/18.
// Copyright © 2018 Ranchero Software, LLC. All rights reserved.
//
#if os(macOS)
import AppKit
public extension NSToolbar {
func existingItem(withIdentifier identifier: NSToolbarItem.Identifier) -> NSToolbarItem? {
return items.first(where: {$0.itemIdentifier == identifier})
}
}
#endif

View File

@ -0,0 +1,99 @@
//
// NSView+Extensions.swift
// RSCore
//
// Created by Maurice Parker on 11/12/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
#if os(macOS)
import AppKit
import FoundationExtras
extension NSView {
public func asImage() -> NSImage {
let rep = bitmapImageRepForCachingDisplay(in: bounds)!
cacheDisplay(in: bounds, to: rep)
let img = NSImage(size: bounds.size)
img.addRepresentation(rep)
return img
}
}
public extension NSView {
/// Keeps a subview at same size as receiver.
///
/// - Parameter subview: The subview to constrain. Must be a descendant of `self`.
func addFullSizeConstraints(forSubview subview: NSView) {
NSLayoutConstraint.activate([
subview.leadingAnchor.constraint(equalTo: leadingAnchor),
subview.trailingAnchor.constraint(equalTo: trailingAnchor),
subview.topAnchor.constraint(equalTo: topAnchor),
subview.bottomAnchor.constraint(equalTo: bottomAnchor)
])
}
/// Sets the view's frame if it's different from the current frame.
///
/// - Parameter rect: The new frame.
func setFrame(ifNotEqualTo rect: NSRect) {
if self.frame != rect {
self.frame = rect
}
}
/// A boolean indicating whether the view is or is descended from the first responder.
var isOrIsDescendedFromFirstResponder: Bool {
guard let firstResponder = self.window?.firstResponder as? NSView else {
return false
}
return self.isDescendant(of: firstResponder)
}
/// A boolean indicating whether the view should draw as active.
var shouldDrawAsActive: Bool {
return (self.window?.isMainWindow ?? false) && self.isOrIsDescendedFromFirstResponder
}
/// Vertically centers a rectangle in the view's bounds.
/// - Parameter rect: The rectangle to center.
/// - Returns: A new rectangle, vertically centered in the view's bounds.
func verticallyCenteredRect(_ rect: NSRect) -> NSRect {
return rect.centeredVertically(in: self.bounds)
}
/// Horizontally centers a rectangle in the view's bounds.
/// - Parameter rect: The rectangle to center.
/// - Returns: A new rectangle, horizontally centered in the view's bounds.
func horizontallyCenteredRect(_ rect: NSRect) -> NSRect {
return rect.centeredHorizontally(in: self.bounds)
}
/// Centers a rectangle in the view's bounds.
/// - Parameter rect: The rectangle to center.
/// - Returns: A new rectangle, both horizontally and vertically centered in the view's bounds.
func centeredRect(_ rect: NSRect) -> NSRect {
return rect.centered(in: self.bounds)
}
/// The view's enclosing table view, if any.
var enclosingTableView: NSTableView? {
var nomad = self.superview
while nomad != nil {
if let nomad = nomad as? NSTableView {
return nomad
}
nomad = nomad!.superview
}
return nil
}
}
#endif

View File

@ -0,0 +1,95 @@
//
// NSWindow-Extensions.swift
// RSCore
//
// Created by Brent Simmons on 10/10/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
#if os(macOS)
import AppKit
public extension NSWindow {
var isDisplayingSheet: Bool {
return attachedSheet != nil
}
func makeFirstResponderUnlessDescendantIsFirstResponder(_ responder: NSResponder) {
if let fr = firstResponder, fr.hasAncestor(responder) {
return
}
makeFirstResponder(responder)
}
func setPointAndSizeAdjustingForScreen(point: NSPoint, size: NSSize, minimumSize: NSSize) {
// point.y specifices from the *top* of the screen, even though screen coordinates work from the bottom up. This is for convenience.
// The eventual size may be smaller than requested, since the screen may be small, but not smaller than minimumSize.
guard let screenFrame = screen?.visibleFrame else {
return
}
let paddingFromScreenEdge: CGFloat = 8.0
let x = point.x
let y = screenFrame.maxY - point.y
var width = size.width
var height = size.height
if x + width > screenFrame.maxX {
width = max((screenFrame.maxX - x) - paddingFromScreenEdge, minimumSize.width)
}
if y - height < 0.0 {
height = max((screenFrame.maxY - point.y) - paddingFromScreenEdge, minimumSize.height)
}
let frame = NSRect(x: x, y: y, width: width, height: height)
setFrame(frame, display: true)
setFrameTopLeftPoint(frame.origin)
}
var flippedOrigin: NSPoint? {
// Screen coordinates start at lower-left.
// With this we can use upper-left, like sane people.
get {
guard let screenFrame = screen?.frame else {
return nil
}
let flippedPoint = NSPoint(x: frame.origin.x, y: screenFrame.maxY - frame.maxY)
return flippedPoint
}
set {
guard let screenFrame = screen?.frame else {
return
}
var point = newValue!
point.y = screenFrame.maxY - point.y
setFrameTopLeftPoint(point)
}
}
func setFlippedOriginAdjustingForScreen(_ point: NSPoint) {
guard let screenFrame = screen?.frame else {
return
}
let paddingFromEdge: CGFloat = 8.0
var unflippedPoint = point
unflippedPoint.y = (screenFrame.maxY - point.y) - frame.height
if unflippedPoint.y < 0 {
unflippedPoint.y = paddingFromEdge
}
if unflippedPoint.x < 0 {
unflippedPoint.x = paddingFromEdge
}
setFrameOrigin(unflippedPoint)
}
}
#endif

View File

@ -0,0 +1,23 @@
//
// NSWindowController+RSCore.swift
// RSCore
//
// Created by Brent Simmons on 2/17/18.
// Copyright © 2018 Ranchero Software, LLC. All rights reserved.
//
#if os(macOS)
import AppKit
public extension NSWindowController {
var isDisplayingSheet: Bool {
return window?.isDisplayingSheet ?? false
}
var isOpen: Bool {
return isWindowLoaded && window!.isVisible
}
}
#endif

View File

@ -0,0 +1,77 @@
//
// NSWorkspace+RSCore.swift
// RSCore
//
// Created by Brent Simmons on 9/3/18.
// Copyright © 2018 Ranchero Software, LLC. All rights reserved.
//
#if os(macOS)
import AppKit
public extension NSWorkspace {
/// Get the file path to the default app for a given scheme such as "feed:"
func defaultApp(forURLScheme scheme: String) -> String? {
guard let url = URL(string: scheme) else {
return nil
}
return urlForApplication(toOpen: url)?.path
}
/// Get the bundle ID for the default app for a given scheme such as "feed:"
func defaultAppBundleID(forURLScheme scheme: String) -> String? {
guard let path = defaultApp(forURLScheme: scheme) else {
return nil
}
return bundleID(for: path)
}
/// Set the file path that should be the default app for a given scheme such as "feed:"
/// It really just uses the bundle ID for the app, so theres no guarantee that the actual path will be respected later.
/// (In other words, you cant specify one app over another if they have the same bundle ID.)
@discardableResult
func setDefaultApp(forURLScheme scheme: String, to path: String) -> Bool {
guard let bundleID = bundleID(for: path) else {
return false
}
return setDefaultAppBundleID(forURLScheme: scheme, to: bundleID)
}
/// Set the bundle ID for the app that should be default for a given scheme such as "feed:"
@discardableResult
func setDefaultAppBundleID(forURLScheme scheme: String, to bundleID: String) -> Bool {
return LSSetDefaultHandlerForURLScheme(scheme as CFString, bundleID as CFString) == noErr
}
/// Get the file paths to apps that can handle a given scheme such as "feed:"
func apps(forURLScheme scheme: String) -> Set<String> {
guard let url = URL(string: scheme) else {
return Set<String>()
}
guard let appURLs = LSCopyApplicationURLsForURL(url as CFURL, .viewer)?.takeRetainedValue() as [AnyObject]? else {
return Set<String>()
}
let appPaths = appURLs.compactMap { (item) -> String? in
guard let url = item as? URL else {
return nil
}
return url.path
}
return Set(appPaths)
}
/// Get the bundle IDs for apps that can handle a given scheme such as "feed:"
func bundleIDsForApps(forURLScheme scheme: String) -> Set<String> {
let appPaths = apps(forURLScheme: scheme)
let bundleIDs = appPaths.compactMap { (path) -> String? in
return bundleID(for: path)
}
return Set(bundleIDs)
}
/// Get the bundle ID for an app at a path.
func bundleID(for path: String) -> String? {
return Bundle(path: path)?.bundleIdentifier
}
}
#endif

View File

@ -0,0 +1,15 @@
//
// PasteboardWriterOwner.swift
// RSCore
//
// Created by Brent Simmons on 2/11/18.
// Copyright © 2018 Ranchero Software, LLC. All rights reserved.
//
#if os(macOS)
import AppKit
public protocol PasteboardWriterOwner {
var pasteboardWriter: NSPasteboardWriting { get }
}
#endif

View File

@ -0,0 +1,41 @@
//
// RSDarkModeAdaptingToolbarButton.swift
// RSCore
//
// Created by Daniel Jalkut on 8/28/18.
// Copyright © 2018 Ranchero Software, LLC. All rights reserved.
//
#if os(macOS)
import AppKit
class RSDarkModeAdaptingToolbarButton: NSButton {
// Clients probably should not bother using this class unless they want
// to force the template in dark mode, but if you are using this in a more
// general context where you want to control and/or override it on a
// case-by-case basis, set this to false to avoid the templating behavior.
public var forceTemplateInDarkMode: Bool = true
var originalImageTemplateState: Bool = false
public convenience init(image: NSImage, target: Any?, action: Selector?, forceTemplateInDarkMode: Bool = false) {
self.init(image: image, target: target, action: action)
self.forceTemplateInDarkMode = forceTemplateInDarkMode
}
override func layout() {
// Always re-set the NSImage template state based on the current dark mode setting
if #available(macOS 10.14, *) {
if self.forceTemplateInDarkMode, let targetImage = self.image {
var newTemplateState: Bool = self.originalImageTemplateState
if self.effectiveAppearance.isDarkMode {
newTemplateState = true
}
targetImage.isTemplate = newTemplateState
}
}
super.layout()
}
}
#endif

View File

@ -0,0 +1,65 @@
//
// RSToolbarItem.swift
// RSCore
//
// Created by Brent Simmons on 10/16/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
#if os(macOS)
import AppKit
public class RSToolbarItem: NSToolbarItem {
override public func validate() {
guard let view = view, let _ = view.window else {
isEnabled = false
return
}
isEnabled = isValidAsUserInterfaceItem()
}
}
private extension RSToolbarItem {
func isValidAsUserInterfaceItem() -> Bool {
// Use NSValidatedUserInterfaceItem protocol rather than calling validateToolbarItem:.
if let target = target as? NSResponder {
return validateWithResponder(target) ?? false
}
var responder = view?.window?.firstResponder
if responder == nil {
return false
}
while(true) {
if let validated = validateWithResponder(responder!) {
return validated
}
responder = responder?.nextResponder
if responder == nil {
break
}
}
if let appDelegate = NSApplication.shared.delegate {
if let validated = validateWithResponder(appDelegate) {
return validated
}
}
return false
}
func validateWithResponder(_ responder: NSObjectProtocol) -> Bool? {
guard responder.responds(to: action), let target = responder as? NSUserInterfaceValidations else {
return nil
}
return target.validateUserInterfaceItem(self)
}
}
#endif

View File

@ -0,0 +1,47 @@
//
// URLPasteboardWriter.swift
// RSCore
//
// Created by Brent Simmons on 1/28/18.
// Copyright © 2018 Ranchero Software, LLC. All rights reserved.
//
#if os(macOS)
import AppKit
// Takes a string, not a URL, but writes it as a URL (when possible) and as a String.
@objc public final class URLPasteboardWriter: NSObject, NSPasteboardWriting {
let urlString: String
public init(urlString: String) {
self.urlString = urlString
}
public class func write(urlString: String, to pasteboard: NSPasteboard) {
pasteboard.clearContents()
let writer = URLPasteboardWriter(urlString: urlString)
pasteboard.writeObjects([writer])
}
// MARK: - NSPasteboardWriting
public func writableTypes(for pasteboard: NSPasteboard) -> [NSPasteboard.PasteboardType] {
if let _ = URL(string: urlString) {
return [.URL, .string]
}
return [.string]
}
public func pasteboardPropertyList(forType type: NSPasteboard.PasteboardType) -> Any? {
guard type == .string || type == .URL else {
return nil
}
return urlString
}
}
#endif

View File

@ -0,0 +1,140 @@
//
// UserApp.swift
// RSCore
//
// Created by Brent Simmons on 1/14/18.
// Copyright © 2018 Ranchero Software, LLC. All rights reserved.
//
#if os(macOS)
import AppKit
/// Represents an app (the type of app mostly found in /Applications.)
///
/// The app may or may not be running. It may or may not exist.
public final class UserApp {
public let bundleID: String
public var icon: NSImage? = nil
public var existsOnDisk = false
public var path: String? = nil
public var runningApplication: NSRunningApplication? = nil
public var isRunning: Bool {
updateStatus()
if let runningApplication = runningApplication {
return !runningApplication.isTerminated
}
return false
}
public init(bundleID: String) {
self.bundleID = bundleID
updateStatus()
}
public func updateStatus() {
if let runningApplication = runningApplication, runningApplication.isTerminated {
self.runningApplication = nil
}
let runningApplications = NSRunningApplication.runningApplications(withBundleIdentifier: bundleID)
for app in runningApplications {
if let runningApplication = runningApplication {
if app == runningApplication {
break
}
}
else {
if !app.isTerminated {
runningApplication = app
break
}
}
}
if let runningApplication = runningApplication {
existsOnDisk = true
icon = runningApplication.icon
if let bundleURL = runningApplication.bundleURL {
path = bundleURL.path
}
else {
path = NSWorkspace.shared.absolutePathForApplication(withBundleIdentifier: bundleID)
}
if icon == nil, let path = path {
icon = NSWorkspace.shared.icon(forFile: path)
}
return
}
path = NSWorkspace.shared.absolutePathForApplication(withBundleIdentifier: bundleID)
if let path = path {
if icon == nil {
icon = NSWorkspace.shared.icon(forFile: path)
}
existsOnDisk = true
}
else {
existsOnDisk = false
icon = nil
}
}
public func launchIfNeeded() -> Bool {
// Return true if already running.
// Return true if not running and successfully gets launched.
updateStatus()
if isRunning {
return true
}
guard existsOnDisk, let path = path else {
return false
}
let url = URL(fileURLWithPath: path)
if let app = try? NSWorkspace.shared.launchApplication(at: url, options: [.withErrorPresentation], configuration: [:]) {
runningApplication = app
if app.isFinishedLaunching {
return true
}
Thread.sleep(forTimeInterval: 1.0) // Give the app time to launch. This is ugly.
if app.isFinishedLaunching {
return true
}
Thread.sleep(forTimeInterval: 1.0) // Give it some *more* time.
return true
}
return false
}
public func bringToFront() -> Bool {
// Activates the app, ignoring other apps.
// Does not automatically launch the app first.
updateStatus()
return runningApplication?.activate(options: [.activateIgnoringOtherApps]) ?? false
}
public func targetDescriptor() -> NSAppleEventDescriptor? {
// Requires that the app has previously been launched.
updateStatus()
guard let runningApplication = runningApplication, !runningApplication.isTerminated else {
return nil
}
return NSAppleEventDescriptor(runningApplication: runningApplication)
}
}
#endif

View File

@ -0,0 +1,12 @@
import XCTest
@testable import AppKitExtras
final class AppKitExtrasTests: XCTestCase {
func testExample() throws {
// XCTest Documentation
// https://developer.apple.com/documentation/xctest
// Defining Test Cases and Test Methods
// https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods
}
}

View File

@ -11,13 +11,13 @@ let package = Package(
targets: ["Articles"]),
],
dependencies: [
.package(url: "https://github.com/Ranchero-Software/RSCore.git", .upToNextMinor(from: "1.0.0")),
.package(path: "../FoundationExtras")
],
targets: [
.target(
name: "Articles",
dependencies: [
"RSCore"
"FoundationExtras"
],
swiftSettings: [
.enableExperimentalFeature("StrictConcurrency")

View File

@ -7,7 +7,7 @@
//
import Foundation
import RSCore
import FoundationExtras
class DatabaseIDCache: @unchecked Sendable {

View File

@ -12,21 +12,21 @@ let package = Package(
targets: ["ArticlesDatabase"]),
],
dependencies: [
.package(url: "https://github.com/Ranchero-Software/RSCore.git", .upToNextMinor(from: "1.0.0")),
.package(url: "https://github.com/Ranchero-Software/RSParser.git", .upToNextMajor(from: "2.0.2")),
.package(path: "../Articles"),
.package(path: "../Database"),
.package(path: "../FMDB"),
.package(path: "../FoundationExtras"),
],
targets: [
.target(
name: "ArticlesDatabase",
dependencies: [
"RSCore",
"Database",
"RSParser",
"Articles",
"FMDB",
"FoundationExtras"
]),
]
)

View File

@ -7,7 +7,6 @@
//
import Foundation
import RSCore
import Database
import RSParser
import Articles

View File

@ -7,7 +7,6 @@
//
import Foundation
import RSCore
import Database
import Articles
import RSParser

View File

@ -7,7 +7,6 @@
//
import Foundation
import RSCore
import Database
import Articles
import FMDB

8
CloudKitExtras/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
.DS_Store
/.build
/Packages
xcuserdata/
DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

View File

@ -0,0 +1,30 @@
// swift-tools-version: 5.10
import PackageDescription
let package = Package(
name: "CloudKitExtras",
platforms: [.macOS(.v14), .iOS(.v17)],
products: [
.library(
name: "CloudKitExtras",
targets: ["CloudKitExtras"]),
],
dependencies: [
.package(path: "../FoundationExtras")
],
targets: [
.target(
name: "CloudKitExtras",
dependencies: [
"FoundationExtras",
],
swiftSettings: [
.enableExperimentalFeature("StrictConcurrency")
]
),
.testTarget(
name: "CloudKitExtrasTests",
dependencies: ["CloudKitExtras"]),
]
)

View File

@ -0,0 +1,98 @@
//
// CloudKitError.swift
// RSCore
//
// Created by Maurice Parker on 3/26/20.
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
//
// Derived from https://github.com/caiyue1993/IceCream
import Foundation
import CloudKit
public class CloudKitError: LocalizedError {
public let error: Error
public init(_ error: Error) {
self.error = error
}
public var errorDescription: String? {
guard let ckError = error as? CKError else {
return error.localizedDescription
}
switch ckError.code {
case .alreadyShared:
return NSLocalizedString("Already Shared: a record or share cannot be saved because doing so would cause the same hierarchy of records to exist in multiple shares.", comment: "Known iCloud Error")
case .assetFileModified:
return NSLocalizedString("Asset File Modified: the content of the specified asset file was modified while being saved.", comment: "Known iCloud Error")
case .assetFileNotFound:
return NSLocalizedString("Asset File Not Found: the specified asset file is not found.", comment: "Known iCloud Error")
case .badContainer:
return NSLocalizedString("Bad Container: the specified container is unknown or unauthorized.", comment: "Known iCloud Error")
case .badDatabase:
return NSLocalizedString("Bad Database: the operation could not be completed on the given database.", comment: "Known iCloud Error")
case .batchRequestFailed:
return NSLocalizedString("Batch Request Failed: the entire batch was rejected.", comment: "Known iCloud Error")
case .changeTokenExpired:
return NSLocalizedString("Change Token Expired: the previous server change token is too old.", comment: "Known iCloud Error")
case .constraintViolation:
return NSLocalizedString("Constraint Violation: the server rejected the request because of a conflict with a unique field.", comment: "Known iCloud Error")
case .incompatibleVersion:
return NSLocalizedString("Incompatible Version: your app version is older than the oldest version allowed.", comment: "Known iCloud Error")
case .internalError:
return NSLocalizedString("Internal Error: a nonrecoverable error was encountered by CloudKit.", comment: "Known iCloud Error")
case .invalidArguments:
return NSLocalizedString("Invalid Arguments: the specified request contains bad information.", comment: "Known iCloud Error")
case .limitExceeded:
return NSLocalizedString("Limit Exceeded: the request to the server is too large.", comment: "Known iCloud Error")
case .managedAccountRestricted:
return NSLocalizedString("Managed Account Restricted: the request was rejected due to a managed-account restriction.", comment: "Known iCloud Error")
case .missingEntitlement:
return NSLocalizedString("Missing Entitlement: the app is missing a required entitlement.", comment: "Known iCloud Error")
case .networkUnavailable:
return NSLocalizedString("Network Unavailable: the internet connection appears to be offline.", comment: "Known iCloud Error")
case .networkFailure:
return NSLocalizedString("Network Failure: the internet connection appears to be offline.", comment: "Known iCloud Error")
case .notAuthenticated:
return NSLocalizedString("Not Authenticated: to use the iCloud account, you must enable iCloud Drive. Go to device Settings, sign in to iCloud, then in the app settings, be sure the iCloud Drive feature is enabled.", comment: "Known iCloud Error")
case .operationCancelled:
return NSLocalizedString("Operation Cancelled: the operation was explicitly canceled.", comment: "Known iCloud Error")
case .partialFailure:
return NSLocalizedString("Partial Failure: some items failed, but the operation succeeded overall.", comment: "Known iCloud Error")
case .participantMayNeedVerification:
return NSLocalizedString("Participant May Need Verification: you are not a member of the share.", comment: "Known iCloud Error")
case .permissionFailure:
return NSLocalizedString("Permission Failure: to use this app, you must enable iCloud Drive. Go to device Settings, sign in to iCloud, then in the app settings, be sure the iCloud Drive feature is enabled.", comment: "Known iCloud Error")
case .quotaExceeded:
return NSLocalizedString("Quota Exceeded: saving would exceed your current iCloud storage quota.", comment: "Known iCloud Error")
case .referenceViolation:
return NSLocalizedString("Reference Violation: the target of a record's parent or share reference was not found.", comment: "Known iCloud Error")
case .requestRateLimited:
return NSLocalizedString("Request Rate Limited: transfers to and from the server are being rate limited at this time.", comment: "Known iCloud Error")
case .serverRecordChanged:
return NSLocalizedString("Server Record Changed: the record was rejected because the version on the server is different.", comment: "Known iCloud Error")
case .serverRejectedRequest:
return NSLocalizedString("Server Rejected Request", comment: "Known iCloud Error")
case .serverResponseLost:
return NSLocalizedString("Server Response Lost", comment: "Known iCloud Error")
case .serviceUnavailable:
return NSLocalizedString("Service Unavailable: Please try again.", comment: "Known iCloud Error")
case .tooManyParticipants:
return NSLocalizedString("Too Many Participants: a share cannot be saved because too many participants are attached to the share.", comment: "Known iCloud Error")
case .unknownItem:
return NSLocalizedString("Unknown Item: the specified record does not exist.", comment: "Known iCloud Error")
case .userDeletedZone:
return NSLocalizedString("User Deleted Zone: the user has deleted this zone from the settings UI.", comment: "Known iCloud Error")
case .zoneBusy:
return NSLocalizedString("Zone Busy: the server is too busy to handle the zone operation.", comment: "Known iCloud Error")
case .zoneNotFound:
return NSLocalizedString("Zone Not Found: the specified record zone does not exist on the server.", comment: "Known iCloud Error")
default:
return NSLocalizedString("Unhandled Error.", comment: "Unknown iCloud Error")
}
}
}

View File

@ -0,0 +1,816 @@
//
// CloudKitZone.swift
// RSCore
//
// Created by Maurice Parker on 3/21/20.
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
//
import CloudKit
import os.log
import FoundationExtras
public enum CloudKitZoneError: LocalizedError {
case userDeletedZone
case corruptAccount
case unknown
public var errorDescription: String? {
switch self {
case .userDeletedZone:
return NSLocalizedString("The iCloud data was deleted. Please remove the application iCloud account and add it again to continue using the application's iCloud support.", comment: "User deleted zone.")
case .corruptAccount:
return NSLocalizedString("There is an unrecoverable problem with your application iCloud account. Please make sure you have iCloud and iCloud Drive enabled in System Preferences. Then remove the application iCloud account and add it again.", comment: "Corrupt account.")
default:
return NSLocalizedString("An unexpected CloudKit error occurred.", comment: "An unexpected CloudKit error occurred.")
}
}
}
public protocol CloudKitZoneDelegate: class {
func cloudKitDidModify(changed: [CKRecord], deleted: [CloudKitRecordKey], completion: @escaping (Result<Void, Error>) -> Void);
}
public typealias CloudKitRecordKey = (recordType: CKRecord.RecordType, recordID: CKRecord.ID)
public protocol CloudKitZone: class {
static var qualityOfService: QualityOfService { get }
var zoneID: CKRecordZone.ID { get }
var log: OSLog { get }
var container: CKContainer? { get }
var database: CKDatabase? { get }
var delegate: CloudKitZoneDelegate? { get set }
/// Reset the change token used to determine what point in time we are doing changes fetches
func resetChangeToken()
/// Generates a new CKRecord.ID using a UUID for the record's name
func generateRecordID() -> CKRecord.ID
/// Subscribe to changes at a zone level
func subscribeToZoneChanges()
/// Process a remove notification
func receiveRemoteNotification(userInfo: [AnyHashable : Any], completion: @escaping () -> Void)
}
public extension CloudKitZone {
// My observation has been that QoS is treated differently for CloudKit operations on macOS vs iOS.
// .userInitiated is too aggressive on iOS and can lead the UI slowing down and appearing to block.
// .default (or lower) on macOS will sometimes hang for extended periods of time and appear to hang.
static var qualityOfService: QualityOfService {
#if os(macOS) || targetEnvironment(macCatalyst)
return .userInitiated
#else
return .default
#endif
}
var oldChangeTokenKey: String {
return "cloudkit.server.token.\(zoneID.zoneName)"
}
var changeTokenKey: String {
return "cloudkit.server.token.\(zoneID.zoneName).\(zoneID.ownerName)"
}
var changeToken: CKServerChangeToken? {
get {
guard let tokenData = UserDefaults.standard.object(forKey: changeTokenKey) as? Data else { return nil }
return try? NSKeyedUnarchiver.unarchivedObject(ofClass: CKServerChangeToken.self, from: tokenData)
}
set {
guard let token = newValue, let data = try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: false) else {
UserDefaults.standard.removeObject(forKey: changeTokenKey)
return
}
UserDefaults.standard.set(data, forKey: changeTokenKey)
}
}
/// Moves the change token to the new key name. This can eventually be removed.
func migrateChangeToken() {
if let tokenData = UserDefaults.standard.object(forKey: oldChangeTokenKey) as? Data,
let oldChangeToken = try? NSKeyedUnarchiver.unarchivedObject(ofClass: CKServerChangeToken.self, from: tokenData) {
changeToken = oldChangeToken
UserDefaults.standard.removeObject(forKey: oldChangeTokenKey)
}
}
/// Reset the change token used to determine what point in time we are doing changes fetches
func resetChangeToken() {
changeToken = nil
}
func generateRecordID() -> CKRecord.ID {
return CKRecord.ID(recordName: UUID().uuidString, zoneID: zoneID)
}
func retryIfPossible(after: Double, block: @escaping () -> ()) {
let delayTime = DispatchTime.now() + after
DispatchQueue.main.asyncAfter(deadline: delayTime, execute: {
block()
})
}
func receiveRemoteNotification(userInfo: [AnyHashable : Any], completion: @escaping () -> Void) {
let note = CKRecordZoneNotification(fromRemoteNotificationDictionary: userInfo)
guard note?.recordZoneID?.zoneName == zoneID.zoneName else {
completion()
return
}
fetchChangesInZone() { result in
if case .failure(let error) = result {
os_log(.error, log: self.log, "%@ zone remote notification fetch error: %@", self.zoneID.zoneName, error.localizedDescription)
}
completion()
}
}
/// Retrieves the zone record for this zone only. If the record isn't found it will be created.
func fetchZoneRecord(completion: @escaping (Result<CKRecordZone?, Error>) -> Void) {
let op = CKFetchRecordZonesOperation(recordZoneIDs: [zoneID])
op.qualityOfService = Self.qualityOfService
op.fetchRecordZonesCompletionBlock = { [weak self] (zoneRecords, error) in
guard let self = self else {
completion(.failure(CloudKitZoneError.unknown))
return
}
switch CloudKitZoneResult.resolve(error) {
case .success:
completion(.success(zoneRecords?[self.zoneID]))
case .zoneNotFound, .userDeletedZone:
self.createZoneRecord() { result in
switch result {
case .success:
self.fetchZoneRecord(completion: completion)
case .failure(let error):
DispatchQueue.main.async {
completion(.failure(error))
}
}
}
case .retry(let timeToWait):
os_log(.error, log: self.log, "%@ zone fetch changes retry in %f seconds.", self.zoneID.zoneName, timeToWait)
self.retryIfPossible(after: timeToWait) {
self.fetchZoneRecord(completion: completion)
}
default:
DispatchQueue.main.async {
completion(.failure(CloudKitError(error!)))
}
}
}
database?.add(op)
}
/// Creates the zone record
func createZoneRecord(completion: @escaping (Result<Void, Error>) -> Void) {
guard let database = database else {
completion(.failure(CloudKitZoneError.unknown))
return
}
database.save(CKRecordZone(zoneID: zoneID)) { (recordZone, error) in
if let error = error {
DispatchQueue.main.async {
completion(.failure(CloudKitError(error)))
}
} else {
DispatchQueue.main.async {
completion(.success(()))
}
}
}
}
/// Subscribes to zone changes
func subscribeToZoneChanges() {
let subscription = CKRecordZoneSubscription(zoneID: zoneID)
let info = CKSubscription.NotificationInfo()
info.shouldSendContentAvailable = true
subscription.notificationInfo = info
save(subscription) { result in
if case .failure(let error) = result {
os_log(.error, log: self.log, "%@ zone subscribe to changes error: %@", self.zoneID.zoneName, error.localizedDescription)
}
}
}
/// Issue a CKQuery and return the resulting CKRecords.
func query(_ ckQuery: CKQuery, desiredKeys: [String]? = nil, completion: @escaping (Result<[CKRecord], Error>) -> Void) {
var records = [CKRecord]()
let op = CKQueryOperation(query: ckQuery)
op.qualityOfService = Self.qualityOfService
if let desiredKeys = desiredKeys {
op.desiredKeys = desiredKeys
}
op.recordFetchedBlock = { record in
records.append(record)
}
op.queryCompletionBlock = { [weak self] (cursor, error) in
guard let self = self else {
completion(.failure(CloudKitZoneError.unknown))
return
}
switch CloudKitZoneResult.resolve(error) {
case .success:
DispatchQueue.main.async {
if let cursor = cursor {
self.query(cursor: cursor, desiredKeys: desiredKeys, carriedRecords: records, completion: completion)
} else {
completion(.success(records))
}
}
case .zoneNotFound:
self.createZoneRecord() { result in
switch result {
case .success:
self.query(ckQuery, desiredKeys: desiredKeys, completion: completion)
case .failure(let error):
DispatchQueue.main.async {
completion(.failure(error))
}
}
}
case .retry(let timeToWait):
os_log(.error, log: self.log, "%@ zone query retry in %f seconds.", self.zoneID.zoneName, timeToWait)
self.retryIfPossible(after: timeToWait) {
self.query(ckQuery, desiredKeys: desiredKeys, completion: completion)
}
case .userDeletedZone:
DispatchQueue.main.async {
completion(.failure(CloudKitZoneError.userDeletedZone))
}
default:
DispatchQueue.main.async {
completion(.failure(CloudKitError(error!)))
}
}
}
database?.add(op)
}
/// Query CKRecords using a CKQuery Cursor
func query(cursor: CKQueryOperation.Cursor, desiredKeys: [String]? = nil, carriedRecords: [CKRecord], completion: @escaping (Result<[CKRecord], Error>) -> Void) {
var records = carriedRecords
let op = CKQueryOperation(cursor: cursor)
op.qualityOfService = Self.qualityOfService
if let desiredKeys = desiredKeys {
op.desiredKeys = desiredKeys
}
op.recordFetchedBlock = { record in
records.append(record)
}
op.queryCompletionBlock = { [weak self] (newCursor, error) in
guard let self = self else {
completion(.failure(CloudKitZoneError.unknown))
return
}
switch CloudKitZoneResult.resolve(error) {
case .success:
DispatchQueue.main.async {
if let newCursor = newCursor {
self.query(cursor: newCursor, desiredKeys: desiredKeys, carriedRecords: records, completion: completion)
} else {
completion(.success(records))
}
}
case .zoneNotFound:
self.createZoneRecord() { result in
switch result {
case .success:
self.query(cursor: cursor, desiredKeys: desiredKeys, carriedRecords: records, completion: completion)
case .failure(let error):
DispatchQueue.main.async {
completion(.failure(error))
}
}
}
case .retry(let timeToWait):
os_log(.error, log: self.log, "%@ zone query retry in %f seconds.", self.zoneID.zoneName, timeToWait)
self.retryIfPossible(after: timeToWait) {
self.query(cursor: cursor, desiredKeys: desiredKeys, carriedRecords: records, completion: completion)
}
case .userDeletedZone:
DispatchQueue.main.async {
completion(.failure(CloudKitZoneError.userDeletedZone))
}
default:
DispatchQueue.main.async {
completion(.failure(CloudKitError(error!)))
}
}
}
database?.add(op)
}
/// Fetch a CKRecord by using its externalID
func fetch(externalID: String?, completion: @escaping (Result<CKRecord, Error>) -> Void) {
guard let externalID = externalID else {
completion(.failure(CloudKitZoneError.corruptAccount))
return
}
let recordID = CKRecord.ID(recordName: externalID, zoneID: zoneID)
database?.fetch(withRecordID: recordID) { [weak self] record, error in
guard let self = self else {
completion(.failure(CloudKitZoneError.unknown))
return
}
switch CloudKitZoneResult.resolve(error) {
case .success:
DispatchQueue.main.async {
if let record = record {
completion(.success(record))
} else {
completion(.failure(CloudKitZoneError.unknown))
}
}
case .zoneNotFound:
self.createZoneRecord() { result in
switch result {
case .success:
self.fetch(externalID: externalID, completion: completion)
case .failure(let error):
DispatchQueue.main.async {
completion(.failure(error))
}
}
}
case .retry(let timeToWait):
os_log(.error, log: self.log, "%@ zone fetch retry in %f seconds.", self.zoneID.zoneName, timeToWait)
self.retryIfPossible(after: timeToWait) {
self.fetch(externalID: externalID, completion: completion)
}
case .userDeletedZone:
DispatchQueue.main.async {
completion(.failure(CloudKitZoneError.userDeletedZone))
}
default:
DispatchQueue.main.async {
completion(.failure(CloudKitError(error!)))
}
}
}
}
/// Save the CKRecord
func save(_ record: CKRecord, completion: @escaping (Result<Void, Error>) -> Void) {
modify(recordsToSave: [record], recordIDsToDelete: [], completion: completion)
}
/// Save the CKRecords
func save(_ records: [CKRecord], completion: @escaping (Result<Void, Error>) -> Void) {
modify(recordsToSave: records, recordIDsToDelete: [], completion: completion)
}
/// Saves or modifies the records as long as they are unchanged relative to the local version
func saveIfNew(_ records: [CKRecord], completion: @escaping (Result<Void, Error>) -> Void) {
let op = CKModifyRecordsOperation(recordsToSave: records, recordIDsToDelete: [CKRecord.ID]())
op.savePolicy = .ifServerRecordUnchanged
op.isAtomic = false
op.qualityOfService = Self.qualityOfService
op.modifyRecordsCompletionBlock = { [weak self] (_, _, error) in
guard let self = self else { return }
switch CloudKitZoneResult.resolve(error) {
case .success, .partialFailure:
DispatchQueue.main.async {
completion(.success(()))
}
case .zoneNotFound:
self.createZoneRecord() { result in
switch result {
case .success:
self.saveIfNew(records, completion: completion)
case .failure(let error):
DispatchQueue.main.async {
completion(.failure(error))
}
}
}
case .userDeletedZone:
DispatchQueue.main.async {
completion(.failure(CloudKitZoneError.userDeletedZone))
}
case .retry(let timeToWait):
self.retryIfPossible(after: timeToWait) {
self.saveIfNew(records, completion: completion)
}
case .limitExceeded:
var chunkedRecords = records.chunked(into: 200)
func saveChunksIfNew() {
if let records = chunkedRecords.popLast() {
self.saveIfNew(records) { result in
switch result {
case .success:
os_log(.info, log: self.log, "Saved %d chunked new records.", records.count)
saveChunksIfNew()
case .failure(let error):
completion(.failure(error))
}
}
} else {
completion(.success(()))
}
}
saveChunksIfNew()
default:
DispatchQueue.main.async {
completion(.failure(CloudKitError(error!)))
}
}
}
database?.add(op)
}
/// Save the CKSubscription
func save(_ subscription: CKSubscription, completion: @escaping (Result<CKSubscription, Error>) -> Void) {
database?.save(subscription) { [weak self] savedSubscription, error in
guard let self = self else {
completion(.failure(CloudKitZoneError.unknown))
return
}
switch CloudKitZoneResult.resolve(error) {
case .success:
DispatchQueue.main.async {
completion(.success((savedSubscription!)))
}
case .zoneNotFound:
self.createZoneRecord() { result in
switch result {
case .success:
self.save(subscription, completion: completion)
case .failure(let error):
DispatchQueue.main.async {
completion(.failure(error))
}
}
}
case .retry(let timeToWait):
os_log(.error, log: self.log, "%@ zone save subscription retry in %f seconds.", self.zoneID.zoneName, timeToWait)
self.retryIfPossible(after: timeToWait) {
self.save(subscription, completion: completion)
}
default:
DispatchQueue.main.async {
completion(.failure(CloudKitError(error!)))
}
}
}
}
/// Delete CKRecords using a CKQuery
func delete(ckQuery: CKQuery, completion: @escaping (Result<Void, Error>) -> Void) {
var records = [CKRecord]()
let op = CKQueryOperation(query: ckQuery)
op.qualityOfService = Self.qualityOfService
op.recordFetchedBlock = { record in
records.append(record)
}
op.queryCompletionBlock = { [weak self] (cursor, error) in
guard let self = self else {
completion(.failure(CloudKitZoneError.unknown))
return
}
if let cursor = cursor {
self.delete(cursor: cursor, carriedRecords: records, completion: completion)
} else {
guard !records.isEmpty else {
DispatchQueue.main.async {
completion(.success(()))
}
return
}
let recordIDs = records.map { $0.recordID }
self.modify(recordsToSave: [], recordIDsToDelete: recordIDs, completion: completion)
}
}
database?.add(op)
}
/// Delete CKRecords using a CKQuery
func delete(cursor: CKQueryOperation.Cursor, carriedRecords: [CKRecord], completion: @escaping (Result<Void, Error>) -> Void) {
var records = [CKRecord]()
let op = CKQueryOperation(cursor: cursor)
op.qualityOfService = Self.qualityOfService
op.recordFetchedBlock = { record in
records.append(record)
}
op.queryCompletionBlock = { [weak self] (cursor, error) in
guard let self = self else {
completion(.failure(CloudKitZoneError.unknown))
return
}
records.append(contentsOf: carriedRecords)
if let cursor = cursor {
self.delete(cursor: cursor, carriedRecords: records, completion: completion)
} else {
let recordIDs = records.map { $0.recordID }
self.modify(recordsToSave: [], recordIDsToDelete: recordIDs, completion: completion)
}
}
database?.add(op)
}
/// Delete a CKRecord using its recordID
func delete(recordID: CKRecord.ID, completion: @escaping (Result<Void, Error>) -> Void) {
modify(recordsToSave: [], recordIDsToDelete: [recordID], completion: completion)
}
/// Delete CKRecords
func delete(recordIDs: [CKRecord.ID], completion: @escaping (Result<Void, Error>) -> Void) {
modify(recordsToSave: [], recordIDsToDelete: recordIDs, completion: completion)
}
/// Delete a CKRecord using its externalID
func delete(externalID: String?, completion: @escaping (Result<Void, Error>) -> Void) {
guard let externalID = externalID else {
completion(.failure(CloudKitZoneError.corruptAccount))
return
}
let recordID = CKRecord.ID(recordName: externalID, zoneID: zoneID)
modify(recordsToSave: [], recordIDsToDelete: [recordID], completion: completion)
}
/// Delete a CKSubscription
func delete(subscriptionID: String, completion: @escaping (Result<Void, Error>) -> Void) {
database?.delete(withSubscriptionID: subscriptionID) { [weak self] _, error in
guard let self = self else {
completion(.failure(CloudKitZoneError.unknown))
return
}
switch CloudKitZoneResult.resolve(error) {
case .success:
DispatchQueue.main.async {
completion(.success(()))
}
case .retry(let timeToWait):
os_log(.error, log: self.log, "%@ zone delete subscription retry in %f seconds.", self.zoneID.zoneName, timeToWait)
self.retryIfPossible(after: timeToWait) {
self.delete(subscriptionID: subscriptionID, completion: completion)
}
default:
DispatchQueue.main.async {
completion(.failure(CloudKitError(error!)))
}
}
}
}
/// Modify and delete the supplied CKRecords and CKRecord.IDs
func modify(recordsToSave: [CKRecord], recordIDsToDelete: [CKRecord.ID], completion: @escaping (Result<Void, Error>) -> Void) {
guard !(recordsToSave.isEmpty && recordIDsToDelete.isEmpty) else {
DispatchQueue.main.async {
completion(.success(()))
}
return
}
let op = CKModifyRecordsOperation(recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete)
op.savePolicy = .changedKeys
op.isAtomic = true
op.qualityOfService = Self.qualityOfService
op.modifyRecordsCompletionBlock = { [weak self] (_, _, error) in
guard let self = self else {
completion(.failure(CloudKitZoneError.unknown))
return
}
switch CloudKitZoneResult.resolve(error) {
case .success:
DispatchQueue.main.async {
completion(.success(()))
}
case .zoneNotFound:
self.createZoneRecord() { result in
switch result {
case .success:
self.modify(recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete, completion: completion)
case .failure(let error):
DispatchQueue.main.async {
completion(.failure(error))
}
}
}
case .userDeletedZone:
DispatchQueue.main.async {
completion(.failure(CloudKitZoneError.userDeletedZone))
}
case .retry(let timeToWait):
os_log(.error, log: self.log, "%@ zone modify retry in %f seconds.", self.zoneID.zoneName, timeToWait)
self.retryIfPossible(after: timeToWait) {
self.modify(recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete, completion: completion)
}
case .limitExceeded:
var recordToSaveChunks = recordsToSave.chunked(into: 200)
var recordIDsToDeleteChunks = recordIDsToDelete.chunked(into: 200)
func saveChunks(completion: @escaping (Result<Void, Error>) -> Void) {
if !recordToSaveChunks.isEmpty {
let records = recordToSaveChunks.removeFirst()
self.modify(recordsToSave: records, recordIDsToDelete: []) { result in
switch result {
case .success:
os_log(.info, log: self.log, "Saved %d chunked records.", records.count)
saveChunks(completion: completion)
case .failure(let error):
completion(.failure(error))
}
}
} else {
completion(.success(()))
}
}
func deleteChunks() {
if !recordIDsToDeleteChunks.isEmpty {
let records = recordIDsToDeleteChunks.removeFirst()
self.modify(recordsToSave: [], recordIDsToDelete: records) { result in
switch result {
case .success:
os_log(.info, log: self.log, "Deleted %d chunked records.", records.count)
deleteChunks()
case .failure(let error):
DispatchQueue.main.async {
completion(.failure(error))
}
}
}
} else {
DispatchQueue.main.async {
completion(.success(()))
}
}
}
saveChunks() { result in
switch result {
case .success:
deleteChunks()
case .failure(let error):
DispatchQueue.main.async {
completion(.failure(error))
}
}
}
default:
DispatchQueue.main.async {
completion(.failure(CloudKitError(error!)))
}
}
}
database?.add(op)
}
/// Fetch all the changes in the CKZone since the last time we checked
func fetchChangesInZone(completion: @escaping (Result<Void, Error>) -> Void) {
var savedChangeToken = changeToken
var changedRecords = [CKRecord]()
var deletedRecordKeys = [CloudKitRecordKey]()
let zoneConfig = CKFetchRecordZoneChangesOperation.ZoneConfiguration()
zoneConfig.previousServerChangeToken = changeToken
let op = CKFetchRecordZoneChangesOperation(recordZoneIDs: [zoneID], configurationsByRecordZoneID: [zoneID: zoneConfig])
op.fetchAllChanges = true
op.qualityOfService = Self.qualityOfService
op.recordZoneChangeTokensUpdatedBlock = { zoneID, token, _ in
savedChangeToken = token
}
op.recordChangedBlock = { record in
changedRecords.append(record)
}
op.recordWithIDWasDeletedBlock = { recordID, recordType in
let recordKey = CloudKitRecordKey(recordType: recordType, recordID: recordID)
deletedRecordKeys.append(recordKey)
}
op.recordZoneFetchCompletionBlock = { zoneID ,token, _, _, error in
if case .success = CloudKitZoneResult.resolve(error) {
savedChangeToken = token
}
}
op.fetchRecordZoneChangesCompletionBlock = { [weak self] error in
guard let self = self else {
completion(.failure(CloudKitZoneError.unknown))
return
}
switch CloudKitZoneResult.resolve(error) {
case .success:
DispatchQueue.main.async {
self.delegate?.cloudKitDidModify(changed: changedRecords, deleted: deletedRecordKeys) { result in
switch result {
case .success:
self.changeToken = savedChangeToken
completion(.success(()))
case .failure(let error):
completion(.failure(error))
}
}
}
case .zoneNotFound:
self.createZoneRecord() { result in
switch result {
case .success:
self.fetchChangesInZone(completion: completion)
case .failure(let error):
DispatchQueue.main.async {
completion(.failure(error))
}
}
}
case .userDeletedZone:
DispatchQueue.main.async {
completion(.failure(CloudKitZoneError.userDeletedZone))
}
case .retry(let timeToWait):
os_log(.error, log: self.log, "%@ zone fetch changes retry in %f seconds.", self.zoneID.zoneName, timeToWait)
self.retryIfPossible(after: timeToWait) {
self.fetchChangesInZone(completion: completion)
}
case .changeTokenExpired:
DispatchQueue.main.async {
self.changeToken = nil
self.fetchChangesInZone(completion: completion)
}
default:
DispatchQueue.main.async {
completion(.failure(CloudKitError(error!)))
}
}
}
database?.add(op)
}
}

View File

@ -0,0 +1,81 @@
//
// CloudKitResult.swift
// RSCore
//
// Created by Maurice Parker on 3/26/20.
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import CloudKit
public enum CloudKitZoneResult {
case success
case retry(afterSeconds: Double)
case limitExceeded
case changeTokenExpired
case partialFailure(errors: [AnyHashable: CKError])
case serverRecordChanged
case zoneNotFound
case userDeletedZone
case failure(error: Error)
public static func resolve(_ error: Error?) -> CloudKitZoneResult {
guard error != nil else { return .success }
guard let ckError = error as? CKError else {
return .failure(error: error!)
}
switch ckError.code {
case .serviceUnavailable, .requestRateLimited, .zoneBusy:
if let retry = ckError.userInfo[CKErrorRetryAfterKey] as? NSNumber {
return .retry(afterSeconds: retry.doubleValue)
} else {
return .failure(error: CloudKitError(ckError))
}
case .zoneNotFound:
return .zoneNotFound
case .userDeletedZone:
return .userDeletedZone
case .changeTokenExpired:
return .changeTokenExpired
case .serverRecordChanged:
return .serverRecordChanged
case .partialFailure:
if let partialErrors = ckError.userInfo[CKPartialErrorsByItemIDKey] as? [AnyHashable: CKError] {
if let zoneResult = anyRequestErrors(partialErrors) {
return zoneResult
} else {
return .partialFailure(errors: partialErrors)
}
} else {
return .failure(error: CloudKitError(ckError))
}
case .limitExceeded:
return .limitExceeded
default:
return .failure(error: CloudKitError(ckError))
}
}
}
private extension CloudKitZoneResult {
static func anyRequestErrors(_ errors: [AnyHashable: CKError]) -> CloudKitZoneResult? {
if errors.values.contains(where: { $0.code == .changeTokenExpired } ) {
return .changeTokenExpired
}
if errors.values.contains(where: { $0.code == .zoneNotFound } ) {
return .zoneNotFound
}
if errors.values.contains(where: { $0.code == .userDeletedZone } ) {
return .userDeletedZone
}
return nil
}
}

View File

@ -0,0 +1,12 @@
import XCTest
@testable import CloudKitExtras
final class CloudKitExtrasTests: XCTestCase {
func testExample() throws {
// XCTest Documentation
// https://developer.apple.com/documentation/xctest
// Defining Test Cases and Test Methods
// https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods
}
}

8
Core/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
.DS_Store
/.build
/Packages
xcuserdata/
DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

39
Core/Package.swift Normal file
View File

@ -0,0 +1,39 @@
// swift-tools-version: 5.10
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "Core",
platforms: [.macOS(.v14), .iOS(.v17)],
products: [
// Products define the executables and libraries a package produces, making them visible to other packages.
.library(name: "Core", targets: ["Core"]),
.library(name: "CoreResources", type: .static, targets: ["CoreResources"])
],
dependencies: [
.package(path: "../AppKitExtras")
],
targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite.
// Targets can depend on other targets in this package and products from dependencies.
.target(
name: "Core",
dependencies: [
"AppKitExtras",
],
swiftSettings: [
.enableExperimentalFeature("StrictConcurrency")
]
),
.target(
name: "CoreResources",
resources: [
.process("Resources/WebViewWindow.xib"),
.process("Resources/IndeterminateProgressWindow.xib")
]),
.testTarget(
name: "CoreTests",
dependencies: ["Core"]),
]
)

View File

@ -0,0 +1,81 @@
//
// BatchUpdates.swift
// DataModel
//
// Created by Brent Simmons on 9/12/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
import Foundation
// Main thread only.
public typealias BatchUpdateBlock = () -> Void
public extension Notification.Name {
/// A notification posted when a batch update completes.
static let BatchUpdateDidPerform = Notification.Name(rawValue: "BatchUpdateDidPerform")
}
/// A class for batch updating.
public final class BatchUpdate {
/// The shared batch update object.
public static let shared = BatchUpdate()
private var count = 0
/// Is updating in progress?
public var isPerforming: Bool {
precondition(Thread.isMainThread)
return count > 0
}
/// Perform a batch update.
public func perform(_ batchUpdateBlock: BatchUpdateBlock) {
precondition(Thread.isMainThread)
incrementCount()
batchUpdateBlock()
decrementCount()
}
/// Start batch updates.
public func start() {
precondition(Thread.isMainThread)
incrementCount()
}
/// End batch updates.
public func end() {
precondition(Thread.isMainThread)
decrementCount()
}
}
private extension BatchUpdate {
func incrementCount() {
count = count + 1
}
func decrementCount() {
count = count - 1
if count < 1 {
assert(count > -1, "Expected batch updates count to be 0 or greater.")
count = 0
postBatchUpdateDidPerform()
}
}
func postBatchUpdateDidPerform() {
if !Thread.isMainThread {
DispatchQueue.main.sync {
NotificationCenter.default.post(name: .BatchUpdateDidPerform, object: nil, userInfo: nil)
}
} else {
NotificationCenter.default.post(name: .BatchUpdateDidPerform, object: nil, userInfo: nil)
}
}
}

View File

@ -0,0 +1,74 @@
//
// BinaryDiskCache.swift
// RSCore
//
// Created by Brent Simmons on 11/24/17.
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.
//
import Foundation
// Thread safety is up to the caller.
public struct BinaryDiskCache {
public let folder: String
public init(folder: String) {
self.folder = folder
}
public func data(forKey key: String) throws -> Data? {
let url = urlForKey(key)
return try Data(contentsOf: url)
}
public func setData(_ data: Data, forKey key: String) throws {
let url = urlForKey(key)
try data.write(to: url)
}
public func deleteData(forKey key: String) throws {
let url = urlForKey(key)
try FileManager.default.removeItem(at: url)
}
// subscript doesnt throw, for cases when you can ignore errors.
public subscript(_ key: String) -> Data? {
get {
do {
return try data(forKey: key)
}
catch {}
return nil
}
set {
if let data = newValue {
do {
try setData(data, forKey: key)
}
catch {}
}
else {
do {
try deleteData(forKey: key)
}
catch{}
}
}
}
}
private extension BinaryDiskCache {
func filePath(forKey key: String) -> String {
return (folder as NSString).appendingPathComponent(key)
}
func urlForKey(_ key: String) -> URL {
let f = filePath(forKey: key)
return URL(fileURLWithPath: f)
}
}

View File

@ -0,0 +1,23 @@
//
// Blocks.swift
// RSCore
//
// Created by Brent Simmons on 11/29/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Foundation
public typealias VoidBlock = () -> Void
public typealias VoidCompletionBlock = VoidBlock
/// Call a VoidCompletionBlock on the main thread.
/// - Parameter block: The block to call.
public func callVoidCompletionBlock(_ block: @escaping VoidCompletionBlock) {
DispatchQueue.main.async(execute: block)
}
public typealias VoidResult = Result<Void, Error>
public typealias VoidResultCompletionBlock = (VoidResult) -> Void
public typealias ImageResultBlock = (RSImage?) -> Void

View File

@ -0,0 +1,97 @@
//
// CoalescingQueue.swift
// RSCore
//
// Created by Brent Simmons on 2/17/18.
// Copyright © 2018 Ranchero Software, LLC. All rights reserved.
//
import Foundation
// Use when you want to coalesce calls for something like updating visible table cells.
// Calls are uniqued. If you add a call with the same target and selector as a previous call, youll just get one call.
// Targets are weakly-held. If a target goes to nil, the call is not performed.
// The perform date is pushed off every time a call is added.
// Calls are FIFO.
struct QueueCall: Equatable {
weak var target: AnyObject?
let selector: Selector
func perform() {
let _ = target?.perform(selector)
}
static func ==(lhs: QueueCall, rhs: QueueCall) -> Bool {
return lhs.target === rhs.target && lhs.selector == rhs.selector
}
}
@objc public final class CoalescingQueue: NSObject {
public static let standard = CoalescingQueue(name: "Standard", interval: 0.05, maxInterval: 0.1)
public let name: String
public var isPaused = false
private let interval: TimeInterval
private let maxInterval: TimeInterval
private var lastCallTime = Date.distantFuture
private var timer: Timer? = nil
private var calls = [QueueCall]()
public init(name: String, interval: TimeInterval = 0.05, maxInterval: TimeInterval = 2.0) {
self.name = name
self.interval = interval
self.maxInterval = maxInterval
}
public func add(_ target: AnyObject, _ selector: Selector) {
let queueCall = QueueCall(target: target, selector: selector)
add(queueCall)
if Date().timeIntervalSince1970 - lastCallTime.timeIntervalSince1970 > maxInterval {
timerDidFire(nil)
}
}
public func performCallsImmediately() {
guard !isPaused else { return }
let callsToMake = calls // Make a copy in case calls are added to the queue while performing calls.
resetCalls()
callsToMake.forEach { $0.perform() }
}
@objc func timerDidFire(_ sender: Any?) {
lastCallTime = Date()
performCallsImmediately()
}
}
private extension CoalescingQueue {
func add(_ call: QueueCall) {
restartTimer()
if !calls.contains(call) {
calls.append(call)
}
}
func resetCalls() {
calls = [QueueCall]()
}
func restartTimer() {
invalidateTimer()
timer = Timer.scheduledTimer(timeInterval: interval, target: self, selector: #selector(timerDidFire(_:)), userInfo: nil, repeats: false)
}
func invalidateTimer() {
if let timer = timer, timer.isValid {
timer.invalidate()
}
timer = nil
}
}

View File

@ -0,0 +1,29 @@
//
// DisplayNameProviderProtocol.swift
// DataModel
//
// Created by Brent Simmons on 7/28/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
import Foundation
extension Notification.Name {
public static let DisplayNameDidChange = Notification.Name("DisplayNameDidChange")
}
/// A type that provides a name for display to the user.
public protocol DisplayNameProvider {
var nameForDisplay: String { get }
}
public extension DisplayNameProvider {
func postDisplayNameDidChangeNotification() {
NotificationCenter.default.post(name: .DisplayNameDidChange, object: self, userInfo: nil)
}
}

View File

@ -0,0 +1,86 @@
//
// MacroProcessor.swift
// RSCore
//
// Created by Nate Weaver on 2020-01-01.
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
//
import Foundation
enum MacroProcessorError: Error {
case emptyMacroDelimiter
}
public class MacroProcessor {
let template: String
let substitutions: [String: String]
let macroStart: String
let macroEnd: String
lazy var renderedText: String = processMacros()
/// Parses a template string and replaces macros with specified values.
///
/// - Returns: A copy of `template` with defined macros replaced by their values.
/// Macros with undefined values are left as-is.
///
/// - Parameters:
/// - template: The template string to parse, with macros surrounded by `macroStart` and `macroEnd`.
/// - substitutions: A dictionary mapping macro keys to their replacement values.
/// - macroStart: A string denoting the beginning of a macro.
/// - macroEnd: A string denoting the end of a macro.
///
/// - Throws: An error of type `MacroProcessorError`.
public static func renderedText(withTemplate template: String, substitutions: [String: String], macroStart: String = "[[", macroEnd: String = "]]") throws -> String {
let processor = try MacroProcessor(template: template, substitutions: substitutions, macroStart: macroStart, macroEnd: macroEnd)
return processor.renderedText
}
init(template: String, substitutions: [String: String], macroStart: String = "[[", macroEnd: String = "]]") throws {
if macroStart.isEmpty || macroEnd.isEmpty {
throw MacroProcessorError.emptyMacroDelimiter
}
self.template = template
self.substitutions = substitutions
self.macroStart = macroStart
self.macroEnd = macroEnd
}
}
private extension MacroProcessor {
func processMacros() -> String {
var result = String()
var index = template.startIndex
while true {
guard let macroStartRange = template[index...].range(of: macroStart) else {
break
}
result.append(contentsOf: template[index..<macroStartRange.lowerBound])
guard let macroEndRange = template[macroStartRange.upperBound...].range(of: macroEnd) else {
index = macroStartRange.lowerBound
break
}
let key = String(template[macroStartRange.upperBound..<macroEndRange.lowerBound])
let replacement = substitutions[key] ?? "\(macroStart)\(key)\(macroEnd)"
result.append(contentsOf: replacement)
index = macroEndRange.upperBound
}
result.append(contentsOf: template[index...])
return result
}
}

View File

@ -0,0 +1,33 @@
//
// MainThreadBlockOperation.swift
// RSCore
//
// Created by Brent Simmons on 1/16/20.
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
//
import Foundation
/// Run a block of code as an operation.
///
/// This also serves as a simple example implementation of MainThreadOperation.
public final class MainThreadBlockOperation: MainThreadOperation {
// MainThreadOperation
public var isCanceled = false
public var id: Int?
public var operationDelegate: MainThreadOperationDelegate?
public var name: String?
public var completionBlock: MainThreadOperation.MainThreadOperationCompletionBlock?
private let block: VoidBlock
public init(block: @escaping VoidBlock) {
self.block = block
}
public func run() {
block()
informOperationDelegateOfCompletion()
}
}

View File

@ -0,0 +1,99 @@
//
// MainThreadOperation.swift
// RSCore
//
// Created by Brent Simmons on 1/10/20.
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
//
import Foundation
/// Code to be run by MainThreadOperationQueue.
///
/// When finished, it must call operationDelegate.operationDidComplete(self).
/// If its canceled, it should not call the delegate.
/// When its canceled, it should do its best to stop
/// doing whatever its doing. However, it should not
/// leave data in an inconsistent state.
public protocol MainThreadOperation: class {
// These three properties are set by MainThreadOperationQueue. Dont set them.
var isCanceled: Bool { get set } // Check this at appropriate times in case the operation has been canceled.
var id: Int? { get set }
var operationDelegate: MainThreadOperationDelegate? { get set } // Make this weak.
/// Name may be useful for debugging. Unused otherwise.
var name: String? { get set }
typealias MainThreadOperationCompletionBlock = (MainThreadOperation) -> Void
/// Called when the operation completes.
///
/// The completionBlock is called
/// even if the operation was canceled. The completionBlock
/// takes the operation as parameter, so you can inspect it as needed.
///
/// Implementations of MainThreadOperation are *not* responsible
/// for calling the completionBlock  MainThreadOperationQueue
/// handles that.
///
/// The completionBlock is always called on the main thread.
/// The queue will clear the completionBlock after calling it.
var completionBlock: MainThreadOperationCompletionBlock? { get set }
/// Do the thing this operation does.
///
/// This code runs on the main thread. If you want to run
/// code off of the main thread, you can use the standard mechanisms:
/// a DispatchQueue, most likely.
///
/// When this is called, you dont need to check isCanceled:
/// its guaranteed to not be canceled. However, if you run code
/// in another thread, you should check isCanceled in that code.
func run()
/// Cancel this operation.
///
/// Any operations dependent on this operation
/// will also be canceled automatically.
///
/// This function has a default implementation. Its super-rare
/// to need to provide your own.
func cancel()
/// Make this operation dependent on an other operation.
///
/// This means the other operation must complete before
/// this operation gets run. If the other operation is canceled,
/// this operation will automatically be canceled.
/// Note: an operation can have multiple dependencies.
///
/// This function has a default implementation. Its super-rare
/// to need to provide your own.
func addDependency(_ parentOperation: MainThreadOperation)
}
public extension MainThreadOperation {
func cancel() {
operationDelegate?.cancelOperation(self)
}
func addDependency(_ parentOperation: MainThreadOperation) {
operationDelegate?.make(self, dependOn: parentOperation)
}
func informOperationDelegateOfCompletion() {
guard !isCanceled else {
return
}
if Thread.isMainThread {
operationDelegate?.operationDidComplete(self)
}
else {
DispatchQueue.main.async {
self.informOperationDelegateOfCompletion()
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More