Replace RSCore with several local modules. Update code as needed.
This commit is contained in:
parent
d0760f3d12
commit
2461e937bf
|
@ -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",
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -7,7 +7,6 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import RSCore
|
||||
import RSWeb
|
||||
import Articles
|
||||
import ArticlesDatabase
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
import os.log
|
||||
import RSCore
|
||||
import Core
|
||||
|
||||
final class AccountMetadataFile {
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -10,8 +10,8 @@ import Foundation
|
|||
import os.log
|
||||
import RSWeb
|
||||
import CloudKit
|
||||
import RSCore
|
||||
import Articles
|
||||
import CloudKitExtras
|
||||
|
||||
class CloudKitAcountZoneDelegate: CloudKitZoneDelegate {
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
import os.log
|
||||
import RSCore
|
||||
import Core
|
||||
|
||||
class CloudKitReceiveStatusOperation: MainThreadOperation {
|
||||
|
||||
|
|
|
@ -7,9 +7,8 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
import os.log
|
||||
import RSCore
|
||||
import Core
|
||||
|
||||
class CloudKitRemoteNotificationOperation: MainThreadOperation {
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
||||
|
|
|
@ -8,7 +8,6 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import RSCore
|
||||
import Articles
|
||||
|
||||
extension Notification.Name {
|
||||
|
|
|
@ -7,9 +7,9 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import RSCore
|
||||
import RSWeb
|
||||
import Articles
|
||||
import Core
|
||||
|
||||
public final class Feed: Renamable, DisplayNameProvider, UnreadCountProvider, Hashable {
|
||||
|
||||
|
|
|
@ -9,7 +9,6 @@
|
|||
import Foundation
|
||||
import RSParser
|
||||
import RSWeb
|
||||
import RSCore
|
||||
|
||||
class FeedFinder {
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
import os.log
|
||||
import RSCore
|
||||
import Core
|
||||
|
||||
final class FeedMetadataFile {
|
||||
|
||||
|
|
|
@ -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."
|
||||
|
|
|
@ -8,7 +8,6 @@
|
|||
|
||||
import Foundation
|
||||
import RSParser
|
||||
import RSCore
|
||||
|
||||
final class FeedbinEntry: Decodable {
|
||||
|
||||
|
|
|
@ -7,7 +7,6 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import RSCore
|
||||
import RSParser
|
||||
|
||||
struct FeedbinSubscription: Hashable, Codable {
|
||||
|
|
|
@ -7,7 +7,6 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import RSCore
|
||||
import RSWeb
|
||||
import Secrets
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -9,8 +9,8 @@
|
|||
import Foundation
|
||||
import os.log
|
||||
import RSWeb
|
||||
import RSCore
|
||||
import Secrets
|
||||
import Core
|
||||
|
||||
class FeedlyAddExistingFeedOperation: FeedlyOperation, FeedlyOperationDelegate, FeedlyCheckpointOperationDelegate {
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -8,8 +8,8 @@
|
|||
|
||||
import Foundation
|
||||
import os.log
|
||||
import RSCore
|
||||
import RSWeb
|
||||
import Core
|
||||
|
||||
class FeedlyDownloadArticlesOperation: FeedlyOperation {
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
import RSWeb
|
||||
import RSCore
|
||||
import Core
|
||||
|
||||
protocol FeedlyOperationDelegate: AnyObject {
|
||||
func feedlyOperation(_ operation: FeedlyOperation, didFailWith error: Error)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
import Articles
|
||||
import RSCore
|
||||
import Core
|
||||
|
||||
public final class Folder: Renamable, Container, DisplayNameProvider, UnreadCountProvider, Hashable {
|
||||
|
||||
|
|
|
@ -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."
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -8,12 +8,12 @@
|
|||
//
|
||||
|
||||
import Articles
|
||||
import RSCore
|
||||
import Database
|
||||
import RSParser
|
||||
import RSWeb
|
||||
import SyncDatabase
|
||||
import os.log
|
||||
import Core
|
||||
|
||||
extension NewsBlurAccountDelegate {
|
||||
|
||||
|
|
|
@ -7,7 +7,6 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import RSCore
|
||||
import RSParser
|
||||
|
||||
typealias NewsBlurFolder = NewsBlurFeedsResponse.Folder
|
||||
|
|
|
@ -7,7 +7,6 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import RSCore
|
||||
import RSParser
|
||||
|
||||
typealias NewsBlurStory = NewsBlurStoriesResponse.Story
|
||||
|
|
|
@ -7,7 +7,6 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import RSCore
|
||||
import RSParser
|
||||
|
||||
typealias NewsBlurStoryHash = NewsBlurStoryHashesResponse.StoryHash
|
||||
|
|
|
@ -7,7 +7,6 @@
|
|||
//
|
||||
|
||||
import Articles
|
||||
import RSCore
|
||||
import Database
|
||||
import RSParser
|
||||
import RSWeb
|
||||
|
|
|
@ -8,8 +8,8 @@
|
|||
|
||||
import Foundation
|
||||
import os.log
|
||||
import RSCore
|
||||
import RSParser
|
||||
import Core
|
||||
|
||||
final class OPMLFile {
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -8,7 +8,6 @@
|
|||
|
||||
import Foundation
|
||||
import RSParser
|
||||
import RSCore
|
||||
|
||||
struct ReaderAPIEntryWrapper: Codable {
|
||||
let id: String
|
||||
|
|
|
@ -7,7 +7,6 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import RSCore
|
||||
import RSParser
|
||||
|
||||
/*
|
||||
|
|
|
@ -8,7 +8,6 @@
|
|||
|
||||
import XCTest
|
||||
@testable import Account
|
||||
import RSCore
|
||||
|
||||
class FeedlyCheckpointOperationTests: XCTestCase {
|
||||
|
||||
|
|
|
@ -8,7 +8,6 @@
|
|||
|
||||
import XCTest
|
||||
@testable import Account
|
||||
import RSCore
|
||||
|
||||
class FeedlyCreateFeedsForCollectionFoldersOperationTests: XCTestCase {
|
||||
|
||||
|
|
|
@ -9,7 +9,6 @@
|
|||
import XCTest
|
||||
@testable import Account
|
||||
import os.log
|
||||
import RSCore
|
||||
|
||||
class FeedlyGetCollectionsOperationTests: XCTestCase {
|
||||
|
||||
|
|
|
@ -8,7 +8,6 @@
|
|||
|
||||
import XCTest
|
||||
@testable import Account
|
||||
import RSCore
|
||||
|
||||
class FeedlyGetStreamContentsOperationTests: XCTestCase {
|
||||
|
||||
|
|
|
@ -8,7 +8,6 @@
|
|||
|
||||
import XCTest
|
||||
@testable import Account
|
||||
import RSCore
|
||||
|
||||
class FeedlyGetStreamIdsOperationTests: XCTestCase {
|
||||
|
||||
|
|
|
@ -8,7 +8,6 @@
|
|||
|
||||
import XCTest
|
||||
@testable import Account
|
||||
import RSCore
|
||||
import Secrets
|
||||
|
||||
class FeedlyLogoutOperationTests: XCTestCase {
|
||||
|
|
|
@ -8,7 +8,6 @@
|
|||
|
||||
import XCTest
|
||||
@testable import Account
|
||||
import RSCore
|
||||
|
||||
class FeedlyMirrorCollectionsAsFoldersOperationTests: XCTestCase {
|
||||
|
||||
|
|
|
@ -9,7 +9,6 @@
|
|||
import XCTest
|
||||
@testable import Account
|
||||
import RSWeb
|
||||
import RSCore
|
||||
|
||||
class FeedlyOperationTests: XCTestCase {
|
||||
|
||||
|
|
|
@ -9,7 +9,6 @@
|
|||
import XCTest
|
||||
@testable import Account
|
||||
import RSParser
|
||||
import RSCore
|
||||
|
||||
class FeedlyOrganiseParsedItemsByFeedOperationTests: XCTestCase {
|
||||
|
||||
|
|
|
@ -9,7 +9,6 @@
|
|||
import XCTest
|
||||
@testable import Account
|
||||
import RSWeb
|
||||
import RSCore
|
||||
import Secrets
|
||||
|
||||
class FeedlyRefreshAccessTokenOperationTests: XCTestCase {
|
||||
|
|
|
@ -10,7 +10,6 @@ import XCTest
|
|||
@testable import Account
|
||||
import SyncDatabase
|
||||
import Articles
|
||||
import RSCore
|
||||
|
||||
class FeedlySendArticleStatusesOperationTests: XCTestCase {
|
||||
|
||||
|
|
|
@ -8,7 +8,6 @@
|
|||
|
||||
import XCTest
|
||||
@testable import Account
|
||||
import RSCore
|
||||
|
||||
class FeedlySyncStreamContentsOperationTests: XCTestCase {
|
||||
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/configuration/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
|
@ -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"]),
|
||||
]
|
||||
)
|
|
@ -0,0 +1,2 @@
|
|||
// The Swift Programming Language
|
||||
// https://docs.swift.org/swift-book
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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, we’re 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
|
||||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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 there’s no guarantee that the actual path will be respected later.
|
||||
/// (In other words, you can’t 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import RSCore
|
||||
import FoundationExtras
|
||||
|
||||
class DatabaseIDCache: @unchecked Sendable {
|
||||
|
||||
|
|
|
@ -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"
|
||||
]),
|
||||
]
|
||||
)
|
||||
|
|
|
@ -7,7 +7,6 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import RSCore
|
||||
import Database
|
||||
import RSParser
|
||||
import Articles
|
||||
|
|
|
@ -7,7 +7,6 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import RSCore
|
||||
import Database
|
||||
import Articles
|
||||
import RSParser
|
||||
|
|
|
@ -7,7 +7,6 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import RSCore
|
||||
import Database
|
||||
import Articles
|
||||
import FMDB
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/configuration/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
|
@ -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"]),
|
||||
]
|
||||
)
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/configuration/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
|
@ -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"]),
|
||||
]
|
||||
)
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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 doesn’t 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)
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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, you’ll 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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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 it’s canceled, it should not call the delegate.
|
||||
/// When it’s canceled, it should do its best to stop
|
||||
/// doing whatever it’s doing. However, it should not
|
||||
/// leave data in an inconsistent state.
|
||||
public protocol MainThreadOperation: class {
|
||||
|
||||
// These three properties are set by MainThreadOperationQueue. Don’t 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 don’t need to check isCanceled:
|
||||
/// it’s 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. It’s 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. It’s 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
Loading…
Reference in New Issue