Fix macOS 14 deprecation warnings. Make RSCore a local module.
This commit is contained in:
parent
813500b55a
commit
def4b95fbc
@ -8,6 +8,7 @@
|
||||
|
||||
import AppKit
|
||||
import RSCore
|
||||
import RSCoreObjC
|
||||
import Articles
|
||||
import Account
|
||||
|
||||
@ -200,7 +201,7 @@ private extension TimelineViewController {
|
||||
|
||||
let sortedArticles = articles.sortedByDate(.orderedAscending)
|
||||
let items = sortedArticles.map { ArticlePasteboardWriter(article: $0) }
|
||||
let standardServices = NSSharingService.sharingServices(forItems: items)
|
||||
let standardServices = NSSharingService.sharingServices(forItems_noDeprecationWarning: items) as? [NSSharingService] ?? [NSSharingService]()
|
||||
let customServices = SharingServicePickerDelegate.customSharingServices(for: items)
|
||||
let services = standardServices + customServices
|
||||
if services.isEmpty {
|
||||
|
@ -8,6 +8,7 @@
|
||||
|
||||
import Cocoa
|
||||
import os.log
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
class ShareViewController: NSViewController {
|
||||
|
||||
@ -33,14 +34,14 @@ class ShareViewController: NSViewController {
|
||||
// Try to get any HTML that is maybe passed in
|
||||
for item in self.extensionContext!.inputItems as! [NSExtensionItem] {
|
||||
for itemProvider in item.attachments! {
|
||||
if itemProvider.hasItemConformingToTypeIdentifier(kUTTypePropertyList as String) {
|
||||
if itemProvider.hasItemConformingToTypeIdentifier(UTType.propertyList.identifier) {
|
||||
provider = itemProvider
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if provider != nil {
|
||||
provider!.loadItem(forTypeIdentifier: kUTTypePropertyList as String, options: nil, completionHandler: { [weak self] (pList, error) in
|
||||
provider!.loadItem(forTypeIdentifier: UTType.propertyList.identifier, options: nil, completionHandler: { [weak self] (pList, error) in
|
||||
if error != nil {
|
||||
return
|
||||
}
|
||||
@ -60,14 +61,14 @@ class ShareViewController: NSViewController {
|
||||
// Try to get the URL if it is passed in as a URL
|
||||
for item in self.extensionContext!.inputItems as! [NSExtensionItem] {
|
||||
for itemProvider in item.attachments! {
|
||||
if itemProvider.hasItemConformingToTypeIdentifier(kUTTypeURL as String) {
|
||||
if itemProvider.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
|
||||
provider = itemProvider
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if provider != nil {
|
||||
provider!.loadItem(forTypeIdentifier: kUTTypeURL as String, options: nil, completionHandler: { [weak self] (urlCoded, error) in
|
||||
provider!.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil, completionHandler: { [weak self] (urlCoded, error) in
|
||||
if error != nil {
|
||||
return
|
||||
}
|
||||
|
@ -58,8 +58,6 @@
|
||||
3B826DCC2385C84800FC1ADB /* AccountsFeedWranglerWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826DCA2385C84800FC1ADB /* AccountsFeedWranglerWindowController.swift */; };
|
||||
4679674625E599C100844E8D /* Articles in Frameworks */ = {isa = PBXBuildFile; productRef = 4679674525E599C100844E8D /* Articles */; };
|
||||
4679674725E599C100844E8D /* Articles in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 4679674525E599C100844E8D /* Articles */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
|
||||
4679674925E599C100844E8D /* RSCore in Frameworks */ = {isa = PBXBuildFile; productRef = 4679674825E599C100844E8D /* RSCore */; };
|
||||
4679674A25E599C100844E8D /* RSCore in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 4679674825E599C100844E8D /* RSCore */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
|
||||
49F40DF82335B71000552BF4 /* newsfoot.js in Resources */ = {isa = PBXBuildFile; fileRef = 49F40DEF2335B71000552BF4 /* newsfoot.js */; };
|
||||
49F40DF92335B71000552BF4 /* newsfoot.js in Resources */ = {isa = PBXBuildFile; fileRef = 49F40DEF2335B71000552BF4 /* newsfoot.js */; };
|
||||
510289CD24519A1D00426DDF /* SelectComboTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510289CC24519A1D00426DDF /* SelectComboTableViewCell.swift */; };
|
||||
@ -128,7 +126,6 @@
|
||||
513277642590FC640064F1E7 /* SyncDatabase in Frameworks */ = {isa = PBXBuildFile; productRef = 513277632590FC640064F1E7 /* SyncDatabase */; };
|
||||
513277652590FC640064F1E7 /* SyncDatabase in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 513277632590FC640064F1E7 /* SyncDatabase */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
|
||||
513277662590FC780064F1E7 /* Secrets in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 51C4CFF524D37DD500AF9874 /* Secrets */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
|
||||
5132778C2590FF1E0064F1E7 /* RSCore in Frameworks */ = {isa = PBXBuildFile; productRef = 5132778B2590FF1E0064F1E7 /* RSCore */; };
|
||||
5132779F2591034D0064F1E7 /* icon.icns in Resources */ = {isa = PBXBuildFile; fileRef = 5132779E2591034D0064F1E7 /* icon.icns */; };
|
||||
5137C2E426F3F52D009EFEDB /* Sepia.nnwtheme in Resources */ = {isa = PBXBuildFile; fileRef = 5137C2E326F3F52D009EFEDB /* Sepia.nnwtheme */; };
|
||||
5137C2E626F3F52D009EFEDB /* Sepia.nnwtheme in Resources */ = {isa = PBXBuildFile; fileRef = 5137C2E326F3F52D009EFEDB /* Sepia.nnwtheme */; };
|
||||
@ -136,8 +133,6 @@
|
||||
51386A8E25673277005F3762 /* AccountCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51386A8D25673276005F3762 /* AccountCell.swift */; };
|
||||
5138E93A24D33E5600AFF0FE /* RSTree in Frameworks */ = {isa = PBXBuildFile; productRef = 5138E93924D33E5600AFF0FE /* RSTree */; };
|
||||
5138E93B24D33E5600AFF0FE /* RSTree in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 5138E93924D33E5600AFF0FE /* RSTree */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
|
||||
5138E94924D3416D00AFF0FE /* RSCore in Frameworks */ = {isa = PBXBuildFile; productRef = 5138E94824D3416D00AFF0FE /* RSCore */; };
|
||||
5138E94A24D3416D00AFF0FE /* RSCore in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 5138E94824D3416D00AFF0FE /* RSCore */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
|
||||
5138E94C24D3417A00AFF0FE /* RSDatabase in Frameworks */ = {isa = PBXBuildFile; productRef = 5138E94B24D3417A00AFF0FE /* RSDatabase */; };
|
||||
5138E94D24D3417A00AFF0FE /* RSDatabase in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 5138E94B24D3417A00AFF0FE /* RSDatabase */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
|
||||
5138E95224D3418100AFF0FE /* RSParser in Frameworks */ = {isa = PBXBuildFile; productRef = 5138E95124D3418100AFF0FE /* RSParser */; };
|
||||
@ -147,8 +142,6 @@
|
||||
513C5CE9232571C2003D4054 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 513C5CE8232571C2003D4054 /* ShareViewController.swift */; };
|
||||
513C5CEC232571C2003D4054 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 513C5CEA232571C2003D4054 /* MainInterface.storyboard */; };
|
||||
513C5CF0232571C2003D4054 /* NetNewsWire iOS Share Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 513C5CE6232571C2003D4054 /* NetNewsWire iOS Share Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
513F325C2593ECF40003048F /* RSCore in Frameworks */ = {isa = PBXBuildFile; productRef = 513F325B2593ECF40003048F /* RSCore */; };
|
||||
513F325D2593ECF40003048F /* RSCore in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 513F325B2593ECF40003048F /* RSCore */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
|
||||
513F32712593EE6F0003048F /* Articles in Frameworks */ = {isa = PBXBuildFile; productRef = 513F32702593EE6F0003048F /* Articles */; };
|
||||
513F32722593EE6F0003048F /* Articles in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 513F32702593EE6F0003048F /* Articles */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
|
||||
513F32742593EE6F0003048F /* ArticlesDatabase in Frameworks */ = {isa = PBXBuildFile; productRef = 513F32732593EE6F0003048F /* ArticlesDatabase */; };
|
||||
@ -158,8 +151,6 @@
|
||||
513F327A2593EE6F0003048F /* SyncDatabase in Frameworks */ = {isa = PBXBuildFile; productRef = 513F32792593EE6F0003048F /* SyncDatabase */; };
|
||||
513F327B2593EE6F0003048F /* SyncDatabase in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 513F32792593EE6F0003048F /* SyncDatabase */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
|
||||
513F32812593EF180003048F /* Account in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 516B695E24D2F33B00B5702F /* Account */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
|
||||
513F32882593EF8F0003048F /* RSCore in Frameworks */ = {isa = PBXBuildFile; productRef = 513F32872593EF8F0003048F /* RSCore */; };
|
||||
513F32892593EF8F0003048F /* RSCore in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 513F32872593EF8F0003048F /* RSCore */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
|
||||
5141E7392373C18B0013FF27 /* FeedInspectorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5141E7382373C18B0013FF27 /* FeedInspectorViewController.swift */; };
|
||||
5142192A23522B5500E07E2C /* ImageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5142192923522B5500E07E2C /* ImageViewController.swift */; };
|
||||
514219372352510100E07E2C /* ImageScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 514219362352510100E07E2C /* ImageScrollView.swift */; };
|
||||
@ -178,7 +169,6 @@
|
||||
514C16CE24D2E63F009A3AFA /* Account in Frameworks */ = {isa = PBXBuildFile; productRef = 514C16CD24D2E63F009A3AFA /* Account */; };
|
||||
514C16DE24D2EF15009A3AFA /* RSTree in Frameworks */ = {isa = PBXBuildFile; productRef = 514C16DD24D2EF15009A3AFA /* RSTree */; };
|
||||
514C16DF24D2EF15009A3AFA /* RSTree in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 514C16DD24D2EF15009A3AFA /* RSTree */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
|
||||
514C16E124D2EF38009A3AFA /* RSCoreResources in Frameworks */ = {isa = PBXBuildFile; productRef = 514C16E024D2EF38009A3AFA /* RSCoreResources */; };
|
||||
5154368B229404D1005E1CDF /* FaviconGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51EF0F76227716200050506E /* FaviconGenerator.swift */; };
|
||||
515D4FCA23257CB500EE1167 /* Node-Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A97971ED9EFAA007D329B /* Node-Extensions.swift */; };
|
||||
515D4FCC2325815A00EE1167 /* SafariExt.js in Resources */ = {isa = PBXBuildFile; fileRef = 515D4FCB2325815A00EE1167 /* SafariExt.js */; };
|
||||
@ -230,8 +220,6 @@
|
||||
51A1699F235E10D700EB091F /* AboutViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A16995235E10D600EB091F /* AboutViewController.swift */; };
|
||||
51A169A0235E10D700EB091F /* FeedbinAccountViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A16996235E10D700EB091F /* FeedbinAccountViewController.swift */; };
|
||||
51A66685238075AE00CB272D /* AddFeedDefaultContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A66684238075AE00CB272D /* AddFeedDefaultContainer.swift */; };
|
||||
51A737AE24DB19730015FA66 /* RSCore in Frameworks */ = {isa = PBXBuildFile; productRef = 51A737AD24DB19730015FA66 /* RSCore */; };
|
||||
51A737AF24DB19730015FA66 /* RSCore in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 51A737AD24DB19730015FA66 /* RSCore */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
|
||||
51A737BF24DB197F0015FA66 /* RSDatabase in Frameworks */ = {isa = PBXBuildFile; productRef = 51A737BE24DB197F0015FA66 /* RSDatabase */; };
|
||||
51A737C024DB197F0015FA66 /* RSDatabase in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 51A737BE24DB197F0015FA66 /* RSDatabase */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
|
||||
51A737C524DB19B50015FA66 /* RSWeb in Frameworks */ = {isa = PBXBuildFile; productRef = 51A737C424DB19B50015FA66 /* RSWeb */; };
|
||||
@ -400,6 +388,23 @@
|
||||
840958632201629A002C1579 /* Subscribe to Feed.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 6581C73320CED60000F4AD34 /* Subscribe to Feed.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
840BEE4121D70E64009BBAFA /* CrashReportWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 840BEE4021D70E64009BBAFA /* CrashReportWindowController.swift */; };
|
||||
840D617F2029031C009BC708 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 840D617E2029031C009BC708 /* AppDelegate.swift */; };
|
||||
8413876D2CD8970B00E8490F /* RSCore in Frameworks */ = {isa = PBXBuildFile; productRef = 8413876C2CD8970B00E8490F /* RSCore */; };
|
||||
8413876E2CD8970B00E8490F /* RSCore in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 8413876C2CD8970B00E8490F /* RSCore */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
|
||||
841387702CD8970B00E8490F /* RSCoreObjC in Frameworks */ = {isa = PBXBuildFile; productRef = 8413876F2CD8970B00E8490F /* RSCoreObjC */; };
|
||||
841387712CD8970B00E8490F /* RSCoreObjC in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 8413876F2CD8970B00E8490F /* RSCoreObjC */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
|
||||
841387732CD8970B00E8490F /* RSCoreResources in Frameworks */ = {isa = PBXBuildFile; productRef = 841387722CD8970B00E8490F /* RSCoreResources */; };
|
||||
841387752CD897C500E8490F /* RSCore in Frameworks */ = {isa = PBXBuildFile; productRef = 841387742CD897C500E8490F /* RSCore */; };
|
||||
841387762CD897C500E8490F /* RSCore in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 841387742CD897C500E8490F /* RSCore */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
|
||||
841387782CD897C500E8490F /* RSCoreObjC in Frameworks */ = {isa = PBXBuildFile; productRef = 841387772CD897C500E8490F /* RSCoreObjC */; };
|
||||
841387792CD897C500E8490F /* RSCoreObjC in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 841387772CD897C500E8490F /* RSCoreObjC */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
|
||||
8413877B2CD897CF00E8490F /* RSCore in Frameworks */ = {isa = PBXBuildFile; productRef = 8413877A2CD897CF00E8490F /* RSCore */; };
|
||||
8413877C2CD897CF00E8490F /* RSCore in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 8413877A2CD897CF00E8490F /* RSCore */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
|
||||
8413877E2CD897CF00E8490F /* RSCoreObjC in Frameworks */ = {isa = PBXBuildFile; productRef = 8413877D2CD897CF00E8490F /* RSCoreObjC */; };
|
||||
8413877F2CD897CF00E8490F /* RSCoreObjC in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 8413877D2CD897CF00E8490F /* RSCoreObjC */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
|
||||
841387812CD897EF00E8490F /* RSCore in Frameworks */ = {isa = PBXBuildFile; productRef = 841387802CD897EF00E8490F /* RSCore */; };
|
||||
841387822CD897EF00E8490F /* RSCore in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 841387802CD897EF00E8490F /* RSCore */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
|
||||
841387842CD897EF00E8490F /* RSCoreObjC in Frameworks */ = {isa = PBXBuildFile; productRef = 841387832CD897EF00E8490F /* RSCoreObjC */; };
|
||||
841387852CD897EF00E8490F /* RSCoreObjC in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 841387832CD897EF00E8490F /* RSCoreObjC */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
|
||||
84162A152038C12C00035290 /* MarkCommandValidationStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84162A142038C12C00035290 /* MarkCommandValidationStatus.swift */; };
|
||||
841ABA4E20145E7300980E11 /* NothingInspectorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 841ABA4D20145E7300980E11 /* NothingInspectorViewController.swift */; };
|
||||
841ABA5E20145E9200980E11 /* FolderInspectorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 841ABA5D20145E9200980E11 /* FolderInspectorViewController.swift */; };
|
||||
@ -639,7 +644,6 @@
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 10;
|
||||
files = (
|
||||
4679674A25E599C100844E8D /* RSCore in Embed Frameworks */,
|
||||
4679674725E599C100844E8D /* Articles in Embed Frameworks */,
|
||||
);
|
||||
name = "Embed Frameworks";
|
||||
@ -651,7 +655,8 @@
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 10;
|
||||
files = (
|
||||
513F325D2593ECF40003048F /* RSCore in Embed Frameworks */,
|
||||
8413877C2CD897CF00E8490F /* RSCore in Embed Frameworks */,
|
||||
8413877F2CD897CF00E8490F /* RSCoreObjC in Embed Frameworks */,
|
||||
51BC2F4924D3439E00E90810 /* RSTree in Embed Frameworks */,
|
||||
);
|
||||
name = "Embed Frameworks";
|
||||
@ -663,7 +668,6 @@
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 10;
|
||||
files = (
|
||||
513F32892593EF8F0003048F /* RSCore in Embed Frameworks */,
|
||||
51BC2F4E24D343AB00E90810 /* RSTree in Embed Frameworks */,
|
||||
);
|
||||
name = "Embed Frameworks";
|
||||
@ -675,6 +679,8 @@
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 10;
|
||||
files = (
|
||||
841387852CD897EF00E8490F /* RSCoreObjC in Embed Frameworks */,
|
||||
841387822CD897EF00E8490F /* RSCore in Embed Frameworks */,
|
||||
);
|
||||
name = "Embed Frameworks";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@ -698,15 +704,16 @@
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 10;
|
||||
files = (
|
||||
841387762CD897C500E8490F /* RSCore in Embed Frameworks */,
|
||||
513F32782593EE6F0003048F /* Secrets in Embed Frameworks */,
|
||||
5138E95324D3418100AFF0FE /* RSParser in Embed Frameworks */,
|
||||
5138E94A24D3416D00AFF0FE /* RSCore in Embed Frameworks */,
|
||||
5138E95924D3419000AFF0FE /* RSWeb in Embed Frameworks */,
|
||||
513F327B2593EE6F0003048F /* SyncDatabase in Embed Frameworks */,
|
||||
513F32722593EE6F0003048F /* Articles in Embed Frameworks */,
|
||||
513F32812593EF180003048F /* Account in Embed Frameworks */,
|
||||
5138E93B24D33E5600AFF0FE /* RSTree in Embed Frameworks */,
|
||||
5138E94D24D3417A00AFF0FE /* RSDatabase in Embed Frameworks */,
|
||||
841387792CD897C500E8490F /* RSCoreObjC in Embed Frameworks */,
|
||||
513F32752593EE6F0003048F /* ArticlesDatabase in Embed Frameworks */,
|
||||
);
|
||||
name = "Embed Frameworks";
|
||||
@ -744,6 +751,7 @@
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 10;
|
||||
files = (
|
||||
8413876E2CD8970B00E8490F /* RSCore in Embed Frameworks */,
|
||||
513277442590FBB60064F1E7 /* Account in Embed Frameworks */,
|
||||
5132775F2590FC640064F1E7 /* Articles in Embed Frameworks */,
|
||||
51A737C624DB19B50015FA66 /* RSWeb in Embed Frameworks */,
|
||||
@ -751,8 +759,8 @@
|
||||
513277662590FC780064F1E7 /* Secrets in Embed Frameworks */,
|
||||
513277652590FC640064F1E7 /* SyncDatabase in Embed Frameworks */,
|
||||
513277622590FC640064F1E7 /* ArticlesDatabase in Embed Frameworks */,
|
||||
51A737AF24DB19730015FA66 /* RSCore in Embed Frameworks */,
|
||||
51A737C924DB19CC0015FA66 /* RSParser in Embed Frameworks */,
|
||||
841387712CD8970B00E8490F /* RSCoreObjC in Embed Frameworks */,
|
||||
514C16DF24D2EF15009A3AFA /* RSTree in Embed Frameworks */,
|
||||
);
|
||||
name = "Embed Frameworks";
|
||||
@ -959,10 +967,10 @@
|
||||
51C452B32265141B00C03939 /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS12.2.sdk/System/Library/Frameworks/WebKit.framework; sourceTree = DEVELOPER_DIR; };
|
||||
51C4CFEF24D37D1F00AF9874 /* Secrets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Secrets.swift; sourceTree = "<group>"; };
|
||||
51C9DE5723EA2EF4003D5A6D /* WrapperScriptMessageHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrapperScriptMessageHandler.swift; sourceTree = "<group>"; };
|
||||
51CD32A824D2CB25009ABAEF /* SyncDatabase */ = {isa = PBXFileReference; lastKnownFileType = folder; path = SyncDatabase; sourceTree = "<group>"; };
|
||||
51CD32A824D2CB25009ABAEF /* SyncDatabase */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = SyncDatabase; sourceTree = "<group>"; };
|
||||
51CD32C324D2CD57009ABAEF /* ArticlesDatabase */ = {isa = PBXFileReference; lastKnownFileType = folder; path = ArticlesDatabase; sourceTree = "<group>"; };
|
||||
51CD32C424D2CF1D009ABAEF /* Articles */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Articles; sourceTree = "<group>"; };
|
||||
51CD32C624D2DEF9009ABAEF /* Account */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Account; sourceTree = "<group>"; };
|
||||
51CD32C624D2DEF9009ABAEF /* Account */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Account; sourceTree = "<group>"; };
|
||||
51CD32C724D2E06C009ABAEF /* Secrets */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Secrets; sourceTree = "<group>"; };
|
||||
51CE1C0823621EDA005548FC /* RefreshProgressView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = RefreshProgressView.xib; sourceTree = "<group>"; };
|
||||
51CE1C0A23622006005548FC /* RefreshProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshProgressView.swift; sourceTree = "<group>"; };
|
||||
@ -1039,6 +1047,7 @@
|
||||
840D617E2029031C009BC708 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
840D61952029031D009BC708 /* NetNewsWire_iOSTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetNewsWire_iOSTests.swift; sourceTree = "<group>"; };
|
||||
840D61972029031D009BC708 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
8413876B2CD896E000E8490F /* RSCore */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = RSCore; sourceTree = "<group>"; };
|
||||
84162A142038C12C00035290 /* MarkCommandValidationStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkCommandValidationStatus.swift; sourceTree = "<group>"; };
|
||||
841ABA4D20145E7300980E11 /* NothingInspectorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NothingInspectorViewController.swift; sourceTree = "<group>"; };
|
||||
841ABA5D20145E9200980E11 /* FolderInspectorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderInspectorViewController.swift; sourceTree = "<group>"; };
|
||||
@ -1235,7 +1244,8 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5132778C2590FF1E0064F1E7 /* RSCore in Frameworks */,
|
||||
841387812CD897EF00E8490F /* RSCore in Frameworks */,
|
||||
841387842CD897EF00E8490F /* RSCoreObjC in Frameworks */,
|
||||
511B148924E5DBDD00C919BD /* Account in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@ -1245,7 +1255,6 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
27B86EEB25A53AAB00264340 /* Account in Frameworks */,
|
||||
513F32882593EF8F0003048F /* RSCore in Frameworks */,
|
||||
51BC2F4D24D343AB00E90810 /* RSTree in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@ -1255,7 +1264,8 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
51BC2F3824D3439A00E90810 /* Account in Frameworks */,
|
||||
513F325C2593ECF40003048F /* RSCore in Frameworks */,
|
||||
8413877B2CD897CF00E8490F /* RSCore in Frameworks */,
|
||||
8413877E2CD897CF00E8490F /* RSCoreObjC in Frameworks */,
|
||||
51BC2F4824D3439E00E90810 /* RSTree in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@ -1278,10 +1288,11 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5138E94924D3416D00AFF0FE /* RSCore in Frameworks */,
|
||||
841387752CD897C500E8490F /* RSCore in Frameworks */,
|
||||
5138E95824D3419000AFF0FE /* RSWeb in Frameworks */,
|
||||
179D280B26F6F93D003B2E0A /* Zip in Frameworks */,
|
||||
516B695F24D2F33B00B5702F /* Account in Frameworks */,
|
||||
841387782CD897C500E8490F /* RSCoreObjC in Frameworks */,
|
||||
5138E95224D3418100AFF0FE /* RSParser in Frameworks */,
|
||||
5138E94C24D3417A00AFF0FE /* RSDatabase in Frameworks */,
|
||||
51C452B42265141B00C03939 /* WebKit.framework in Frameworks */,
|
||||
@ -1305,11 +1316,12 @@
|
||||
5132775E2590FC640064F1E7 /* Articles in Frameworks */,
|
||||
513277612590FC640064F1E7 /* ArticlesDatabase in Frameworks */,
|
||||
51C4CFF624D37DD500AF9874 /* Secrets in Frameworks */,
|
||||
51A737AE24DB19730015FA66 /* RSCore in Frameworks */,
|
||||
51A737C824DB19CC0015FA66 /* RSParser in Frameworks */,
|
||||
841387732CD8970B00E8490F /* RSCoreResources in Frameworks */,
|
||||
179C39EA26F76B0500D4E741 /* Zip in Frameworks */,
|
||||
51E4DAED2425F6940091EB5B /* CloudKit.framework in Frameworks */,
|
||||
514C16E124D2EF38009A3AFA /* RSCoreResources in Frameworks */,
|
||||
841387702CD8970B00E8490F /* RSCoreObjC in Frameworks */,
|
||||
8413876D2CD8970B00E8490F /* RSCore in Frameworks */,
|
||||
514C16CE24D2E63F009A3AFA /* Account in Frameworks */,
|
||||
519CA8E525841DB700EB079A /* CrashReporter in Frameworks */,
|
||||
51A737BF24DB197F0015FA66 /* RSDatabase in Frameworks */,
|
||||
@ -1320,7 +1332,6 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
4679674925E599C100844E8D /* RSCore in Frameworks */,
|
||||
4679674625E599C100844E8D /* Articles in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@ -2031,6 +2042,7 @@
|
||||
51CD32C324D2CD57009ABAEF /* ArticlesDatabase */,
|
||||
51CD32C724D2E06C009ABAEF /* Secrets */,
|
||||
51CD32A824D2CB25009ABAEF /* SyncDatabase */,
|
||||
8413876B2CD896E000E8490F /* RSCore */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
usesTabs = 1;
|
||||
@ -2433,7 +2445,8 @@
|
||||
name = "NetNewsWire Share Extension";
|
||||
packageProductDependencies = (
|
||||
511B148824E5DBDD00C919BD /* Account */,
|
||||
5132778B2590FF1E0064F1E7 /* RSCore */,
|
||||
841387802CD897EF00E8490F /* RSCore */,
|
||||
841387832CD897EF00E8490F /* RSCoreObjC */,
|
||||
);
|
||||
productName = ShareExtension;
|
||||
productReference = 510C415C24E5CDE3008226FD /* NetNewsWire Share Extension.appex */;
|
||||
@ -2456,7 +2469,6 @@
|
||||
packageProductDependencies = (
|
||||
51BC2F4A24D343A500E90810 /* Account */,
|
||||
51BC2F4C24D343AB00E90810 /* RSTree */,
|
||||
513F32872593EF8F0003048F /* RSCore */,
|
||||
);
|
||||
productName = "NetNewsWire iOS Intents Extension";
|
||||
productReference = 51314637235A7BBE00387FDC /* NetNewsWire iOS Intents Extension.appex */;
|
||||
@ -2479,7 +2491,8 @@
|
||||
packageProductDependencies = (
|
||||
51BC2F3724D3439A00E90810 /* Account */,
|
||||
51BC2F4724D3439E00E90810 /* RSTree */,
|
||||
513F325B2593ECF40003048F /* RSCore */,
|
||||
8413877A2CD897CF00E8490F /* RSCore */,
|
||||
8413877D2CD897CF00E8490F /* RSCoreObjC */,
|
||||
);
|
||||
productName = "NetNewsWire iOS Share Extension";
|
||||
productReference = 513C5CE6232571C2003D4054 /* NetNewsWire iOS Share Extension.appex */;
|
||||
@ -2543,7 +2556,6 @@
|
||||
packageProductDependencies = (
|
||||
516B695E24D2F33B00B5702F /* Account */,
|
||||
5138E93924D33E5600AFF0FE /* RSTree */,
|
||||
5138E94824D3416D00AFF0FE /* RSCore */,
|
||||
5138E94B24D3417A00AFF0FE /* RSDatabase */,
|
||||
5138E95124D3418100AFF0FE /* RSParser */,
|
||||
5138E95724D3419000AFF0FE /* RSWeb */,
|
||||
@ -2552,6 +2564,8 @@
|
||||
513F32762593EE6F0003048F /* Secrets */,
|
||||
513F32792593EE6F0003048F /* SyncDatabase */,
|
||||
179D280A26F6F93D003B2E0A /* Zip */,
|
||||
841387742CD897C500E8490F /* RSCore */,
|
||||
841387772CD897C500E8490F /* RSCoreObjC */,
|
||||
);
|
||||
productName = "NetNewsWire-iOS";
|
||||
productReference = 840D617C2029031C009BC708 /* NetNewsWire.app */;
|
||||
@ -2582,9 +2596,7 @@
|
||||
packageProductDependencies = (
|
||||
514C16CD24D2E63F009A3AFA /* Account */,
|
||||
514C16DD24D2EF15009A3AFA /* RSTree */,
|
||||
514C16E024D2EF38009A3AFA /* RSCoreResources */,
|
||||
51C4CFF524D37DD500AF9874 /* Secrets */,
|
||||
51A737AD24DB19730015FA66 /* RSCore */,
|
||||
51A737BE24DB197F0015FA66 /* RSDatabase */,
|
||||
51A737C424DB19B50015FA66 /* RSWeb */,
|
||||
51A737C724DB19CC0015FA66 /* RSParser */,
|
||||
@ -2594,6 +2606,9 @@
|
||||
513277602590FC640064F1E7 /* ArticlesDatabase */,
|
||||
513277632590FC640064F1E7 /* SyncDatabase */,
|
||||
179C39E926F76B0500D4E741 /* Zip */,
|
||||
8413876C2CD8970B00E8490F /* RSCore */,
|
||||
8413876F2CD8970B00E8490F /* RSCoreObjC */,
|
||||
841387722CD8970B00E8490F /* RSCoreResources */,
|
||||
);
|
||||
productName = NetNewsWire;
|
||||
productReference = 849C64601ED37A5D003D8FC0 /* NetNewsWire.app */;
|
||||
@ -2617,7 +2632,6 @@
|
||||
name = NetNewsWireTests;
|
||||
packageProductDependencies = (
|
||||
4679674525E599C100844E8D /* Articles */,
|
||||
4679674825E599C100844E8D /* RSCore */,
|
||||
);
|
||||
productName = NetNewsWireTests;
|
||||
productReference = 849C64711ED37A5D003D8FC0 /* NetNewsWireTests.xctest */;
|
||||
@ -2698,7 +2712,6 @@
|
||||
);
|
||||
mainGroup = 849C64571ED37A5D003D8FC0;
|
||||
packageReferences = (
|
||||
5102AE4324D17E820050839C /* XCRemoteSwiftPackageReference "RSCore" */,
|
||||
510ECA4024D1DCD0001C31A6 /* XCRemoteSwiftPackageReference "RSTree" */,
|
||||
51383A3024D1F90E0027E272 /* XCRemoteSwiftPackageReference "RSWeb" */,
|
||||
51B0DF0D24D24E3B000AD99E /* XCRemoteSwiftPackageReference "RSDatabase" */,
|
||||
@ -3877,14 +3890,6 @@
|
||||
revision = 059e7346082d02de16220cd79df7db18ddeba8c3;
|
||||
};
|
||||
};
|
||||
5102AE4324D17E820050839C /* XCRemoteSwiftPackageReference "RSCore" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/Ranchero-Software/RSCore.git";
|
||||
requirement = {
|
||||
kind = upToNextMinorVersion;
|
||||
minimumVersion = 1.0.0;
|
||||
};
|
||||
};
|
||||
510ECA4024D1DCD0001C31A6 /* XCRemoteSwiftPackageReference "RSTree" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/Ranchero-Software/RSTree.git";
|
||||
@ -3952,11 +3957,6 @@
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = Articles;
|
||||
};
|
||||
4679674825E599C100844E8D /* RSCore */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 5102AE4324D17E820050839C /* XCRemoteSwiftPackageReference "RSCore" */;
|
||||
productName = RSCore;
|
||||
};
|
||||
511B148824E5DBDD00C919BD /* Account */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = Account;
|
||||
@ -3973,21 +3973,11 @@
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = SyncDatabase;
|
||||
};
|
||||
5132778B2590FF1E0064F1E7 /* RSCore */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 5102AE4324D17E820050839C /* XCRemoteSwiftPackageReference "RSCore" */;
|
||||
productName = RSCore;
|
||||
};
|
||||
5138E93924D33E5600AFF0FE /* RSTree */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 510ECA4024D1DCD0001C31A6 /* XCRemoteSwiftPackageReference "RSTree" */;
|
||||
productName = RSTree;
|
||||
};
|
||||
5138E94824D3416D00AFF0FE /* RSCore */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 5102AE4324D17E820050839C /* XCRemoteSwiftPackageReference "RSCore" */;
|
||||
productName = RSCore;
|
||||
};
|
||||
5138E94B24D3417A00AFF0FE /* RSDatabase */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 51B0DF0D24D24E3B000AD99E /* XCRemoteSwiftPackageReference "RSDatabase" */;
|
||||
@ -4003,11 +3993,6 @@
|
||||
package = 51383A3024D1F90E0027E272 /* XCRemoteSwiftPackageReference "RSWeb" */;
|
||||
productName = RSWeb;
|
||||
};
|
||||
513F325B2593ECF40003048F /* RSCore */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 5102AE4324D17E820050839C /* XCRemoteSwiftPackageReference "RSCore" */;
|
||||
productName = RSCore;
|
||||
};
|
||||
513F32702593EE6F0003048F /* Articles */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = Articles;
|
||||
@ -4024,11 +4009,6 @@
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = SyncDatabase;
|
||||
};
|
||||
513F32872593EF8F0003048F /* RSCore */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 5102AE4324D17E820050839C /* XCRemoteSwiftPackageReference "RSCore" */;
|
||||
productName = RSCore;
|
||||
};
|
||||
514C16CD24D2E63F009A3AFA /* Account */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = Account;
|
||||
@ -4038,11 +4018,6 @@
|
||||
package = 510ECA4024D1DCD0001C31A6 /* XCRemoteSwiftPackageReference "RSTree" */;
|
||||
productName = RSTree;
|
||||
};
|
||||
514C16E024D2EF38009A3AFA /* RSCoreResources */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 5102AE4324D17E820050839C /* XCRemoteSwiftPackageReference "RSCore" */;
|
||||
productName = RSCoreResources;
|
||||
};
|
||||
516B695E24D2F33B00B5702F /* Account */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = Account;
|
||||
@ -4052,11 +4027,6 @@
|
||||
package = 519CA8E325841DB700EB079A /* XCRemoteSwiftPackageReference "plcrashreporter" */;
|
||||
productName = CrashReporter;
|
||||
};
|
||||
51A737AD24DB19730015FA66 /* RSCore */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 5102AE4324D17E820050839C /* XCRemoteSwiftPackageReference "RSCore" */;
|
||||
productName = RSCore;
|
||||
};
|
||||
51A737BE24DB197F0015FA66 /* RSDatabase */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 51B0DF0D24D24E3B000AD99E /* XCRemoteSwiftPackageReference "RSDatabase" */;
|
||||
@ -4094,6 +4064,42 @@
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = Secrets;
|
||||
};
|
||||
8413876C2CD8970B00E8490F /* RSCore */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = RSCore;
|
||||
};
|
||||
8413876F2CD8970B00E8490F /* RSCoreObjC */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = RSCoreObjC;
|
||||
};
|
||||
841387722CD8970B00E8490F /* RSCoreResources */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = RSCoreResources;
|
||||
};
|
||||
841387742CD897C500E8490F /* RSCore */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = RSCore;
|
||||
};
|
||||
841387772CD897C500E8490F /* RSCoreObjC */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = RSCoreObjC;
|
||||
};
|
||||
8413877A2CD897CF00E8490F /* RSCore */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = RSCore;
|
||||
};
|
||||
8413877D2CD897CF00E8490F /* RSCoreObjC */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = RSCoreObjC;
|
||||
};
|
||||
841387802CD897EF00E8490F /* RSCore */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = RSCore;
|
||||
};
|
||||
841387832CD897EF00E8490F /* RSCoreObjC */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = RSCoreObjC;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 849C64581ED37A5D003D8FC0 /* Project object */;
|
||||
|
@ -10,15 +10,6 @@
|
||||
"version": "1.10.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "RSCore",
|
||||
"repositoryURL": "https://github.com/Ranchero-Software/RSCore.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "060b12a3d3b6d27d57b2fae84160bfec91ec7118",
|
||||
"version": "1.0.7"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "RSDatabase",
|
||||
"repositoryURL": "https://github.com/Ranchero-Software/RSDatabase.git",
|
||||
|
21
RSCore/LICENSE
Executable file
21
RSCore/LICENSE
Executable file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2016 brentsimmons
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
35
RSCore/Package.swift
Normal file
35
RSCore/Package.swift
Normal file
@ -0,0 +1,35 @@
|
||||
// swift-tools-version:5.3
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "RSCore",
|
||||
platforms: [.macOS(SupportedPlatform.MacOSVersion.v10_15), .iOS(SupportedPlatform.IOSVersion.v13)],
|
||||
products: [
|
||||
.library(name: "RSCore", type: .dynamic, targets: ["RSCore"]),
|
||||
.library(name: "RSCoreObjC", type: .dynamic, targets: ["RSCoreObjC"]),
|
||||
.library(name: "RSCoreResources", type: .static, targets: ["RSCoreResources"])
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "RSCore",
|
||||
dependencies: ["RSCoreObjC"]),
|
||||
.target(
|
||||
name: "RSCoreObjC",
|
||||
dependencies: [],
|
||||
cSettings: [
|
||||
.headerSearchPath("include")
|
||||
]
|
||||
),
|
||||
.target(
|
||||
name: "RSCoreResources",
|
||||
resources: [
|
||||
.process("Resources/WebViewWindow.xib"),
|
||||
.process("Resources/IndeterminateProgressWindow.xib")
|
||||
]),
|
||||
.testTarget(
|
||||
name: "RSCoreTests",
|
||||
dependencies: ["RSCore"]),
|
||||
]
|
||||
)
|
8
RSCore/README.md
Executable file
8
RSCore/README.md
Executable file
@ -0,0 +1,8 @@
|
||||
# RSCore
|
||||
Utility code for Mac and iOS apps.
|
||||
|
||||
The `main` branch builds a Mac framework and an iOS framework. The `spm` branch is a Swift Package.
|
||||
|
||||
There’s a whole bunch of stuff in here. There are categories on Foundation and AppKit objects plus a few miscellaneous things.
|
||||
|
||||
(More notes will be coming.)
|
37
RSCore/Sources/RSCore/AppKit/FourCharCode.swift
Normal file
37
RSCore/Sources/RSCore/AppKit/FourCharCode.swift
Normal file
@ -0,0 +1,37 @@
|
||||
//
|
||||
// FourCharCode.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Olof Hellman on 1/7/18.
|
||||
// Copyright © 2018 Olof Hellman. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public extension String {
|
||||
|
||||
/// Converts a string to a `FourCharCode`.
|
||||
///
|
||||
/// `FourCharCode` values like `OSType`, `DescType` or `AEKeyword` are really just
|
||||
/// 4-byte values commonly represented as values like `'odoc'` where each byte is
|
||||
/// represented as its ASCII character. This property turns a Swift string into
|
||||
/// its `FourCharCode` equivalent, as Swift doesn't recognize `FourCharCode` types
|
||||
/// natively just yet. With this extension, one can use `"odoc".fourCharCode`
|
||||
/// where one would really want to use `'odoc'`.
|
||||
var fourCharCode: FourCharCode {
|
||||
precondition(count == 4)
|
||||
var sum: UInt32 = 0
|
||||
for scalar in self.unicodeScalars {
|
||||
sum = (sum * 256) + scalar.value
|
||||
}
|
||||
return sum
|
||||
}
|
||||
}
|
||||
|
||||
public extension Int {
|
||||
|
||||
var fourCharCode: FourCharCode {
|
||||
return UInt32(self)
|
||||
}
|
||||
}
|
||||
|
151
RSCore/Sources/RSCore/AppKit/Keyboard.swift
Normal file
151
RSCore/Sources/RSCore/AppKit/Keyboard.swift
Normal file
@ -0,0 +1,151 @@
|
||||
//
|
||||
// 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])
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
public let shiftKeyDown: Bool
|
||||
public let optionKeyDown: Bool
|
||||
public let commandKeyDown: Bool
|
||||
public let controlKeyDown: Bool
|
||||
public let integerValue: Int // unmodified character as Int
|
||||
|
||||
public var isModified: Bool {
|
||||
return !shiftKeyDown && !optionKeyDown && !commandKeyDown && !controlKeyDown
|
||||
}
|
||||
|
||||
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
|
18
RSCore/Sources/RSCore/AppKit/KeyboardDelegateProtocol.swift
Normal file
18
RSCore/Sources/RSCore/AppKit/KeyboardDelegateProtocol.swift
Normal file
@ -0,0 +1,18 @@
|
||||
//
|
||||
// KeyboardDelegateProtocol.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 10/11/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
//let keypadEnter: unichar = 3
|
||||
|
||||
@objc public protocol KeyboardDelegate: AnyObject {
|
||||
|
||||
// Return true if handled.
|
||||
func keydown(_: NSEvent, in view: NSView) -> Bool
|
||||
}
|
||||
#endif
|
24
RSCore/Sources/RSCore/AppKit/NSAppearance+RSCore.swift
Normal file
24
RSCore/Sources/RSCore/AppKit/NSAppearance+RSCore.swift
Normal file
@ -0,0 +1,24 @@
|
||||
//
|
||||
// NSAppearance+RSCore.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Daniel Jalkut on 8/28/18.
|
||||
// Copyright © 2018 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
extension NSAppearance {
|
||||
|
||||
@objc(rsIsDarkMode)
|
||||
public var isDarkMode: Bool {
|
||||
if #available(macOS 10.14, *) {
|
||||
return self.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
|
||||
}
|
||||
else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
@ -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
|
29
RSCore/Sources/RSCore/AppKit/NSImage+RSCore.swift
Normal file
29
RSCore/Sources/RSCore/AppKit/NSImage+RSCore.swift
Normal file
@ -0,0 +1,29 @@
|
||||
//
|
||||
// NSImage+RSCore.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 12/16/17.
|
||||
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
public extension NSImage {
|
||||
|
||||
func tinted(with color: NSColor) -> NSImage {
|
||||
|
||||
let image = self.copy() as! NSImage
|
||||
|
||||
image.lockFocus()
|
||||
|
||||
color.set()
|
||||
let rect = NSRect(x: 0, y: 0, width: image.size.width, height: image.size.height)
|
||||
rect.fill(using: .sourceAtop)
|
||||
|
||||
image.unlockFocus()
|
||||
|
||||
image.isTemplate = false
|
||||
return image
|
||||
}
|
||||
}
|
||||
#endif
|
31
RSCore/Sources/RSCore/AppKit/NSMenu+Extensions.swift
Normal file
31
RSCore/Sources/RSCore/AppKit/NSMenu+Extensions.swift
Normal file
@ -0,0 +1,31 @@
|
||||
//
|
||||
// NSMenu+Extensions.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 2/9/18.
|
||||
// Copyright © 2018 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
public extension NSMenu {
|
||||
|
||||
func takeItems(from menu: NSMenu) {
|
||||
|
||||
// The passed-in menu gets all its items removed.
|
||||
|
||||
let items = menu.items
|
||||
menu.removeAllItems()
|
||||
for menuItem in items {
|
||||
addItem(menuItem)
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a separator if there are multiple menu items and the last one is not a separator.
|
||||
func addSeparatorIfNeeded() {
|
||||
if items.count > 0 && !items.last!.isSeparatorItem {
|
||||
addItem(NSMenuItem.separator())
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
183
RSCore/Sources/RSCore/AppKit/NSOutlineView+RSCore.swift
Executable file
183
RSCore/Sources/RSCore/AppKit/NSOutlineView+RSCore.swift
Executable file
@ -0,0 +1,183 @@
|
||||
//
|
||||
// NSOutlineView+Extensions.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 9/6/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
public extension NSOutlineView {
|
||||
|
||||
var selectedItems: [AnyObject] {
|
||||
if selectionIsEmpty {
|
||||
return [AnyObject]()
|
||||
}
|
||||
|
||||
return selectedRowIndexes.compactMap { (oneIndex) -> AnyObject? in
|
||||
return item(atRow: oneIndex) as AnyObject
|
||||
}
|
||||
}
|
||||
|
||||
var firstSelectedRow: Int? {
|
||||
|
||||
if selectionIsEmpty {
|
||||
return nil
|
||||
}
|
||||
return selectedRowIndexes.first
|
||||
}
|
||||
|
||||
var lastSelectedRow: Int? {
|
||||
|
||||
if selectionIsEmpty {
|
||||
return nil
|
||||
}
|
||||
return selectedRowIndexes.last
|
||||
}
|
||||
|
||||
@IBAction func selectPreviousRow(_ sender: Any?) {
|
||||
|
||||
guard var row = firstSelectedRow else {
|
||||
return
|
||||
}
|
||||
|
||||
if row < 1 {
|
||||
return
|
||||
}
|
||||
while true {
|
||||
row -= 1
|
||||
if row < 0 {
|
||||
return
|
||||
}
|
||||
if canSelect(row) {
|
||||
selectRowAndScrollToVisible(row)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction func selectNextRow(_ sender: Any?) {
|
||||
|
||||
// If no selectedRow, end up at first selectable row.
|
||||
var row = lastSelectedRow ?? -1
|
||||
|
||||
while true {
|
||||
row += 1
|
||||
if let _ = item(atRow: row) {
|
||||
if canSelect(row) {
|
||||
selectRowAndScrollToVisible(row)
|
||||
return
|
||||
}
|
||||
}
|
||||
else {
|
||||
return // if there are no more items, 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
|
||||
|
63
RSCore/Sources/RSCore/AppKit/NSPasteboard+RSCore.swift
Normal file
63
RSCore/Sources/RSCore/AppKit/NSPasteboard+RSCore.swift
Normal file
@ -0,0 +1,63 @@
|
||||
//
|
||||
// NSPasteboard+RSCore.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 2/11/18.
|
||||
// Copyright © 2018 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
public extension NSPasteboard {
|
||||
|
||||
func copyObjects(_ objects: [Any]) {
|
||||
|
||||
guard let writers = writersFor(objects) else {
|
||||
return
|
||||
}
|
||||
|
||||
clearContents()
|
||||
writeObjects(writers)
|
||||
}
|
||||
|
||||
func canCopyAtLeastOneObject(_ objects: [Any]) -> Bool {
|
||||
|
||||
for object in objects {
|
||||
if object is PasteboardWriterOwner {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public extension NSPasteboard {
|
||||
|
||||
static func urlString(from pasteboard: NSPasteboard) -> String? {
|
||||
return pasteboard.urlString
|
||||
}
|
||||
|
||||
private var urlString: String? {
|
||||
guard let type = self.availableType(from: [.string]) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let str = self.string(forType: type), !str.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return str.mayBeURL ? str : nil
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension NSPasteboard {
|
||||
|
||||
func writersFor(_ objects: [Any]) -> [NSPasteboardWriting]? {
|
||||
|
||||
let writers = objects.compactMap { ($0 as? PasteboardWriterOwner)?.pasteboardWriter }
|
||||
return writers.isEmpty ? nil : writers
|
||||
}
|
||||
}
|
||||
#endif
|
31
RSCore/Sources/RSCore/AppKit/NSResponder-Extensions.swift
Executable file
31
RSCore/Sources/RSCore/AppKit/NSResponder-Extensions.swift
Executable file
@ -0,0 +1,31 @@
|
||||
//
|
||||
// NSResponder-Extensions.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 10/10/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
public extension NSResponder {
|
||||
|
||||
func hasAncestor(_ ancestor: NSResponder) -> Bool {
|
||||
|
||||
var nomad: NSResponder = self
|
||||
while(true) {
|
||||
if nomad === ancestor {
|
||||
return true
|
||||
}
|
||||
if let _ = nomad.nextResponder {
|
||||
nomad = nomad.nextResponder!
|
||||
}
|
||||
else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
#endif
|
108
RSCore/Sources/RSCore/AppKit/NSTableView+RSCore.swift
Executable file
108
RSCore/Sources/RSCore/AppKit/NSTableView+RSCore.swift
Executable file
@ -0,0 +1,108 @@
|
||||
//
|
||||
// NSTableView+Extensions.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 9/6/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
public extension NSTableView {
|
||||
|
||||
var selectionIsEmpty: Bool {
|
||||
return selectedRowIndexes.startIndex == selectedRowIndexes.endIndex
|
||||
}
|
||||
|
||||
func indexesOfAvailableRowsPassingTest(_ test: (Int) -> Bool) -> IndexSet? {
|
||||
|
||||
// Checks visible and in-flight rows.
|
||||
|
||||
var indexes = IndexSet()
|
||||
enumerateAvailableRowViews { (_, row) in
|
||||
if test(row) {
|
||||
indexes.insert(row)
|
||||
}
|
||||
}
|
||||
|
||||
return indexes.isEmpty ? nil : indexes
|
||||
}
|
||||
|
||||
func indexesOfAvailableRows() -> IndexSet? {
|
||||
|
||||
var indexes = IndexSet()
|
||||
enumerateAvailableRowViews { indexes.insert($1) }
|
||||
return indexes.isEmpty ? nil : indexes
|
||||
}
|
||||
|
||||
func scrollTo(row: Int, extraHeight: Int = 150) {
|
||||
|
||||
guard let scrollView = self.enclosingScrollView else {
|
||||
return
|
||||
}
|
||||
let documentVisibleRect = scrollView.documentVisibleRect
|
||||
|
||||
let r = rect(ofRow: row)
|
||||
if NSContainsRect(documentVisibleRect, r) {
|
||||
return
|
||||
}
|
||||
|
||||
let rMidY = NSMidY(r)
|
||||
var scrollPoint = NSZeroPoint;
|
||||
scrollPoint.y = floor(rMidY - (documentVisibleRect.size.height / 2.0)) + CGFloat(extraHeight)
|
||||
scrollPoint.y = max(scrollPoint.y, 0)
|
||||
|
||||
let maxScrollPointY = frame.size.height - documentVisibleRect.size.height
|
||||
scrollPoint.y = min(maxScrollPointY, scrollPoint.y)
|
||||
|
||||
let clipView = scrollView.contentView
|
||||
|
||||
let rClipView = NSMakeRect(scrollPoint.x, scrollPoint.y, NSWidth(clipView.bounds), NSHeight(clipView.bounds))
|
||||
|
||||
clipView.animator().bounds = rClipView
|
||||
}
|
||||
|
||||
func scrollToRowIfNotVisible(_ row: Int) {
|
||||
if let followingRow = rowView(atRow: row, makeIfNecessary: false) {
|
||||
if !(visibleRowViews()?.contains(followingRow) ?? false) {
|
||||
scrollTo(row: row, extraHeight: 0)
|
||||
}
|
||||
} else {
|
||||
scrollTo(row: row, extraHeight: 0)
|
||||
}
|
||||
}
|
||||
|
||||
func visibleRowViews() -> [NSTableRowView]? {
|
||||
|
||||
guard let scrollView = self.enclosingScrollView, numberOfRows > 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let range = rows(in: scrollView.documentVisibleRect)
|
||||
let ixMax = numberOfRows - 1
|
||||
let ixStart = min(range.location, ixMax)
|
||||
let ixEnd = min(((range.location + range.length) - 1), ixMax)
|
||||
|
||||
var visibleRows = [NSTableRowView]()
|
||||
|
||||
for ixRow in ixStart...ixEnd {
|
||||
if let oneRowView = rowView(atRow: ixRow, makeIfNecessary: false) {
|
||||
visibleRows += [oneRowView]
|
||||
}
|
||||
}
|
||||
|
||||
return visibleRows.isEmpty ? nil : visibleRows
|
||||
}
|
||||
|
||||
func selectRow(_ row: Int) {
|
||||
|
||||
self.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false)
|
||||
}
|
||||
|
||||
func selectRowAndScrollToVisible(_ row: Int) {
|
||||
|
||||
self.selectRow(row)
|
||||
self.scrollRowToVisible(row)
|
||||
}
|
||||
}
|
||||
#endif
|
17
RSCore/Sources/RSCore/AppKit/NSToolbar+RSCore.swift
Normal file
17
RSCore/Sources/RSCore/AppKit/NSToolbar+RSCore.swift
Normal file
@ -0,0 +1,17 @@
|
||||
//
|
||||
// NSToolbar+RSCore.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 2/17/18.
|
||||
// Copyright © 2018 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
public extension NSToolbar {
|
||||
|
||||
func existingItem(withIdentifier identifier: NSToolbarItem.Identifier) -> NSToolbarItem? {
|
||||
return items.first(where: {$0.itemIdentifier == identifier})
|
||||
}
|
||||
}
|
||||
#endif
|
98
RSCore/Sources/RSCore/AppKit/NSView+RSCore.swift
Normal file
98
RSCore/Sources/RSCore/AppKit/NSView+RSCore.swift
Normal file
@ -0,0 +1,98 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
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
|
95
RSCore/Sources/RSCore/AppKit/NSWindow-Extensions.swift
Executable file
95
RSCore/Sources/RSCore/AppKit/NSWindow-Extensions.swift
Executable file
@ -0,0 +1,95 @@
|
||||
//
|
||||
// NSWindow-Extensions.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 10/10/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
public extension NSWindow {
|
||||
|
||||
var isDisplayingSheet: Bool {
|
||||
|
||||
return attachedSheet != nil
|
||||
}
|
||||
|
||||
func makeFirstResponderUnlessDescendantIsFirstResponder(_ responder: NSResponder) {
|
||||
|
||||
if let fr = firstResponder, fr.hasAncestor(responder) {
|
||||
return
|
||||
}
|
||||
makeFirstResponder(responder)
|
||||
}
|
||||
|
||||
func setPointAndSizeAdjustingForScreen(point: NSPoint, size: NSSize, minimumSize: NSSize) {
|
||||
|
||||
// point.y specifices from the *top* of the screen, even though screen coordinates work from the bottom up. This is for convenience.
|
||||
// The eventual size may be smaller than requested, since the screen may be small, but not smaller than minimumSize.
|
||||
|
||||
guard let screenFrame = screen?.visibleFrame else {
|
||||
return
|
||||
}
|
||||
|
||||
let paddingFromScreenEdge: CGFloat = 8.0
|
||||
let x = point.x
|
||||
let y = screenFrame.maxY - point.y
|
||||
|
||||
var width = size.width
|
||||
var height = size.height
|
||||
|
||||
if x + width > screenFrame.maxX {
|
||||
width = max((screenFrame.maxX - x) - paddingFromScreenEdge, minimumSize.width)
|
||||
}
|
||||
if y - height < 0.0 {
|
||||
height = max((screenFrame.maxY - point.y) - paddingFromScreenEdge, minimumSize.height)
|
||||
}
|
||||
|
||||
let frame = NSRect(x: x, y: y, width: width, height: height)
|
||||
setFrame(frame, display: true)
|
||||
setFrameTopLeftPoint(frame.origin)
|
||||
}
|
||||
|
||||
var flippedOrigin: NSPoint? {
|
||||
|
||||
// Screen coordinates start at lower-left.
|
||||
// With this we can use upper-left, like sane people.
|
||||
|
||||
get {
|
||||
guard let screenFrame = screen?.frame else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let flippedPoint = NSPoint(x: frame.origin.x, y: screenFrame.maxY - frame.maxY)
|
||||
return flippedPoint
|
||||
}
|
||||
set {
|
||||
guard let screenFrame = screen?.frame else {
|
||||
return
|
||||
}
|
||||
var point = newValue!
|
||||
point.y = screenFrame.maxY - point.y
|
||||
setFrameTopLeftPoint(point)
|
||||
}
|
||||
}
|
||||
|
||||
func setFlippedOriginAdjustingForScreen(_ point: NSPoint) {
|
||||
|
||||
guard let screenFrame = screen?.frame else {
|
||||
return
|
||||
}
|
||||
|
||||
let paddingFromEdge: CGFloat = 8.0
|
||||
var unflippedPoint = point
|
||||
unflippedPoint.y = (screenFrame.maxY - point.y) - frame.height
|
||||
if unflippedPoint.y < 0 {
|
||||
unflippedPoint.y = paddingFromEdge
|
||||
}
|
||||
if unflippedPoint.x < 0 {
|
||||
unflippedPoint.x = paddingFromEdge
|
||||
}
|
||||
setFrameOrigin(unflippedPoint)
|
||||
}
|
||||
}
|
||||
#endif
|
23
RSCore/Sources/RSCore/AppKit/NSWindowController+RSCore.swift
Normal file
23
RSCore/Sources/RSCore/AppKit/NSWindowController+RSCore.swift
Normal file
@ -0,0 +1,23 @@
|
||||
//
|
||||
// NSWindowController+RSCore.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 2/17/18.
|
||||
// Copyright © 2018 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
public extension NSWindowController {
|
||||
|
||||
var isDisplayingSheet: Bool {
|
||||
|
||||
return window?.isDisplayingSheet ?? false
|
||||
}
|
||||
|
||||
var isOpen: Bool {
|
||||
|
||||
return isWindowLoaded && window!.isVisible
|
||||
}
|
||||
}
|
||||
#endif
|
77
RSCore/Sources/RSCore/AppKit/NSWorkspace+RSCore.swift
Normal file
77
RSCore/Sources/RSCore/AppKit/NSWorkspace+RSCore.swift
Normal file
@ -0,0 +1,77 @@
|
||||
//
|
||||
// NSWorkspace+RSCore.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 9/3/18.
|
||||
// Copyright © 2018 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
public extension NSWorkspace {
|
||||
|
||||
/// Get the file path to the default app for a given scheme such as "feed:"
|
||||
func defaultApp(forURLScheme scheme: String) -> String? {
|
||||
guard let url = URL(string: scheme) else {
|
||||
return nil
|
||||
}
|
||||
return urlForApplication(toOpen: url)?.path
|
||||
}
|
||||
|
||||
/// Get the bundle ID for the default app for a given scheme such as "feed:"
|
||||
func defaultAppBundleID(forURLScheme scheme: String) -> String? {
|
||||
guard let path = defaultApp(forURLScheme: scheme) else {
|
||||
return nil
|
||||
}
|
||||
return bundleID(for: path)
|
||||
}
|
||||
|
||||
/// Set the file path that should be the default app for a given scheme such as "feed:"
|
||||
/// It really just uses the bundle ID for the app, so 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
|
15
RSCore/Sources/RSCore/AppKit/PasteboardWriterOwner.swift
Normal file
15
RSCore/Sources/RSCore/AppKit/PasteboardWriterOwner.swift
Normal file
@ -0,0 +1,15 @@
|
||||
//
|
||||
// PasteboardWriterOwner.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 2/11/18.
|
||||
// Copyright © 2018 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
public protocol PasteboardWriterOwner {
|
||||
|
||||
var pasteboardWriter: NSPasteboardWriting { get }
|
||||
}
|
||||
#endif
|
154
RSCore/Sources/RSCore/AppKit/RSAppMovementMonitor.swift
Normal file
154
RSCore/Sources/RSCore/AppKit/RSAppMovementMonitor.swift
Normal file
@ -0,0 +1,154 @@
|
||||
//
|
||||
// RSAppMovementMonitor.swift
|
||||
//
|
||||
// https:://github.com/RedSweater/RSAppMovementMonitor
|
||||
//
|
||||
// Created by Daniel Jalkut on 8/28/19.
|
||||
// Copyright © 2019 Red Sweater Software. All rights reserved.
|
||||
//
|
||||
#if os(macOS)
|
||||
import Cocoa
|
||||
|
||||
public class RSAppMovementMonitor: NSObject {
|
||||
|
||||
// If provided, the handler will be consulted when the app is moved.
|
||||
// Return true to indicate that the default handler should be invoked.
|
||||
public var appMovementHandler: ((RSAppMovementMonitor) -> Bool)? = nil
|
||||
|
||||
// DispatchSource offers a monitoring mechanism based on an open file descriptor
|
||||
var fileDescriptor: Int32 = -1
|
||||
var dispatchSource: DispatchSourceFileSystemObject? = nil
|
||||
|
||||
// Save the original location of the app in a file reference URL, which will track its new location.
|
||||
// Note this is NSURL, not URL, because file reference URLs violate value-type assumptions of URL.
|
||||
// Casting shenanigans here are required to avoid the NSURL ever bridging to URL, and losing its
|
||||
// "magical" fileReferenceURL status.
|
||||
//
|
||||
// See: https://christiantietze.de/posts/2018/09/nsurl-filereferenceurl-swift/
|
||||
//
|
||||
let originalAppURL: URL?
|
||||
var appTrackingURL: NSURL?
|
||||
|
||||
// We load these strings at launch time so that they can be localized. If we wait until
|
||||
// the application has been moved, the localization will fail.
|
||||
let alertMessageText: String
|
||||
let alertInformativeText: String
|
||||
let alertRelaunchButtonText: String
|
||||
|
||||
override public init() {
|
||||
|
||||
// Establish baseline URLs. Note that simply asking for Bundle.main.bundleURL will return
|
||||
// the translocated location of an app when it is launched in quarantine state. This leads
|
||||
// to a permanent false-positive detection that the app has moved. To work around this, we
|
||||
// ask for the fileReferenceURL's absoluteURL at launch time, and compare to the absoluteURL
|
||||
// later to detect bona fide user-driven app movement.
|
||||
self.appTrackingURL = (Bundle.main.bundleURL as NSURL).fileReferenceURL() as NSURL?
|
||||
self.originalAppURL = appTrackingURL?.absoluteURL
|
||||
|
||||
let appName = Bundle.main.infoDictionary?[kCFBundleNameKey as String] as? String ?? NSLocalizedString("This app", comment: "Backup name if the app name cannot be deduced from the bundle")
|
||||
let informativeTextTemplate = NSLocalizedString("%@ was moved or renamed while open.", comment: "Message text for app moved while running alert")
|
||||
self.alertMessageText = String(format: informativeTextTemplate, arguments: [appName])
|
||||
self.alertInformativeText = NSLocalizedString("Moving an open application can cause unexpected behavior. Relaunch the application to continue.", comment: "Informative text for app moved while running alert")
|
||||
self.alertRelaunchButtonText = NSLocalizedString("Relaunch", comment: "Relaunch Button")
|
||||
|
||||
super.init()
|
||||
|
||||
// Monitor for direct changes to the app bundle's folder - this will catch the
|
||||
// majority of direct manipulations to the app's location on disk immediately,
|
||||
// right as it happens.
|
||||
if let originalAppPath = originalAppURL?.path {
|
||||
self.fileDescriptor = open(originalAppPath, O_EVTONLY)
|
||||
if self.fileDescriptor != -1 {
|
||||
self.dispatchSource = DispatchSource.makeFileSystemObjectSource(fileDescriptor: self.fileDescriptor, eventMask: [.delete, .rename], queue: DispatchQueue.main)
|
||||
if let source = self.dispatchSource {
|
||||
source.setEventHandler {
|
||||
self.invokeEventHandler()
|
||||
}
|
||||
|
||||
source.setCancelHandler {
|
||||
self.invalidate()
|
||||
}
|
||||
|
||||
source.resume()
|
||||
}
|
||||
}
|
||||
|
||||
// Also install a notification to re-check the location of the app on disk
|
||||
// every time the app becomes active. This catches a good number of edge-case
|
||||
// changes to the app bundle's path, such as when a containing folder or the
|
||||
// volume name changes.
|
||||
NotificationCenter.default.addObserver(forName: NSApplication.didBecomeActiveNotification, object: nil, queue: nil) { notification in
|
||||
// Removing observer in invalidate doesn't seem to prevent this getting called? Maybe
|
||||
// because it's on the same invocation of the runloop?
|
||||
if self.isValid() && self.originalAppURL != self.appTrackingURL?.absoluteURL {
|
||||
self.invokeEventHandler()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.invalidate()
|
||||
}
|
||||
|
||||
func invokeEventHandler() {
|
||||
// Prevent re-entry when the app is activated while running handler
|
||||
self.invalidate()
|
||||
|
||||
var useDefaultHandler = true
|
||||
if let customHandler = self.appMovementHandler {
|
||||
useDefaultHandler = customHandler(self)
|
||||
}
|
||||
|
||||
if useDefaultHandler {
|
||||
self.defaultHandler()
|
||||
}
|
||||
}
|
||||
|
||||
func isValid() -> Bool {
|
||||
return self.fileDescriptor != -1
|
||||
}
|
||||
|
||||
func invalidate() {
|
||||
if let dispatchSource = self.dispatchSource {
|
||||
dispatchSource.cancel()
|
||||
self.dispatchSource = nil
|
||||
}
|
||||
|
||||
if self.fileDescriptor != -1 {
|
||||
close(self.fileDescriptor)
|
||||
self.fileDescriptor = -1
|
||||
}
|
||||
|
||||
NotificationCenter.default.removeObserver(self, name: NSApplication.didBecomeActiveNotification, object: nil)
|
||||
|
||||
self.appMovementHandler = nil
|
||||
}
|
||||
|
||||
func relaunchFromURL(_ appURL: URL) {
|
||||
// Relaunching is best achieved by requesting that the system launch the app
|
||||
// at the given URL with the "new instance" option to prevent it simply reactivating us.
|
||||
let _ = try? NSWorkspace.shared.launchApplication(at: appURL, options: .newInstance, configuration: [:])
|
||||
NSApp.terminate(self)
|
||||
}
|
||||
|
||||
func defaultHandler() {
|
||||
let quitAlert = NSAlert()
|
||||
quitAlert.alertStyle = .critical
|
||||
quitAlert.addButton(withTitle: self.alertRelaunchButtonText)
|
||||
|
||||
quitAlert.messageText = self.alertMessageText
|
||||
quitAlert.informativeText = self.alertInformativeText
|
||||
|
||||
let modalResponse = quitAlert.runModal()
|
||||
if modalResponse == .alertFirstButtonReturn {
|
||||
self.invalidate()
|
||||
|
||||
if let movedAppURL = self.appTrackingURL as URL? {
|
||||
self.relaunchFromURL(movedAppURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
#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
|
65
RSCore/Sources/RSCore/AppKit/RSToolbarItem.swift
Executable file
65
RSCore/Sources/RSCore/AppKit/RSToolbarItem.swift
Executable file
@ -0,0 +1,65 @@
|
||||
//
|
||||
// RSToolbarItem.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 10/16/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
public class RSToolbarItem: NSToolbarItem {
|
||||
|
||||
override public func validate() {
|
||||
|
||||
guard let view = view, let _ = view.window else {
|
||||
isEnabled = false
|
||||
return
|
||||
}
|
||||
isEnabled = isValidAsUserInterfaceItem()
|
||||
}
|
||||
}
|
||||
|
||||
private extension RSToolbarItem {
|
||||
|
||||
func isValidAsUserInterfaceItem() -> Bool {
|
||||
|
||||
// Use NSValidatedUserInterfaceItem protocol rather than calling validateToolbarItem:.
|
||||
|
||||
if let target = target as? NSResponder {
|
||||
return validateWithResponder(target) ?? false
|
||||
}
|
||||
|
||||
var responder = view?.window?.firstResponder
|
||||
if responder == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
while(true) {
|
||||
if let validated = validateWithResponder(responder!) {
|
||||
return validated
|
||||
}
|
||||
responder = responder?.nextResponder
|
||||
if responder == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if let appDelegate = NSApplication.shared.delegate {
|
||||
if let validated = validateWithResponder(appDelegate) {
|
||||
return validated
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func validateWithResponder(_ responder: NSObjectProtocol) -> Bool? {
|
||||
|
||||
guard responder.responds(to: action), let target = responder as? NSUserInterfaceValidations else {
|
||||
return nil
|
||||
}
|
||||
return target.validateUserInterfaceItem(self)
|
||||
}
|
||||
}
|
||||
#endif
|
47
RSCore/Sources/RSCore/AppKit/URLPasteboardWriter.swift
Normal file
47
RSCore/Sources/RSCore/AppKit/URLPasteboardWriter.swift
Normal file
@ -0,0 +1,47 @@
|
||||
//
|
||||
// URLPasteboardWriter.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 1/28/18.
|
||||
// Copyright © 2018 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
// Takes a string, not a URL, but writes it as a URL (when possible) and as a String.
|
||||
|
||||
@objc public final class URLPasteboardWriter: NSObject, NSPasteboardWriting {
|
||||
|
||||
let urlString: String
|
||||
|
||||
public init(urlString: String) {
|
||||
|
||||
self.urlString = urlString
|
||||
}
|
||||
|
||||
public class func write(urlString: String, to pasteboard: NSPasteboard) {
|
||||
|
||||
pasteboard.clearContents()
|
||||
let writer = URLPasteboardWriter(urlString: urlString)
|
||||
pasteboard.writeObjects([writer])
|
||||
}
|
||||
|
||||
// MARK: - NSPasteboardWriting
|
||||
|
||||
public func writableTypes(for pasteboard: NSPasteboard) -> [NSPasteboard.PasteboardType] {
|
||||
|
||||
if let _ = URL(string: urlString) {
|
||||
return [.URL, .string]
|
||||
}
|
||||
return [.string]
|
||||
}
|
||||
|
||||
public func pasteboardPropertyList(forType type: NSPasteboard.PasteboardType) -> Any? {
|
||||
|
||||
guard type == .string || type == .URL else {
|
||||
return nil
|
||||
}
|
||||
return urlString
|
||||
}
|
||||
}
|
||||
#endif
|
140
RSCore/Sources/RSCore/AppKit/UserApp.swift
Normal file
140
RSCore/Sources/RSCore/AppKit/UserApp.swift
Normal file
@ -0,0 +1,140 @@
|
||||
//
|
||||
// UserApp.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 1/14/18.
|
||||
// Copyright © 2018 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
/// Represents an app (the type of app mostly found in /Applications.)
|
||||
///
|
||||
/// The app may or may not be running. It may or may not exist.
|
||||
|
||||
public final class UserApp {
|
||||
|
||||
public let bundleID: String
|
||||
public var icon: NSImage? = nil
|
||||
public var existsOnDisk = false
|
||||
public var path: String? = nil
|
||||
public var runningApplication: NSRunningApplication? = nil
|
||||
|
||||
public var isRunning: Bool {
|
||||
|
||||
updateStatus()
|
||||
if let runningApplication = runningApplication {
|
||||
return !runningApplication.isTerminated
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
public init(bundleID: String) {
|
||||
|
||||
self.bundleID = bundleID
|
||||
updateStatus()
|
||||
}
|
||||
|
||||
public func updateStatus() {
|
||||
|
||||
if let runningApplication = runningApplication, runningApplication.isTerminated {
|
||||
self.runningApplication = nil
|
||||
}
|
||||
|
||||
let runningApplications = NSRunningApplication.runningApplications(withBundleIdentifier: bundleID)
|
||||
for app in runningApplications {
|
||||
if let runningApplication = runningApplication {
|
||||
if app == runningApplication {
|
||||
break
|
||||
}
|
||||
}
|
||||
else {
|
||||
if !app.isTerminated {
|
||||
runningApplication = app
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let runningApplication = runningApplication {
|
||||
existsOnDisk = true
|
||||
icon = runningApplication.icon
|
||||
if let bundleURL = runningApplication.bundleURL {
|
||||
path = bundleURL.path
|
||||
}
|
||||
else {
|
||||
path = NSWorkspace.shared.absolutePathForApplication(withBundleIdentifier: bundleID)
|
||||
}
|
||||
if icon == nil, let path = path {
|
||||
icon = NSWorkspace.shared.icon(forFile: path)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
path = NSWorkspace.shared.absolutePathForApplication(withBundleIdentifier: bundleID)
|
||||
if let path = path {
|
||||
if icon == nil {
|
||||
icon = NSWorkspace.shared.icon(forFile: path)
|
||||
}
|
||||
existsOnDisk = true
|
||||
}
|
||||
else {
|
||||
existsOnDisk = false
|
||||
icon = nil
|
||||
}
|
||||
}
|
||||
|
||||
public func launchIfNeeded() -> Bool {
|
||||
|
||||
// Return true if already running.
|
||||
// Return true if not running and successfully gets launched.
|
||||
|
||||
updateStatus()
|
||||
if isRunning {
|
||||
return true
|
||||
}
|
||||
|
||||
guard existsOnDisk, let path = path else {
|
||||
return false
|
||||
}
|
||||
|
||||
let url = URL(fileURLWithPath: path)
|
||||
if let app = try? NSWorkspace.shared.launchApplication(at: url, options: [.withErrorPresentation], configuration: [:]) {
|
||||
runningApplication = app
|
||||
if app.isFinishedLaunching {
|
||||
return true
|
||||
}
|
||||
Thread.sleep(forTimeInterval: 1.0) // Give the app time to launch. This is ugly.
|
||||
if app.isFinishedLaunching {
|
||||
return true
|
||||
}
|
||||
Thread.sleep(forTimeInterval: 1.0) // Give it some *more* time.
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
public func bringToFront() -> Bool {
|
||||
|
||||
// Activates the app, ignoring other apps.
|
||||
// Does not automatically launch the app first.
|
||||
|
||||
updateStatus()
|
||||
return runningApplication?.activate(options: [.activateIgnoringOtherApps]) ?? false
|
||||
}
|
||||
|
||||
public func targetDescriptor() -> NSAppleEventDescriptor? {
|
||||
|
||||
// Requires that the app has previously been launched.
|
||||
|
||||
updateStatus()
|
||||
guard let runningApplication = runningApplication, !runningApplication.isTerminated else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return NSAppleEventDescriptor(runningApplication: runningApplication)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
98
RSCore/Sources/RSCore/CloudKit/CloudKitError.swift
Normal file
98
RSCore/Sources/RSCore/CloudKit/CloudKitError.swift
Normal file
@ -0,0 +1,98 @@
|
||||
//
|
||||
// CloudKitError.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Maurice Parker on 3/26/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
// Derived from https://github.com/caiyue1993/IceCream
|
||||
|
||||
import Foundation
|
||||
import CloudKit
|
||||
|
||||
public class CloudKitError: LocalizedError {
|
||||
|
||||
public let error: Error
|
||||
|
||||
public init(_ error: Error) {
|
||||
self.error = error
|
||||
}
|
||||
|
||||
public var errorDescription: String? {
|
||||
guard let ckError = error as? CKError else {
|
||||
return error.localizedDescription
|
||||
}
|
||||
|
||||
switch ckError.code {
|
||||
case .alreadyShared:
|
||||
return NSLocalizedString("Already Shared: a record or share cannot be saved because doing so would cause the same hierarchy of records to exist in multiple shares.", comment: "Known iCloud Error")
|
||||
case .assetFileModified:
|
||||
return NSLocalizedString("Asset File Modified: the content of the specified asset file was modified while being saved.", comment: "Known iCloud Error")
|
||||
case .assetFileNotFound:
|
||||
return NSLocalizedString("Asset File Not Found: the specified asset file is not found.", comment: "Known iCloud Error")
|
||||
case .badContainer:
|
||||
return NSLocalizedString("Bad Container: the specified container is unknown or unauthorized.", comment: "Known iCloud Error")
|
||||
case .badDatabase:
|
||||
return NSLocalizedString("Bad Database: the operation could not be completed on the given database.", comment: "Known iCloud Error")
|
||||
case .batchRequestFailed:
|
||||
return NSLocalizedString("Batch Request Failed: the entire batch was rejected.", comment: "Known iCloud Error")
|
||||
case .changeTokenExpired:
|
||||
return NSLocalizedString("Change Token Expired: the previous server change token is too old.", comment: "Known iCloud Error")
|
||||
case .constraintViolation:
|
||||
return NSLocalizedString("Constraint Violation: the server rejected the request because of a conflict with a unique field.", comment: "Known iCloud Error")
|
||||
case .incompatibleVersion:
|
||||
return NSLocalizedString("Incompatible Version: your app version is older than the oldest version allowed.", comment: "Known iCloud Error")
|
||||
case .internalError:
|
||||
return NSLocalizedString("Internal Error: a nonrecoverable error was encountered by CloudKit.", comment: "Known iCloud Error")
|
||||
case .invalidArguments:
|
||||
return NSLocalizedString("Invalid Arguments: the specified request contains bad information.", comment: "Known iCloud Error")
|
||||
case .limitExceeded:
|
||||
return NSLocalizedString("Limit Exceeded: the request to the server is too large.", comment: "Known iCloud Error")
|
||||
case .managedAccountRestricted:
|
||||
return NSLocalizedString("Managed Account Restricted: the request was rejected due to a managed-account restriction.", comment: "Known iCloud Error")
|
||||
case .missingEntitlement:
|
||||
return NSLocalizedString("Missing Entitlement: the app is missing a required entitlement.", comment: "Known iCloud Error")
|
||||
case .networkUnavailable:
|
||||
return NSLocalizedString("Network Unavailable: the internet connection appears to be offline.", comment: "Known iCloud Error")
|
||||
case .networkFailure:
|
||||
return NSLocalizedString("Network Failure: the internet connection appears to be offline.", comment: "Known iCloud Error")
|
||||
case .notAuthenticated:
|
||||
return NSLocalizedString("Not Authenticated: to use the iCloud account, you must enable iCloud Drive. Go to device Settings, sign in to iCloud, then in the app settings, be sure the iCloud Drive feature is enabled.", comment: "Known iCloud Error")
|
||||
case .operationCancelled:
|
||||
return NSLocalizedString("Operation Cancelled: the operation was explicitly canceled.", comment: "Known iCloud Error")
|
||||
case .partialFailure:
|
||||
return NSLocalizedString("Partial Failure: some items failed, but the operation succeeded overall.", comment: "Known iCloud Error")
|
||||
case .participantMayNeedVerification:
|
||||
return NSLocalizedString("Participant May Need Verification: you are not a member of the share.", comment: "Known iCloud Error")
|
||||
case .permissionFailure:
|
||||
return NSLocalizedString("Permission Failure: to use this app, you must enable iCloud Drive. Go to device Settings, sign in to iCloud, then in the app settings, be sure the iCloud Drive feature is enabled.", comment: "Known iCloud Error")
|
||||
case .quotaExceeded:
|
||||
return NSLocalizedString("Quota Exceeded: saving would exceed your current iCloud storage quota.", comment: "Known iCloud Error")
|
||||
case .referenceViolation:
|
||||
return NSLocalizedString("Reference Violation: the target of a record's parent or share reference was not found.", comment: "Known iCloud Error")
|
||||
case .requestRateLimited:
|
||||
return NSLocalizedString("Request Rate Limited: transfers to and from the server are being rate limited at this time.", comment: "Known iCloud Error")
|
||||
case .serverRecordChanged:
|
||||
return NSLocalizedString("Server Record Changed: the record was rejected because the version on the server is different.", comment: "Known iCloud Error")
|
||||
case .serverRejectedRequest:
|
||||
return NSLocalizedString("Server Rejected Request", comment: "Known iCloud Error")
|
||||
case .serverResponseLost:
|
||||
return NSLocalizedString("Server Response Lost", comment: "Known iCloud Error")
|
||||
case .serviceUnavailable:
|
||||
return NSLocalizedString("Service Unavailable: Please try again.", comment: "Known iCloud Error")
|
||||
case .tooManyParticipants:
|
||||
return NSLocalizedString("Too Many Participants: a share cannot be saved because too many participants are attached to the share.", comment: "Known iCloud Error")
|
||||
case .unknownItem:
|
||||
return NSLocalizedString("Unknown Item: the specified record does not exist.", comment: "Known iCloud Error")
|
||||
case .userDeletedZone:
|
||||
return NSLocalizedString("User Deleted Zone: the user has deleted this zone from the settings UI.", comment: "Known iCloud Error")
|
||||
case .zoneBusy:
|
||||
return NSLocalizedString("Zone Busy: the server is too busy to handle the zone operation.", comment: "Known iCloud Error")
|
||||
case .zoneNotFound:
|
||||
return NSLocalizedString("Zone Not Found: the specified record zone does not exist on the server.", comment: "Known iCloud Error")
|
||||
default:
|
||||
return NSLocalizedString("Unhandled Error.", comment: "Unknown iCloud Error")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
815
RSCore/Sources/RSCore/CloudKit/CloudKitZone.swift
Normal file
815
RSCore/Sources/RSCore/CloudKit/CloudKitZone.swift
Normal file
@ -0,0 +1,815 @@
|
||||
//
|
||||
// CloudKitZone.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Maurice Parker on 3/21/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import CloudKit
|
||||
import os.log
|
||||
|
||||
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: AnyObject {
|
||||
func cloudKitDidModify(changed: [CKRecord], deleted: [CloudKitRecordKey], completion: @escaping (Result<Void, Error>) -> Void);
|
||||
}
|
||||
|
||||
public typealias CloudKitRecordKey = (recordType: CKRecord.RecordType, recordID: CKRecord.ID)
|
||||
|
||||
public protocol CloudKitZone: AnyObject {
|
||||
|
||||
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, subscriptionID: zoneID.zoneName)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
81
RSCore/Sources/RSCore/CloudKit/CloudKitZoneResult.swift
Normal file
81
RSCore/Sources/RSCore/CloudKit/CloudKitZoneResult.swift
Normal file
@ -0,0 +1,81 @@
|
||||
//
|
||||
// CloudKitResult.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Maurice Parker on 3/26/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CloudKit
|
||||
|
||||
public enum CloudKitZoneResult {
|
||||
case success
|
||||
case retry(afterSeconds: Double)
|
||||
case limitExceeded
|
||||
case changeTokenExpired
|
||||
case partialFailure(errors: [AnyHashable: CKError])
|
||||
case serverRecordChanged
|
||||
case zoneNotFound
|
||||
case userDeletedZone
|
||||
case failure(error: Error)
|
||||
|
||||
public static func resolve(_ error: Error?) -> CloudKitZoneResult {
|
||||
|
||||
guard error != nil else { return .success }
|
||||
|
||||
guard let ckError = error as? CKError else {
|
||||
return .failure(error: error!)
|
||||
}
|
||||
|
||||
switch ckError.code {
|
||||
case .serviceUnavailable, .requestRateLimited, .zoneBusy:
|
||||
if let retry = ckError.userInfo[CKErrorRetryAfterKey] as? NSNumber {
|
||||
return .retry(afterSeconds: retry.doubleValue)
|
||||
} else {
|
||||
return .failure(error: CloudKitError(ckError))
|
||||
}
|
||||
case .zoneNotFound:
|
||||
return .zoneNotFound
|
||||
case .userDeletedZone:
|
||||
return .userDeletedZone
|
||||
case .changeTokenExpired:
|
||||
return .changeTokenExpired
|
||||
case .serverRecordChanged:
|
||||
return .serverRecordChanged
|
||||
case .partialFailure:
|
||||
if let partialErrors = ckError.userInfo[CKPartialErrorsByItemIDKey] as? [AnyHashable: CKError] {
|
||||
if let zoneResult = anyRequestErrors(partialErrors) {
|
||||
return zoneResult
|
||||
} else {
|
||||
return .partialFailure(errors: partialErrors)
|
||||
}
|
||||
} else {
|
||||
return .failure(error: CloudKitError(ckError))
|
||||
}
|
||||
case .limitExceeded:
|
||||
return .limitExceeded
|
||||
default:
|
||||
return .failure(error: CloudKitError(ckError))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension CloudKitZoneResult {
|
||||
|
||||
static func anyRequestErrors(_ errors: [AnyHashable: CKError]) -> CloudKitZoneResult? {
|
||||
if errors.values.contains(where: { $0.code == .changeTokenExpired } ) {
|
||||
return .changeTokenExpired
|
||||
}
|
||||
if errors.values.contains(where: { $0.code == .zoneNotFound } ) {
|
||||
return .zoneNotFound
|
||||
}
|
||||
if errors.values.contains(where: { $0.code == .userDeletedZone } ) {
|
||||
return .userDeletedZone
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
}
|
3
RSCore/Sources/RSCore/RSCore.swift
Normal file
3
RSCore/Sources/RSCore/RSCore.swift
Normal file
@ -0,0 +1,3 @@
|
||||
struct RSCore {
|
||||
var text = "Hello, World!"
|
||||
}
|
28
RSCore/Sources/RSCore/Shared/Array+RSCore.swift
Normal file
28
RSCore/Sources/RSCore/Shared/Array+RSCore.swift
Normal file
@ -0,0 +1,28 @@
|
||||
//
|
||||
// Array+RSCore.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 2/17/18.
|
||||
// Copyright © 2018 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public extension Array {
|
||||
|
||||
func chunked(into size: Int) -> [[Element]] {
|
||||
return stride(from: 0, to: count, by: size).map {
|
||||
Array(self[$0 ..< Swift.min($0 + size, count)])
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public extension Array where Element: Equatable {
|
||||
|
||||
mutating func removeFirst(object: Element) {
|
||||
guard let index = firstIndex(of: object) else {return}
|
||||
remove(at: index)
|
||||
}
|
||||
|
||||
}
|
81
RSCore/Sources/RSCore/Shared/BatchUpdate.swift
Normal file
81
RSCore/Sources/RSCore/Shared/BatchUpdate.swift
Normal file
@ -0,0 +1,81 @@
|
||||
//
|
||||
// BatchUpdates.swift
|
||||
// DataModel
|
||||
//
|
||||
// Created by Brent Simmons on 9/12/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// Main thread only.
|
||||
|
||||
public typealias BatchUpdateBlock = () -> Void
|
||||
|
||||
public extension Notification.Name {
|
||||
|
||||
/// A notification posted when a batch update completes.
|
||||
static let BatchUpdateDidPerform = Notification.Name(rawValue: "BatchUpdateDidPerform")
|
||||
}
|
||||
|
||||
/// A class for batch updating.
|
||||
public final class BatchUpdate {
|
||||
|
||||
/// The shared batch update object.
|
||||
public static let shared = BatchUpdate()
|
||||
|
||||
private var count = 0
|
||||
|
||||
/// Is updating in progress?
|
||||
public var isPerforming: Bool {
|
||||
precondition(Thread.isMainThread)
|
||||
return count > 0
|
||||
}
|
||||
|
||||
/// Perform a batch update.
|
||||
public func perform(_ batchUpdateBlock: BatchUpdateBlock) {
|
||||
precondition(Thread.isMainThread)
|
||||
incrementCount()
|
||||
batchUpdateBlock()
|
||||
decrementCount()
|
||||
}
|
||||
|
||||
/// Start batch updates.
|
||||
public func start() {
|
||||
precondition(Thread.isMainThread)
|
||||
incrementCount()
|
||||
}
|
||||
|
||||
/// End batch updates.
|
||||
public func end() {
|
||||
precondition(Thread.isMainThread)
|
||||
decrementCount()
|
||||
}
|
||||
}
|
||||
|
||||
private extension BatchUpdate {
|
||||
|
||||
func incrementCount() {
|
||||
count = count + 1
|
||||
}
|
||||
|
||||
func decrementCount() {
|
||||
count = count - 1
|
||||
if count < 1 {
|
||||
assert(count > -1, "Expected batch updates count to be 0 or greater.")
|
||||
count = 0
|
||||
postBatchUpdateDidPerform()
|
||||
}
|
||||
}
|
||||
|
||||
func postBatchUpdateDidPerform() {
|
||||
if !Thread.isMainThread {
|
||||
DispatchQueue.main.sync {
|
||||
NotificationCenter.default.post(name: .BatchUpdateDidPerform, object: nil, userInfo: nil)
|
||||
}
|
||||
} else {
|
||||
NotificationCenter.default.post(name: .BatchUpdateDidPerform, object: nil, userInfo: nil)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
74
RSCore/Sources/RSCore/Shared/BinaryDiskCache.swift
Normal file
74
RSCore/Sources/RSCore/Shared/BinaryDiskCache.swift
Normal file
@ -0,0 +1,74 @@
|
||||
//
|
||||
// BinaryDiskCache.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 11/24/17.
|
||||
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// Thread safety is up to the caller.
|
||||
|
||||
public struct BinaryDiskCache {
|
||||
|
||||
public let folder: String
|
||||
|
||||
public init(folder: String) {
|
||||
self.folder = folder
|
||||
}
|
||||
|
||||
public func data(forKey key: String) throws -> Data? {
|
||||
let url = urlForKey(key)
|
||||
return try Data(contentsOf: url)
|
||||
}
|
||||
|
||||
public func setData(_ data: Data, forKey key: String) throws {
|
||||
let url = urlForKey(key)
|
||||
try data.write(to: url)
|
||||
}
|
||||
|
||||
public func deleteData(forKey key: String) throws {
|
||||
let url = urlForKey(key)
|
||||
try FileManager.default.removeItem(at: url)
|
||||
}
|
||||
|
||||
// subscript 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)
|
||||
}
|
||||
}
|
23
RSCore/Sources/RSCore/Shared/Blocks.swift
Normal file
23
RSCore/Sources/RSCore/Shared/Blocks.swift
Normal file
@ -0,0 +1,23 @@
|
||||
//
|
||||
// Blocks.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 11/29/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public typealias VoidBlock = () -> Void
|
||||
public typealias VoidCompletionBlock = VoidBlock
|
||||
|
||||
/// Call a VoidCompletionBlock on the main thread.
|
||||
/// - Parameter block: The block to call.
|
||||
public func callVoidCompletionBlock(_ block: @escaping VoidCompletionBlock) {
|
||||
DispatchQueue.main.async(execute: block)
|
||||
}
|
||||
|
||||
public typealias VoidResult = Result<Void, Error>
|
||||
public typealias VoidResultCompletionBlock = (VoidResult) -> Void
|
||||
|
||||
public typealias ImageResultBlock = (RSImage?) -> Void
|
30
RSCore/Sources/RSCore/Shared/Calendar+RSCore.swift
Normal file
30
RSCore/Sources/RSCore/Shared/Calendar+RSCore.swift
Normal file
@ -0,0 +1,30 @@
|
||||
//
|
||||
// Calendar+RSCore.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Nate Weaver on 2020-01-01.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public extension Calendar {
|
||||
|
||||
/// A cached `.autoupdatingCurrent` for performance.
|
||||
static let cached: Calendar = .autoupdatingCurrent
|
||||
|
||||
/// Determine whether a date is in today.
|
||||
///
|
||||
/// - Parameter date: The specified date.
|
||||
///
|
||||
/// - Returns: `true` if `date` is in today; `false` otherwise.
|
||||
static func dateIsToday(_ date: Date) -> Bool {
|
||||
return cached.isDateInToday(date)
|
||||
}
|
||||
|
||||
/// The first moment of today.
|
||||
static var startOfToday: Date {
|
||||
cached.startOfDay(for: Date())
|
||||
}
|
||||
|
||||
}
|
21
RSCore/Sources/RSCore/Shared/Character+RSCore.swift
Normal file
21
RSCore/Sources/RSCore/Shared/Character+RSCore.swift
Normal file
@ -0,0 +1,21 @@
|
||||
//
|
||||
// Character+RSCore.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Maurice Parker on 4/20/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public extension Character {
|
||||
|
||||
var isSimpleEmoji: Bool {
|
||||
guard let firstScalar = unicodeScalars.first else { return false }
|
||||
return firstScalar.properties.isEmoji && firstScalar.value > 0x238C
|
||||
}
|
||||
|
||||
var isCombinedIntoEmoji: Bool { unicodeScalars.count > 1 && unicodeScalars.first?.properties.isEmoji ?? false }
|
||||
|
||||
var isEmoji: Bool { isSimpleEmoji || isCombinedIntoEmoji }
|
||||
}
|
97
RSCore/Sources/RSCore/Shared/CoalescingQueue.swift
Normal file
97
RSCore/Sources/RSCore/Shared/CoalescingQueue.swift
Normal file
@ -0,0 +1,97 @@
|
||||
//
|
||||
// CoalescingQueue.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 2/17/18.
|
||||
// Copyright © 2018 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// Use when you want to coalesce calls for something like updating visible table cells.
|
||||
// Calls are uniqued. If you add a call with the same target and selector as a previous call, 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
|
||||
}
|
||||
}
|
180
RSCore/Sources/RSCore/Shared/Data+RSCore.swift
Normal file
180
RSCore/Sources/RSCore/Shared/Data+RSCore.swift
Normal file
@ -0,0 +1,180 @@
|
||||
//
|
||||
// Data+RSCore.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Nate Weaver on 2020-01-02.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
#if canImport(CryptoKit)
|
||||
import CryptoKit
|
||||
#endif
|
||||
import CommonCrypto
|
||||
|
||||
public extension Data {
|
||||
|
||||
/// The MD5 hash of the data.
|
||||
var md5Hash: Data {
|
||||
|
||||
#if canImport(CryptoKit)
|
||||
if #available(macOS 10.15, *) {
|
||||
let digest = Insecure.MD5.hash(data: self)
|
||||
return Data(digest)
|
||||
} else {
|
||||
return ccMD5Hash
|
||||
}
|
||||
#else
|
||||
return ccMD5Hash
|
||||
#endif
|
||||
|
||||
}
|
||||
|
||||
@available(macOS, deprecated: 10.15)
|
||||
@available(iOS, deprecated: 13.0)
|
||||
private var ccMD5Hash: Data {
|
||||
let len = Int(CC_MD5_DIGEST_LENGTH)
|
||||
let md = UnsafeMutablePointer<CUnsignedChar>.allocate(capacity: len)
|
||||
|
||||
let _ = self.withUnsafeBytes {
|
||||
CC_MD5($0.baseAddress, numericCast($0.count), md)
|
||||
}
|
||||
|
||||
return Data(bytes: md, count: len)
|
||||
}
|
||||
|
||||
/// The MD5 has of the data, as a hexadecimal string.
|
||||
var md5String: String? {
|
||||
return md5Hash.hexadecimalString
|
||||
}
|
||||
|
||||
/// Image signature constants.
|
||||
private enum ImageSignature {
|
||||
|
||||
/// The signature for PNG data.
|
||||
///
|
||||
/// [PNG signature](http://www.w3.org/TR/PNG/#5PNG-file-signature)\:
|
||||
/// The first eight bytes of a PNG datastream always contain the following (decimal) values:
|
||||
///
|
||||
/// ```
|
||||
/// 137 80 78 71 13 10 26 10
|
||||
/// ```
|
||||
static let png = Data([137, 80, 78, 71, 13, 10, 26, 10])
|
||||
|
||||
/// The signature for GIF 89a data.
|
||||
///
|
||||
/// [http://www.onicos.com/staff/iz/formats/gif.html](http://www.onicos.com/staff/iz/formats/gif.html)
|
||||
static let gif89a = "GIF89a".data(using: .ascii)!
|
||||
|
||||
/// The signature for GIF 87a data.
|
||||
///
|
||||
/// [http://www.onicos.com/staff/iz/formats/gif.html](http://www.onicos.com/staff/iz/formats/gif.html)
|
||||
static let gif87a = "GIF87a".data(using: .ascii)!
|
||||
|
||||
/// The signature for JPEG data.
|
||||
static let jpeg = Data([0xFF, 0xD8, 0xFF])
|
||||
|
||||
}
|
||||
|
||||
/// Check if data matches a signature at its start.
|
||||
///
|
||||
/// - Parameter signatures: An array of signatures to match against.
|
||||
/// - Returns: `true` if the data matches; `false` otherwise.
|
||||
private func matchesSignature(from signatures: [Data]) -> Bool {
|
||||
for signature in signatures {
|
||||
if self.prefix(signature.count) == signature {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/// Returns `true` if the data begins with the PNG signature.
|
||||
var isPNG: Bool {
|
||||
return matchesSignature(from: [ImageSignature.png])
|
||||
}
|
||||
|
||||
/// Returns `true` if the data begins with a valid GIF signature.
|
||||
var isGIF: Bool {
|
||||
return matchesSignature(from: [ImageSignature.gif89a, ImageSignature.gif87a])
|
||||
}
|
||||
|
||||
/// Returns `true` if the data begins with a valid JPEG signature.
|
||||
var isJPEG: Bool {
|
||||
return matchesSignature(from: [ImageSignature.jpeg])
|
||||
}
|
||||
|
||||
/// Returns `true` if the data is an image (PNG, JPEG, or GIF).
|
||||
var isImage: Bool {
|
||||
return isPNG || isJPEG || isGIF
|
||||
}
|
||||
|
||||
/// Constants for `isProbablyHTML`.
|
||||
private enum RSSearch {
|
||||
|
||||
static let lessThan = "<".utf8.first!
|
||||
static let greaterThan = ">".utf8.first!
|
||||
|
||||
/// Tags in UTF-8/ASCII format.
|
||||
enum UTF8 {
|
||||
static let lowercaseHTML = "html".data(using: .utf8)!
|
||||
static let lowercaseBody = "body".data(using: .utf8)!
|
||||
static let uppercaseHTML = "HTML".data(using: .utf8)!
|
||||
static let uppercaseBody = "BODY".data(using: .utf8)!
|
||||
}
|
||||
|
||||
/// Tags in UTF-16 format.
|
||||
enum UTF16 {
|
||||
static let lowercaseHTML = "html".data(using: .utf16LittleEndian)!
|
||||
static let lowercaseBody = "body".data(using: .utf16LittleEndian)!
|
||||
static let uppercaseHTML = "HTML".data(using: .utf16LittleEndian)!
|
||||
static let uppercaseBody = "BODY".data(using: .utf16LittleEndian)!
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Returns `true` if the data looks like it could be HTML.
|
||||
///
|
||||
/// Advantage is taken of the fact that most common encodings are ASCII-compatible, aside from UTF-16,
|
||||
/// which for ASCII codepoints is essentially ASCII characters with nulls in between.
|
||||
///
|
||||
/// An uncommon exception is any EBCDIC-derived encoding.
|
||||
var isProbablyHTML: Bool {
|
||||
|
||||
if !self.contains(RSSearch.lessThan) || !self.contains(RSSearch.greaterThan) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (self.range(of: RSSearch.UTF8.lowercaseHTML) != nil || self.range(of: RSSearch.UTF8.uppercaseHTML) != nil)
|
||||
&& (self.range(of: RSSearch.UTF8.lowercaseBody) != nil || self.range(of: RSSearch.UTF8.uppercaseBody) != nil) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (self.range(of: RSSearch.UTF16.lowercaseHTML) != nil || self.range(of: RSSearch.UTF16.uppercaseHTML) != nil)
|
||||
&& (self.range(of: RSSearch.UTF16.lowercaseBody) != nil || self.range(of: RSSearch.UTF16.uppercaseBody) != nil) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/// A representation of the data as a hexadecimal string.
|
||||
///
|
||||
/// Returns `nil` if the data is empty.
|
||||
var hexadecimalString: String? {
|
||||
|
||||
if count == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Special case for MD5
|
||||
if count == 16 {
|
||||
return String(format: "%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x", self[0], self[1], self[2], self[3], self[4], self[5], self[6], self[7], self[8], self[9], self[10], self[11], self[12], self[13], self[14], self[15])
|
||||
}
|
||||
|
||||
return reduce("") { $0 + String(format: "%02x", $1) }
|
||||
|
||||
}
|
||||
|
||||
}
|
29
RSCore/Sources/RSCore/Shared/Date+Extensions.swift
Executable file
29
RSCore/Sources/RSCore/Shared/Date+Extensions.swift
Executable file
@ -0,0 +1,29 @@
|
||||
//
|
||||
// Date+Extensions.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 6/21/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public extension Date {
|
||||
|
||||
// Below are for rough use only — they don't use the calendar.
|
||||
|
||||
func bySubtracting(days: Int) -> Date {
|
||||
return addingTimeInterval(0.0 - TimeInterval(days: days))
|
||||
}
|
||||
|
||||
func byAdding(days: Int) -> Date {
|
||||
return addingTimeInterval(TimeInterval(days: days))
|
||||
}
|
||||
}
|
||||
|
||||
private extension TimeInterval {
|
||||
|
||||
init(days: Int) {
|
||||
self.init(days * 24 * 60 * 60)
|
||||
}
|
||||
}
|
29
RSCore/Sources/RSCore/Shared/DisplayNameProvider.swift
Normal file
29
RSCore/Sources/RSCore/Shared/DisplayNameProvider.swift
Normal file
@ -0,0 +1,29 @@
|
||||
//
|
||||
// DisplayNameProviderProtocol.swift
|
||||
// DataModel
|
||||
//
|
||||
// Created by Brent Simmons on 7/28/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Notification.Name {
|
||||
|
||||
public static let DisplayNameDidChange = Notification.Name("DisplayNameDidChange")
|
||||
}
|
||||
|
||||
/// A type that provides a name for display to the user.
|
||||
|
||||
public protocol DisplayNameProvider {
|
||||
|
||||
var nameForDisplay: String { get }
|
||||
}
|
||||
|
||||
public extension DisplayNameProvider {
|
||||
|
||||
func postDisplayNameDidChangeNotification() {
|
||||
|
||||
NotificationCenter.default.post(name: .DisplayNameDidChange, object: self, userInfo: nil)
|
||||
}
|
||||
}
|
112
RSCore/Sources/RSCore/Shared/FileManager+RSCore.swift
Normal file
112
RSCore/Sources/RSCore/Shared/FileManager+RSCore.swift
Normal file
@ -0,0 +1,112 @@
|
||||
//
|
||||
// FileManager+RSCore.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Nate Weaver on 2020-01-02.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public extension FileManager {
|
||||
|
||||
|
||||
/// Returns whether a path refers to a folder.
|
||||
///
|
||||
/// - Parameter path: The file path to check.
|
||||
///
|
||||
/// - Returns: `true` if the path refers to a folder; otherwise `false`.
|
||||
|
||||
func isFolder(atPath path: String) -> Bool {
|
||||
let url = URL(fileURLWithPath: path)
|
||||
|
||||
if let values = try? url.resourceValues(forKeys: [.isDirectoryKey]) {
|
||||
return values.isDirectory ?? false
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/// Copies files from one folder to another, overwriting any existing files with the same name.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - source: The path of the folder from which to copy files.
|
||||
/// - destination: The path to the folder at which to place the copied files.
|
||||
///
|
||||
/// - Note: This function does not copy files whose names begin with a period.
|
||||
func copyFiles(fromFolder source: String, toFolder destination: String) throws {
|
||||
assert(isFolder(atPath: source))
|
||||
assert(isFolder(atPath: destination))
|
||||
|
||||
let sourceURL = URL(fileURLWithPath: source)
|
||||
let destinationURL = URL(fileURLWithPath: destination)
|
||||
|
||||
let filenames = try self.contentsOfDirectory(atPath: source)
|
||||
|
||||
for oneFilename in filenames {
|
||||
if oneFilename.hasPrefix(".") {
|
||||
continue
|
||||
}
|
||||
|
||||
let sourceFile = sourceURL.appendingPathComponent(oneFilename)
|
||||
let destinationFile = destinationURL.appendingPathComponent(oneFilename)
|
||||
|
||||
try copyFile(atPath: sourceFile.path, toPath: destinationFile.path, overwriting: true)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Retrieve the names of files contained in a folder.
|
||||
///
|
||||
/// - Parameter folder: The path to the folder whose contents to retrieve.
|
||||
///
|
||||
/// - Returns: An array containing the names of files in `folder`, an empty
|
||||
/// array if `folder` does not refer to a folder, or `nil` if an error occurs.
|
||||
func filenames(inFolder folder: String) -> [String]? {
|
||||
assert(isFolder(atPath: folder))
|
||||
|
||||
guard isFolder(atPath: folder) else {
|
||||
return []
|
||||
}
|
||||
|
||||
return try? self.contentsOfDirectory(atPath: folder)
|
||||
}
|
||||
|
||||
/// Retrieve the full paths of files contained in a folder.
|
||||
///
|
||||
/// - Parameter folder: The path to the folder whose contents to retrieve.
|
||||
///
|
||||
/// - Returns: An array containing the full paths of files in `folder`,
|
||||
/// an empty array if `folder` does not refer to a folder, or `nil` if an error occurs.
|
||||
func filePaths(inFolder folder: String) -> [String]? {
|
||||
guard let filenames = self.filenames(inFolder: folder) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let url = URL(fileURLWithPath: folder)
|
||||
return filenames.map { url.appendingPathComponent($0).path }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension FileManager {
|
||||
|
||||
/// Copies a single file, possibly overwriting any existing file.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - source: The source path.
|
||||
/// - destination: The destination path.
|
||||
/// - overwriting: `true` if an existing file at `destination` should be overwritten.
|
||||
func copyFile(atPath source: String, toPath destination: String, overwriting: Bool) throws {
|
||||
assert(fileExists(atPath: source))
|
||||
|
||||
if fileExists(atPath: destination) {
|
||||
if (overwriting) {
|
||||
try removeItem(atPath: destination)
|
||||
}
|
||||
}
|
||||
|
||||
try copyItem(atPath: source, toPath: destination)
|
||||
}
|
||||
|
||||
}
|
48
RSCore/Sources/RSCore/Shared/Geometry.swift
Normal file
48
RSCore/Sources/RSCore/Shared/Geometry.swift
Normal file
@ -0,0 +1,48 @@
|
||||
//
|
||||
// Geometry.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Nate Weaver on 2020-01-01.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
#if os(macOS)
|
||||
public extension CGRect {
|
||||
|
||||
/// Centers a rectangle vertically in another rectangle.
|
||||
///
|
||||
/// - Parameter containerRect: The rectangle in which to be centered.
|
||||
/// - Returns: A new rectangle, cenetered vertically in `containerRect`,
|
||||
/// with the same size as the source rectangle.
|
||||
func centeredVertically(in containerRect: CGRect) -> CGRect {
|
||||
var r = self;
|
||||
r.origin.y = containerRect.midY - (r.height / 2.0);
|
||||
r = r.integral;
|
||||
r.size = self.size;
|
||||
return r;
|
||||
}
|
||||
|
||||
/// Centers a rectangle horizontally in another rectangle.
|
||||
///
|
||||
/// - Parameter containerRect: The rectangle in which to be centered.
|
||||
/// - Returns: A new rectangle, cenetered horizontally in `containerRect`,
|
||||
/// with the same size as the source rectangle.
|
||||
func centeredHorizontally(in containerRect: CGRect) -> CGRect {
|
||||
var r = self;
|
||||
r.origin.x = containerRect.midX - (r.width / 2.0);
|
||||
r = r.integral;
|
||||
r.size = self.size;
|
||||
return r;
|
||||
}
|
||||
|
||||
/// Centers a rectangle in another rectangle.
|
||||
///
|
||||
/// - Parameter containerRect: The rectangle in which to be centered.
|
||||
/// - Returns: A new rectangle, cenetered both horizontally and vertically
|
||||
/// in `containerRect`, with the same size as the source rectangle.
|
||||
func centered(in containerRect: CGRect) -> CGRect {
|
||||
return self.centeredHorizontally(in: self.centeredVertically(in: containerRect))
|
||||
}
|
||||
}
|
||||
#endif
|
86
RSCore/Sources/RSCore/Shared/MacroProcessor.swift
Normal file
86
RSCore/Sources/RSCore/Shared/MacroProcessor.swift
Normal file
@ -0,0 +1,86 @@
|
||||
//
|
||||
// MacroProcessor.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Nate Weaver on 2020-01-01.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum MacroProcessorError: Error {
|
||||
case emptyMacroDelimiter
|
||||
}
|
||||
|
||||
public class MacroProcessor {
|
||||
|
||||
let template: String
|
||||
let substitutions: [String: String]
|
||||
let macroStart: String
|
||||
let macroEnd: String
|
||||
lazy var renderedText: String = processMacros()
|
||||
|
||||
/// Parses a template string and replaces macros with specified values.
|
||||
///
|
||||
/// - Returns: A copy of `template` with defined macros replaced by their values.
|
||||
/// Macros with undefined values are left as-is.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - template: The template string to parse, with macros surrounded by `macroStart` and `macroEnd`.
|
||||
/// - substitutions: A dictionary mapping macro keys to their replacement values.
|
||||
/// - macroStart: A string denoting the beginning of a macro.
|
||||
/// - macroEnd: A string denoting the end of a macro.
|
||||
///
|
||||
/// - Throws: An error of type `MacroProcessorError`.
|
||||
|
||||
public static func renderedText(withTemplate template: String, substitutions: [String: String], macroStart: String = "[[", macroEnd: String = "]]") throws -> String {
|
||||
let processor = try MacroProcessor(template: template, substitutions: substitutions, macroStart: macroStart, macroEnd: macroEnd)
|
||||
return processor.renderedText
|
||||
}
|
||||
|
||||
init(template: String, substitutions: [String: String], macroStart: String = "[[", macroEnd: String = "]]") throws {
|
||||
if macroStart.isEmpty || macroEnd.isEmpty {
|
||||
throw MacroProcessorError.emptyMacroDelimiter
|
||||
}
|
||||
|
||||
self.template = template
|
||||
self.substitutions = substitutions
|
||||
self.macroStart = macroStart
|
||||
self.macroEnd = macroEnd
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension MacroProcessor {
|
||||
|
||||
func processMacros() -> String {
|
||||
var result = String()
|
||||
|
||||
var index = template.startIndex
|
||||
|
||||
while true {
|
||||
guard let macroStartRange = template[index...].range(of: macroStart) else {
|
||||
break
|
||||
}
|
||||
|
||||
result.append(contentsOf: template[index..<macroStartRange.lowerBound])
|
||||
|
||||
guard let macroEndRange = template[macroStartRange.upperBound...].range(of: macroEnd) else {
|
||||
index = macroStartRange.lowerBound
|
||||
break
|
||||
}
|
||||
|
||||
let key = String(template[macroStartRange.upperBound..<macroEndRange.lowerBound])
|
||||
let replacement = substitutions[key] ?? "\(macroStart)\(key)\(macroEnd)"
|
||||
|
||||
result.append(contentsOf: replacement)
|
||||
|
||||
index = macroEndRange.upperBound
|
||||
}
|
||||
|
||||
result.append(contentsOf: template[index...])
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
}
|
33
RSCore/Sources/RSCore/Shared/MainThreadBlockOperation.swift
Normal file
33
RSCore/Sources/RSCore/Shared/MainThreadBlockOperation.swift
Normal file
@ -0,0 +1,33 @@
|
||||
//
|
||||
// MainThreadBlockOperation.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 1/16/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Run a block of code as an operation.
|
||||
///
|
||||
/// This also serves as a simple example implementation of MainThreadOperation.
|
||||
public final class MainThreadBlockOperation: MainThreadOperation {
|
||||
|
||||
// MainThreadOperation
|
||||
public var isCanceled = false
|
||||
public var id: Int?
|
||||
public var operationDelegate: MainThreadOperationDelegate?
|
||||
public var name: String?
|
||||
public var completionBlock: MainThreadOperation.MainThreadOperationCompletionBlock?
|
||||
|
||||
private let block: VoidBlock
|
||||
|
||||
public init(block: @escaping VoidBlock) {
|
||||
self.block = block
|
||||
}
|
||||
|
||||
public func run() {
|
||||
block()
|
||||
informOperationDelegateOfCompletion()
|
||||
}
|
||||
}
|
99
RSCore/Sources/RSCore/Shared/MainThreadOperation.swift
Normal file
99
RSCore/Sources/RSCore/Shared/MainThreadOperation.swift
Normal file
@ -0,0 +1,99 @@
|
||||
//
|
||||
// MainThreadOperation.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 1/10/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Code to be run by MainThreadOperationQueue.
|
||||
///
|
||||
/// When finished, it must call operationDelegate.operationDidComplete(self).
|
||||
/// If 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: AnyObject {
|
||||
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
477
RSCore/Sources/RSCore/Shared/MainThreadOperationQueue.swift
Normal file
477
RSCore/Sources/RSCore/Shared/MainThreadOperationQueue.swift
Normal file
@ -0,0 +1,477 @@
|
||||
//
|
||||
// MainThreadOperationQueue.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 1/10/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public protocol MainThreadOperationDelegate: AnyObject {
|
||||
func operationDidComplete(_ operation: MainThreadOperation)
|
||||
func cancelOperation(_ operation: MainThreadOperation)
|
||||
func make(_ childOperation: MainThreadOperation, dependOn parentOperation: MainThreadOperation)
|
||||
}
|
||||
|
||||
/// Manage a queue of MainThreadOperation tasks.
|
||||
///
|
||||
/// Runs them one at a time; runs them on the main thread.
|
||||
/// Any operation can use DispatchQueue or whatever to run code off of the main thread.
|
||||
/// An operation calls back to the queue when it’s completed or canceled.
|
||||
///
|
||||
/// Use this only on the main thread.
|
||||
/// The operation can be suspended and resumed.
|
||||
/// It is *not* suspended on creation.
|
||||
public final class MainThreadOperationQueue {
|
||||
|
||||
/// Use the shared queue when you don’t need to create a separate queue.
|
||||
public static let shared: MainThreadOperationQueue = {
|
||||
MainThreadOperationQueue()
|
||||
}()
|
||||
|
||||
private var operations = [Int: MainThreadOperation]()
|
||||
private var pendingOperationIDs = [Int]()
|
||||
private var currentOperationID: Int?
|
||||
private static var incrementingID = 0
|
||||
private var isSuspended = false
|
||||
private let dependencies = MainThreadOperationDependencies()
|
||||
|
||||
/// Meant for testing; not intended to be useful.
|
||||
public var pendingOperationsCount: Int {
|
||||
return pendingOperationIDs.count
|
||||
}
|
||||
|
||||
public init() {
|
||||
// Silence compiler complaint about init not being public.
|
||||
}
|
||||
|
||||
deinit {
|
||||
cancelAllOperations()
|
||||
}
|
||||
|
||||
/// Add an operation to the queue.
|
||||
public func add(_ operation: MainThreadOperation) {
|
||||
precondition(Thread.isMainThread)
|
||||
operation.operationDelegate = self
|
||||
let operationID = ensureOperationID(operation)
|
||||
operations[operationID] = operation
|
||||
|
||||
assert(!pendingOperationIDs.contains(operationID))
|
||||
if !pendingOperationIDs.contains(operationID) {
|
||||
pendingOperationIDs.append(operationID)
|
||||
}
|
||||
|
||||
runNextOperationIfNeeded()
|
||||
}
|
||||
|
||||
/// Add multiple operations to the queue.
|
||||
/// This has the same effect as calling addOperation one-by-one.
|
||||
public func addOperations(_ operations: [MainThreadOperation]) {
|
||||
operations.forEach{ add($0) }
|
||||
}
|
||||
|
||||
/// Add a dependency. Do this *before* calling addOperation, since addOperation might run the operation right away.
|
||||
public func make(_ childOperation: MainThreadOperation, dependOn parentOperation: MainThreadOperation) {
|
||||
precondition(Thread.isMainThread)
|
||||
let childOperationID = ensureOperationID(childOperation)
|
||||
let parentOperationID = ensureOperationID(parentOperation)
|
||||
dependencies.make(childOperationID, dependOn: parentOperationID)
|
||||
}
|
||||
|
||||
/// Cancel all the current and pending operations.
|
||||
public func cancelAllOperations() {
|
||||
precondition(Thread.isMainThread)
|
||||
var operationIDsToCancel = pendingOperationIDs
|
||||
if let currentOperationID = currentOperationID {
|
||||
operationIDsToCancel.append(currentOperationID)
|
||||
}
|
||||
cancel(operationIDsToCancel)
|
||||
}
|
||||
|
||||
/// Cancel some operations. If any of them have dependent operations,
|
||||
/// those operations will be canceled also.
|
||||
public func cancelOperations(_ operations: [MainThreadOperation]) {
|
||||
precondition(Thread.isMainThread)
|
||||
let operationIDsToCancel = operations.map{ ensureOperationID($0) }
|
||||
assert(allOperationIDsArePendingOrCurrent(operationIDsToCancel))
|
||||
assert(allOperationIDsAreInStorage(operationIDsToCancel))
|
||||
|
||||
cancel(operationIDsToCancel)
|
||||
runNextOperationIfNeeded()
|
||||
}
|
||||
|
||||
/// Cancel operations with the given name. If any of them have dependent
|
||||
/// operations, they will be canceled too.
|
||||
///
|
||||
/// This will cancel the current operation, not just pending operations,
|
||||
/// if it has the specified name.
|
||||
public func cancelOperations(named name: String) {
|
||||
precondition(Thread.isMainThread)
|
||||
guard let operationsToCancel = pendingAndCurrentOperations(named: name) else {
|
||||
return
|
||||
}
|
||||
cancelOperations(operationsToCancel)
|
||||
}
|
||||
|
||||
/// Stop running operations until resume() is called.
|
||||
/// The current operation, if there is one, will run to completion —
|
||||
/// it will not be canceled.
|
||||
public func suspend() {
|
||||
precondition(Thread.isMainThread)
|
||||
isSuspended = true
|
||||
}
|
||||
|
||||
/// Resume running operations.
|
||||
public func resume() {
|
||||
precondition(Thread.isMainThread)
|
||||
isSuspended = false
|
||||
runNextOperationIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
extension MainThreadOperationQueue: MainThreadOperationDelegate {
|
||||
|
||||
public func operationDidComplete(_ operation: MainThreadOperation) {
|
||||
precondition(Thread.isMainThread)
|
||||
operationDidFinish(operation)
|
||||
}
|
||||
|
||||
public func cancelOperation(_ operation: MainThreadOperation) {
|
||||
cancelOperations([operation])
|
||||
}
|
||||
}
|
||||
|
||||
private extension MainThreadOperationQueue {
|
||||
|
||||
var pendingOperations: [MainThreadOperation] {
|
||||
return pendingOperationIDs.compactMap { (operationID) -> MainThreadOperation? in
|
||||
guard let operation = operations[operationID] else {
|
||||
assertionFailure("Expected operation, got nil.")
|
||||
return nil
|
||||
}
|
||||
return operation
|
||||
}
|
||||
}
|
||||
|
||||
var currentOperation: MainThreadOperation? {
|
||||
guard let operationID = currentOperationID else {
|
||||
return nil
|
||||
}
|
||||
return operations[operationID]
|
||||
}
|
||||
|
||||
func pendingAndCurrentOperations(named name: String) -> [MainThreadOperation]? {
|
||||
var operations = pendingOperations.filter { $0.name == name }
|
||||
if let current = currentOperation, current.name == name {
|
||||
operations.append(current)
|
||||
}
|
||||
return operations.isEmpty ? nil : operations
|
||||
}
|
||||
|
||||
func operationDidFinish(_ operation: MainThreadOperation) {
|
||||
guard let operationID = operation.id else {
|
||||
assertionFailure("Expected operation.id, got nil")
|
||||
return
|
||||
}
|
||||
if let currentOperationID = currentOperationID, currentOperationID == operationID {
|
||||
self.currentOperationID = nil
|
||||
}
|
||||
|
||||
if operation.isCanceled {
|
||||
dependencies.operationIDWasCanceled(operationID)
|
||||
}
|
||||
else {
|
||||
dependencies.operationIDDidComplete(operationID)
|
||||
}
|
||||
|
||||
callCompletionBlock(for: operation)
|
||||
removeFromStorage(operation)
|
||||
operation.operationDelegate = nil
|
||||
runNextOperationIfNeeded()
|
||||
}
|
||||
|
||||
func runNextOperationIfNeeded() {
|
||||
DispatchQueue.main.async {
|
||||
guard !self.isSuspended && !self.isRunningAnOperation() else {
|
||||
return
|
||||
}
|
||||
guard let operation = self.popNextAvailableOperation() else {
|
||||
return
|
||||
}
|
||||
self.currentOperationID = operation.id!
|
||||
operation.run()
|
||||
}
|
||||
}
|
||||
|
||||
func isRunningAnOperation() -> Bool {
|
||||
return currentOperationID != nil
|
||||
}
|
||||
|
||||
func popNextAvailableOperation() -> MainThreadOperation? {
|
||||
for operationID in pendingOperationIDs {
|
||||
guard let operation = operations[operationID] else {
|
||||
assertionFailure("Expected pending operation to be found in operations dictionary.")
|
||||
continue
|
||||
}
|
||||
if operationIsAvailable(operation) {
|
||||
removeOperationIDsFromPendingOperationIDs([operationID])
|
||||
dependencies.operationIDWillRun(operationID)
|
||||
return operation
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func operationIsAvailable(_ operation: MainThreadOperation) -> Bool {
|
||||
return !operation.isCanceled && !dependencies.operationIDIsBlockedByDependency(operation.id!)
|
||||
}
|
||||
|
||||
func createOperationID() -> Int {
|
||||
precondition(Thread.isMainThread)
|
||||
Self.incrementingID += 1
|
||||
return Self.incrementingID
|
||||
}
|
||||
|
||||
func ensureOperationID(_ operation: MainThreadOperation) -> Int {
|
||||
if let operationID = operation.id {
|
||||
return operationID
|
||||
}
|
||||
|
||||
let operationID = createOperationID()
|
||||
operation.id = operationID
|
||||
return operationID
|
||||
}
|
||||
|
||||
func cancel(_ operationIDs: [Int]) {
|
||||
guard !operationIDs.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
let operationIDsToCancel = operationIDsByAddingChildOperationIDs(operationIDs)
|
||||
setCanceledAndRemoveDelegate(for: operationIDsToCancel)
|
||||
callCompletionBlockForOperationIDs(operationIDsToCancel)
|
||||
clearCurrentOperationIDIfContained(by: operationIDsToCancel)
|
||||
removeOperationIDsFromPendingOperationIDs(operationIDsToCancel)
|
||||
removeOperationIDsFromStorage(operationIDsToCancel)
|
||||
dependencies.cancel(operationIDsToCancel)
|
||||
}
|
||||
|
||||
func operationIDsByAddingChildOperationIDs(_ operationIDs: [Int]) -> [Int] {
|
||||
var operationIDsToCancel = operationIDs
|
||||
for operationID in operationIDs {
|
||||
if let childOperationIDs = dependencies.childOperationIDs(for: operationID) {
|
||||
operationIDsToCancel += childOperationIDs
|
||||
}
|
||||
}
|
||||
return operationIDsToCancel
|
||||
}
|
||||
|
||||
func setCanceledAndRemoveDelegate(for operationIDs: [Int]) {
|
||||
for operationID in operationIDs {
|
||||
if let operation = operations[operationID] {
|
||||
operation.isCanceled = true
|
||||
operation.operationDelegate = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func clearCurrentOperationIDIfContained(by operationIDs: [Int]) {
|
||||
if let currentOperationID = currentOperationID, operationIDs.contains(currentOperationID) {
|
||||
self.currentOperationID = nil
|
||||
}
|
||||
}
|
||||
|
||||
func removeOperationIDsFromPendingOperationIDs(_ operationIDs: [Int]) {
|
||||
var updatedPendingOperationIDs = pendingOperationIDs
|
||||
for operationID in operationIDs {
|
||||
if let ix = updatedPendingOperationIDs.firstIndex(of: operationID) {
|
||||
updatedPendingOperationIDs.remove(at: ix)
|
||||
}
|
||||
}
|
||||
|
||||
pendingOperationIDs = updatedPendingOperationIDs
|
||||
}
|
||||
|
||||
func removeFromStorage(_ operation: MainThreadOperation) {
|
||||
guard let operationID = operation.id else {
|
||||
assertionFailure("Expected operation.id, got nil.")
|
||||
return
|
||||
}
|
||||
removeOperationIDsFromStorage([operationID])
|
||||
}
|
||||
|
||||
func removeOperationIDsFromStorage(_ operationIDs: [Int]) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
for operationID in operationIDs {
|
||||
self?.operations[operationID] = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func callCompletionBlockForOperationIDs(_ operationIDs: [Int]) {
|
||||
let completedOperations = operationIDs.compactMap { operations[$0] }
|
||||
callCompletionBlockForOperations(completedOperations)
|
||||
}
|
||||
|
||||
func callCompletionBlockForOperations(_ operations: [MainThreadOperation]) {
|
||||
for operation in operations {
|
||||
callCompletionBlock(for: operation)
|
||||
}
|
||||
}
|
||||
|
||||
func callCompletionBlock(for operation: MainThreadOperation) {
|
||||
guard let completionBlock = operation.completionBlock else {
|
||||
return
|
||||
}
|
||||
completionBlock(operation)
|
||||
operation.completionBlock = nil
|
||||
}
|
||||
|
||||
func allOperationIDsArePendingOrCurrent(_ operationIDs: [Int]) -> Bool {
|
||||
// Used by an assert.
|
||||
for operationID in operationIDs {
|
||||
if currentOperationID != operationID && !pendingOperationIDs.contains(operationID) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func allOperationIDsAreInStorage(_ operationIDs: [Int]) -> Bool {
|
||||
// Used by an assert.
|
||||
for operationID in operationIDs {
|
||||
guard let _ = operations[operationID] else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private final class MainThreadOperationDependencies {
|
||||
|
||||
private var dependencies = [Int: Dependency]() // Key is parentOperationID
|
||||
|
||||
private final class Dependency {
|
||||
|
||||
let operationID: Int
|
||||
var parentOperationDidComplete = false
|
||||
var isEmpty: Bool {
|
||||
return childOperationIDs.isEmpty
|
||||
}
|
||||
var childOperationIDs = [Int]()
|
||||
|
||||
init(operationID: Int) {
|
||||
self.operationID = operationID
|
||||
}
|
||||
|
||||
func remove(_ childOperationID: Int) {
|
||||
if let ix = childOperationIDs.firstIndex(of: childOperationID) {
|
||||
childOperationIDs.remove(at: ix)
|
||||
}
|
||||
}
|
||||
|
||||
func add(_ childOperationID: Int) {
|
||||
guard !childOperationIDs.contains(childOperationID) else {
|
||||
return
|
||||
}
|
||||
childOperationIDs.append(childOperationID)
|
||||
}
|
||||
|
||||
func operationIDIsBlocked(_ operationID: Int) -> Bool {
|
||||
if parentOperationDidComplete {
|
||||
return false
|
||||
}
|
||||
return childOperationIDs.contains(operationID)
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a dependency: make childOperationID dependent on parentOperationID.
|
||||
func make(_ childOperationID: Int, dependOn parentOperationID: Int) {
|
||||
let dependency = ensureDependency(parentOperationID)
|
||||
dependency.add(childOperationID)
|
||||
}
|
||||
|
||||
/// Child operationIDs for a possible dependency.
|
||||
func childOperationIDs(for parentOperationID: Int) -> [Int]? {
|
||||
if let dependency = dependencies[parentOperationID] {
|
||||
return dependency.childOperationIDs
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Update dependencies when an operation is completed.
|
||||
func operationIDDidComplete(_ operationID: Int) {
|
||||
if let dependency = dependencies[operationID] {
|
||||
dependency.parentOperationDidComplete = true
|
||||
}
|
||||
removeChildOperationID(operationID)
|
||||
removeEmptyDependencies()
|
||||
}
|
||||
|
||||
/// Update dependencies when an operation finished but was canceled.
|
||||
func operationIDWasCanceled(_ operationID: Int) {
|
||||
removeAllReferencesToOperationIDs([operationID])
|
||||
}
|
||||
|
||||
/// Update dependencies when canceling operations.
|
||||
func cancel(_ operationIDs: [Int]) {
|
||||
removeAllReferencesToOperationIDs(operationIDs)
|
||||
}
|
||||
|
||||
/// Update dependencies when an operation is about to run.
|
||||
func operationIDWillRun(_ operationID: Int) {
|
||||
removeChildOperationIDs([operationID])
|
||||
}
|
||||
|
||||
/// Find out if an operationID is blocked by a dependency.
|
||||
func operationIDIsBlockedByDependency(_ operationID: Int) -> Bool {
|
||||
for dependency in dependencies.values {
|
||||
if dependency.operationIDIsBlocked(operationID) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func ensureDependency(_ parentOperationID: Int) -> Dependency {
|
||||
if let dependency = dependencies[parentOperationID] {
|
||||
return dependency
|
||||
}
|
||||
let dependency = Dependency(operationID: parentOperationID)
|
||||
dependencies[parentOperationID] = dependency
|
||||
return dependency
|
||||
}
|
||||
}
|
||||
|
||||
private extension MainThreadOperationDependencies {
|
||||
|
||||
func removeAllReferencesToOperationIDs(_ operationIDs: [Int]) {
|
||||
removeDependencies(operationIDs)
|
||||
removeChildOperationIDs(operationIDs)
|
||||
}
|
||||
|
||||
func removeDependencies(_ parentOperationIDs: [Int]) {
|
||||
parentOperationIDs.forEach { dependencies[$0] = nil }
|
||||
}
|
||||
|
||||
func removeChildOperationIDs(_ operationIDs: [Int]) {
|
||||
operationIDs.forEach{ removeChildOperationID($0) }
|
||||
removeEmptyDependencies()
|
||||
}
|
||||
|
||||
func removeChildOperationID(_ operationID: Int) {
|
||||
dependencies.values.forEach{ $0.remove(operationID) }
|
||||
}
|
||||
|
||||
func removeEmptyDependencies() {
|
||||
let parentOperationIDs = dependencies.keys
|
||||
for parentOperationID in parentOperationIDs {
|
||||
let dependency = dependencies[parentOperationID]!
|
||||
if dependency.isEmpty {
|
||||
dependencies[parentOperationID] = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
127
RSCore/Sources/RSCore/Shared/ManagedResourceFile.swift
Normal file
127
RSCore/Sources/RSCore/Shared/ManagedResourceFile.swift
Normal file
@ -0,0 +1,127 @@
|
||||
//
|
||||
// ManagedResourceFile.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Maurice Parker on 9/13/19.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
@preconcurrency import Foundation
|
||||
|
||||
public final class ManagedResourceFile: NSObject, NSFilePresenter {
|
||||
|
||||
private var isDirty = false {
|
||||
didSet {
|
||||
queueSaveToDiskIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private var isLoading = false
|
||||
private let fileURL: URL
|
||||
private let operationQueue: OperationQueue
|
||||
private var saveQueue: CoalescingQueue
|
||||
|
||||
private let loadCallback: () -> Void
|
||||
private let saveCallback: () -> Void
|
||||
|
||||
public var saveInterval: TimeInterval = 5.0 {
|
||||
didSet {
|
||||
saveQueue.performCallsImmediately()
|
||||
saveQueue = CoalescingQueue(name: "ManagedResourceFile Save Queue", interval: saveInterval)
|
||||
}
|
||||
}
|
||||
|
||||
public var presentedItemURL: URL? {
|
||||
return fileURL
|
||||
}
|
||||
|
||||
public var presentedItemOperationQueue: OperationQueue {
|
||||
return operationQueue
|
||||
}
|
||||
|
||||
public init(fileURL: URL, load: @escaping () -> Void, save: @escaping () -> Void) {
|
||||
|
||||
self.fileURL = fileURL
|
||||
self.loadCallback = load
|
||||
self.saveCallback = save
|
||||
|
||||
saveQueue = CoalescingQueue(name: "ManagedResourceFile Save Queue", interval: saveInterval)
|
||||
operationQueue = OperationQueue()
|
||||
operationQueue.qualityOfService = .userInteractive
|
||||
operationQueue.maxConcurrentOperationCount = 1
|
||||
|
||||
super.init()
|
||||
|
||||
NSFileCoordinator.addFilePresenter(self)
|
||||
}
|
||||
|
||||
public func presentedItemDidChange() {
|
||||
guard !isDirty else { return }
|
||||
DispatchQueue.main.async {
|
||||
self.load()
|
||||
}
|
||||
}
|
||||
|
||||
public func savePresentedItemChanges(completionHandler: @escaping (Error?) -> Void) {
|
||||
saveIfNecessary()
|
||||
completionHandler(nil)
|
||||
}
|
||||
|
||||
public func relinquishPresentedItem(toReader reader: @escaping ((() -> Void)?) -> Void) {
|
||||
saveQueue.isPaused = true
|
||||
reader() {
|
||||
self.saveQueue.isPaused = false
|
||||
}
|
||||
}
|
||||
|
||||
public func relinquishPresentedItem(toWriter writer: @escaping ((() -> Void)?) -> Void) {
|
||||
saveQueue.isPaused = true
|
||||
writer() {
|
||||
self.saveQueue.isPaused = false
|
||||
}
|
||||
}
|
||||
|
||||
public func markAsDirty() {
|
||||
if !isLoading {
|
||||
isDirty = true
|
||||
}
|
||||
}
|
||||
|
||||
public func queueSaveToDiskIfNeeded() {
|
||||
saveQueue.add(self, #selector(saveToDiskIfNeeded))
|
||||
}
|
||||
|
||||
public func load() {
|
||||
isLoading = true
|
||||
loadCallback()
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
public func saveIfNecessary() {
|
||||
saveQueue.performCallsImmediately()
|
||||
}
|
||||
|
||||
public func resume() {
|
||||
NSFileCoordinator.addFilePresenter(self)
|
||||
}
|
||||
|
||||
public func suspend() {
|
||||
NSFileCoordinator.removeFilePresenter(self)
|
||||
}
|
||||
|
||||
deinit {
|
||||
NSFileCoordinator.removeFilePresenter(self)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension ManagedResourceFile {
|
||||
|
||||
@objc func saveToDiskIfNeeded() {
|
||||
if isDirty {
|
||||
isDirty = false
|
||||
saveCallback()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
21
RSCore/Sources/RSCore/Shared/OPMLRepresentable.swift
Normal file
21
RSCore/Sources/RSCore/Shared/OPMLRepresentable.swift
Normal file
@ -0,0 +1,21 @@
|
||||
//
|
||||
// OPMLRepresentable.swift
|
||||
// DataModel
|
||||
//
|
||||
// Created by Brent Simmons on 7/1/17.
|
||||
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public protocol OPMLRepresentable {
|
||||
|
||||
func OPMLString(indentLevel: Int, allowCustomAttributes: Bool) -> String
|
||||
}
|
||||
|
||||
public extension OPMLRepresentable {
|
||||
|
||||
func OPMLString(indentLevel: Int) -> String {
|
||||
return OPMLString(indentLevel: indentLevel, allowCustomAttributes: false)
|
||||
}
|
||||
}
|
59
RSCore/Sources/RSCore/Shared/Platform.swift
Normal file
59
RSCore/Sources/RSCore/Shared/Platform.swift
Normal file
@ -0,0 +1,59 @@
|
||||
//
|
||||
// Platform.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Nate Weaver on 2020-01-02.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
public enum Platform {
|
||||
|
||||
/// Get the path to a subfolder of the application's data folder (often `Application Support`).
|
||||
/// - Parameters:
|
||||
/// - appName: The name of the application.
|
||||
/// - folderName: The name of the subfolder in the application's data folder.
|
||||
public static func dataSubfolder(forApplication appName: String?, folderName: String) -> String? {
|
||||
guard let dataFolder = dataFile(forApplication: appName, filename: folderName) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: dataFolder, withIntermediateDirectories: true, attributes: nil)
|
||||
return dataFolder.path
|
||||
} catch {
|
||||
os_log(.error, log: .default, "Platform.dataSubfolder error: %@", error.localizedDescription)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private extension Platform {
|
||||
|
||||
static func dataFolder(forApplication appName: String?) -> URL? {
|
||||
do {
|
||||
var dataFolder = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: false)
|
||||
|
||||
if let appName = appName ?? Bundle.main.infoDictionary?["CFBundleExecutable"] as? String {
|
||||
|
||||
dataFolder = dataFolder.appendingPathComponent(appName)
|
||||
|
||||
try FileManager.default.createDirectory(at: dataFolder, withIntermediateDirectories: true, attributes: nil)
|
||||
}
|
||||
|
||||
return dataFolder
|
||||
} catch {
|
||||
os_log(.error, log: .default, "Platform.dataFolder error: %@", error.localizedDescription)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
static func dataFile(forApplication appName: String?, filename: String) -> URL? {
|
||||
let dataFolder = self.dataFolder(forApplication: appName)
|
||||
return dataFolder?.appendingPathComponent(filename)
|
||||
}
|
||||
}
|
32
RSCore/Sources/RSCore/Shared/PropertyList.swift
Normal file
32
RSCore/Sources/RSCore/Shared/PropertyList.swift
Normal file
@ -0,0 +1,32 @@
|
||||
//
|
||||
// PropertyList.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 7/12/17.
|
||||
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// These functions eat errors.
|
||||
|
||||
public func propertyList(withData data: Data) -> Any? {
|
||||
|
||||
do {
|
||||
return try PropertyListSerialization.propertyList(from: data, options: [], format: nil)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Create a binary plist.
|
||||
|
||||
public func data(withPropertyList plist: Any) -> Data? {
|
||||
|
||||
do {
|
||||
return try PropertyListSerialization.data(fromPropertyList: plist, format: .binary, options: 0)
|
||||
}
|
||||
catch {
|
||||
return nil
|
||||
}
|
||||
}
|
217
RSCore/Sources/RSCore/Shared/RSImage.swift
Normal file
217
RSCore/Sources/RSCore/Shared/RSImage.swift
Normal file
@ -0,0 +1,217 @@
|
||||
//
|
||||
// RSImage.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Maurice Parker on 4/11/19.
|
||||
// Copyright © 2019 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
public typealias RSImage = NSImage
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
public typealias RSImage = UIImage
|
||||
#endif
|
||||
|
||||
public extension RSImage {
|
||||
|
||||
/// Create a colored image from the source image using a specified color.
|
||||
///
|
||||
/// - Parameter color: The color with which to fill the mask image.
|
||||
/// - Returns: A new masked image.
|
||||
func maskWithColor(color: CGColor) -> RSImage? {
|
||||
|
||||
#if os(macOS)
|
||||
guard let maskImage = cgImage(forProposedRect: nil, context: nil, hints: nil) else { return nil }
|
||||
#else
|
||||
guard let maskImage = cgImage else { return nil }
|
||||
#endif
|
||||
|
||||
let width = size.width
|
||||
let height = size.height
|
||||
let bounds = CGRect(x: 0, y: 0, width: width, height: height)
|
||||
|
||||
let colorSpace = CGColorSpaceCreateDeviceRGB()
|
||||
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)
|
||||
let context = CGContext(data: nil, width: Int(width), height: Int(height), bitsPerComponent: 8, bytesPerRow: 0, space: colorSpace, bitmapInfo: bitmapInfo.rawValue)!
|
||||
|
||||
context.clip(to: bounds, mask: maskImage)
|
||||
context.setFillColor(color)
|
||||
context.fill(bounds)
|
||||
|
||||
if let cgImage = context.makeImage() {
|
||||
#if os(macOS)
|
||||
let coloredImage = RSImage(cgImage: cgImage, size: CGSize(width: cgImage.width, height: cgImage.height))
|
||||
#else
|
||||
let coloredImage = RSImage(cgImage: cgImage)
|
||||
#endif
|
||||
return coloredImage
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// Tint an image.
|
||||
///
|
||||
/// - Parameter color: The color to use to tint the image.
|
||||
/// - Returns: The tinted image.
|
||||
func tinted(color: UIColor) -> UIImage? {
|
||||
let image = withRenderingMode(.alwaysTemplate)
|
||||
let imageView = UIImageView(image: image)
|
||||
imageView.tintColor = color
|
||||
|
||||
UIGraphicsBeginImageContextWithOptions(image.size, false, 0.0)
|
||||
if let context = UIGraphicsGetCurrentContext() {
|
||||
imageView.layer.render(in: context)
|
||||
let tintedImage = UIGraphicsGetImageFromCurrentImageContext()
|
||||
UIGraphicsEndImageContext()
|
||||
return tintedImage
|
||||
} else {
|
||||
return self
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Returns a data representation of the image.
|
||||
///
|
||||
/// The resultant data is TIFF data on macOS, and PNG data on iOS.
|
||||
/// - Returns: Data representing the image.
|
||||
func dataRepresentation() -> Data? {
|
||||
#if os(macOS)
|
||||
return tiffRepresentation
|
||||
#else
|
||||
return pngData()
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Asynchronously initializes an image from data.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - data: The data object containing the image data.
|
||||
/// - imageResultBlock: The closure to call when the image has been initialized.
|
||||
static func image(with data: Data, imageResultBlock: @escaping ImageResultBlock) {
|
||||
DispatchQueue.global().async {
|
||||
let image = RSImage(data: data)
|
||||
DispatchQueue.main.async {
|
||||
imageResultBlock(image)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a scaled image from image data.
|
||||
///
|
||||
/// - Note: the returned image may be larger than `maxPixelSize`, but not more than `maxPixelSize * 2`.
|
||||
/// - Parameters:
|
||||
/// - data: The data object containing the image data.
|
||||
/// - maxPixelSize: The maximum dimension of the image.
|
||||
static func scaleImage(_ data: Data, maxPixelSize: Int) -> CGImage? {
|
||||
|
||||
guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let numberOfImages = CGImageSourceGetCount(imageSource)
|
||||
|
||||
// If the image size matches exactly, then return it.
|
||||
for i in 0..<numberOfImages {
|
||||
|
||||
guard let cfImageProperties = CGImageSourceCopyPropertiesAtIndex(imageSource, i, nil) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let imageProperties = cfImageProperties as NSDictionary
|
||||
|
||||
guard let imagePixelWidth = imageProperties[kCGImagePropertyPixelWidth] as? NSNumber else {
|
||||
continue
|
||||
}
|
||||
if imagePixelWidth.intValue != maxPixelSize {
|
||||
continue
|
||||
}
|
||||
|
||||
guard let imagePixelHeight = imageProperties[kCGImagePropertyPixelHeight] as? NSNumber else {
|
||||
continue
|
||||
}
|
||||
if imagePixelHeight.intValue != maxPixelSize {
|
||||
continue
|
||||
}
|
||||
|
||||
return CGImageSourceCreateImageAtIndex(imageSource, i, nil)
|
||||
}
|
||||
|
||||
// If image height > maxPixelSize, but <= maxPixelSize * 2, then return it.
|
||||
for i in 0..<numberOfImages {
|
||||
|
||||
guard let cfImageProperties = CGImageSourceCopyPropertiesAtIndex(imageSource, i, nil) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let imageProperties = cfImageProperties as NSDictionary
|
||||
|
||||
guard let imagePixelWidth = imageProperties[kCGImagePropertyPixelWidth] as? NSNumber else {
|
||||
continue
|
||||
}
|
||||
if imagePixelWidth.intValue > maxPixelSize * 2 || imagePixelWidth.intValue < maxPixelSize {
|
||||
continue
|
||||
}
|
||||
|
||||
guard let imagePixelHeight = imageProperties[kCGImagePropertyPixelHeight] as? NSNumber else {
|
||||
continue
|
||||
}
|
||||
if imagePixelHeight.intValue > maxPixelSize * 2 || imagePixelHeight.intValue < maxPixelSize {
|
||||
continue
|
||||
}
|
||||
|
||||
return CGImageSourceCreateImageAtIndex(imageSource, i, nil)
|
||||
}
|
||||
|
||||
|
||||
// If the image data contains a smaller image than the max size, just return it.
|
||||
for i in 0..<numberOfImages {
|
||||
|
||||
guard let cfImageProperties = CGImageSourceCopyPropertiesAtIndex(imageSource, i, nil) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let imageProperties = cfImageProperties as NSDictionary
|
||||
|
||||
guard let imagePixelWidth = imageProperties[kCGImagePropertyPixelWidth] as? NSNumber else {
|
||||
continue
|
||||
}
|
||||
if imagePixelWidth.intValue < 1 || imagePixelWidth.intValue > maxPixelSize {
|
||||
continue
|
||||
}
|
||||
|
||||
guard let imagePixelHeight = imageProperties[kCGImagePropertyPixelHeight] as? NSNumber else {
|
||||
continue
|
||||
}
|
||||
if imagePixelHeight.intValue > 0 && imagePixelHeight.intValue <= maxPixelSize {
|
||||
if let image = CGImageSourceCreateImageAtIndex(imageSource, i, nil) {
|
||||
return image
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return RSImage.createThumbnail(imageSource, maxPixelSize: maxPixelSize)
|
||||
|
||||
}
|
||||
|
||||
/// Create a thumbnail from a CGImageSource.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - imageSource: The `CGImageSource` from which to create the thumbnail.
|
||||
/// - maxPixelSize: The maximum dimension of the resulting image.
|
||||
static func createThumbnail(_ imageSource: CGImageSource, maxPixelSize: Int) -> CGImage? {
|
||||
let options = [kCGImageSourceCreateThumbnailWithTransform : true,
|
||||
kCGImageSourceCreateThumbnailFromImageIfAbsent : true,
|
||||
kCGImageSourceThumbnailMaxPixelSize : NSNumber(value: maxPixelSize)]
|
||||
return CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options as CFDictionary)
|
||||
}
|
||||
|
||||
}
|
25
RSCore/Sources/RSCore/Shared/RSScreen.swift
Normal file
25
RSCore/Sources/RSCore/Shared/RSScreen.swift
Normal file
@ -0,0 +1,25 @@
|
||||
//
|
||||
// RSScreen.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Maurice Parker on 4/11/19.
|
||||
// Copyright © 2019 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
public class RSScreen {
|
||||
public static var maxScreenScale = CGFloat(2)
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
|
||||
public class RSScreen {
|
||||
public static var maxScreenScale = CGFloat(3)
|
||||
}
|
||||
|
||||
#endif
|
23
RSCore/Sources/RSCore/Shared/Renamable.swift
Normal file
23
RSCore/Sources/RSCore/Shared/Renamable.swift
Normal file
@ -0,0 +1,23 @@
|
||||
//
|
||||
// Renamable.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 11/22/18.
|
||||
// Copyright © 2018 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// For anything that can be renamed by the user.
|
||||
|
||||
public protocol Renamable {
|
||||
|
||||
/// Renames an object.
|
||||
/// - Parameters:
|
||||
/// - to: The new name for the object.
|
||||
/// - completion: A block called when the renaming completes or fails.
|
||||
/// - result: The result of the renaming.
|
||||
func rename(to: String, completion: @escaping (_ result: Result<Void, Error>) -> Void)
|
||||
|
||||
}
|
||||
|
141
RSCore/Sources/RSCore/Shared/SendToBlogEditorApp.swift
Normal file
141
RSCore/Sources/RSCore/Shared/SendToBlogEditorApp.swift
Normal file
@ -0,0 +1,141 @@
|
||||
//
|
||||
// SendToBlogEditorApp.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Nate Weaver on 2020-01-04.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
#if os(macOS)
|
||||
import Foundation
|
||||
|
||||
/// This is for sending articles to MarsEdit and other apps that implement the
|
||||
/// [send-to-blog-editor Apple Events API](http://ranchero.com/netnewswire/developers/externalinterface)\.
|
||||
|
||||
public struct SendToBlogEditorApp {
|
||||
|
||||
///The target descriptor of the application.
|
||||
///
|
||||
/// The easiest way to get this is probably `UserApp.targetDescriptor` or `NSAppleEventDescriptor(runningApplication:)`.
|
||||
///
|
||||
/// This does not take care of launching the application in the first place.
|
||||
/// See UserApp.swift.
|
||||
private let targetDescriptor: NSAppleEventDescriptor
|
||||
private let title: String?
|
||||
private let body: String?
|
||||
private let summary: String?
|
||||
private let link: String?
|
||||
private let permalink: String?
|
||||
private let subject: String?
|
||||
private let creator: String?
|
||||
private let commentsURL: String?
|
||||
private let guid: String?
|
||||
private let sourceName: String?
|
||||
private let sourceHomeURL: String?
|
||||
private let sourceFeedURL: String?
|
||||
|
||||
public init(targetDescriptor: NSAppleEventDescriptor, title: String?, body: String?, summary: String?, link: String?, permalink: String?, subject: String?, creator: String?, commentsURL: String?, guid: String?, sourceName: String?, sourceHomeURL: String?, sourceFeedURL: String?) {
|
||||
self.targetDescriptor = targetDescriptor
|
||||
self.title = title
|
||||
self.body = body
|
||||
self.summary = summary
|
||||
self.link = link
|
||||
self.permalink = permalink
|
||||
self.subject = subject
|
||||
self.creator = creator
|
||||
self.commentsURL = commentsURL
|
||||
self.guid = guid
|
||||
self.sourceName = sourceName
|
||||
self.sourceHomeURL = sourceHomeURL
|
||||
self.sourceFeedURL = sourceFeedURL
|
||||
}
|
||||
|
||||
|
||||
/// Sends the receiver's data to the blog editor application described by `targetDescriptor`.
|
||||
public func send() {
|
||||
|
||||
let appleEvent = NSAppleEventDescriptor(eventClass: .editDataItemAppleEventClass, eventID: .editDataItemAppleEventID, targetDescriptor: targetDescriptor, returnID: .autoGenerate, transactionID: .any)
|
||||
|
||||
appleEvent.setParam(paramDescriptor, forKeyword: keyDirectObject)
|
||||
|
||||
let _ = try? appleEvent.sendEvent(options: [.noReply, .canSwitchLayer, .alwaysInteract], timeout: .AEDefaultTimeout)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension SendToBlogEditorApp {
|
||||
|
||||
var paramDescriptor: NSAppleEventDescriptor {
|
||||
let descriptor = NSAppleEventDescriptor.record()
|
||||
|
||||
add(toDescriptor: descriptor, value: title, keyword: .dataItemTitle)
|
||||
add(toDescriptor: descriptor, value: body, keyword: .dataItemDescription)
|
||||
add(toDescriptor: descriptor, value: summary, keyword: .dataItemSummary)
|
||||
add(toDescriptor: descriptor, value: link, keyword: .dataItemLink)
|
||||
add(toDescriptor: descriptor, value: permalink, keyword: .dataItemPermalink)
|
||||
add(toDescriptor: descriptor, value: subject, keyword: .dataItemSubject)
|
||||
add(toDescriptor: descriptor, value: creator, keyword: .dataItemCreator)
|
||||
add(toDescriptor: descriptor, value: commentsURL, keyword: .dataItemCommentsURL)
|
||||
add(toDescriptor: descriptor, value: guid, keyword: .dataItemGUID)
|
||||
add(toDescriptor: descriptor, value: sourceName, keyword: .dataItemSourceName)
|
||||
add(toDescriptor: descriptor, value: sourceHomeURL, keyword: .dataItemSourceHomeURL)
|
||||
add(toDescriptor: descriptor, value: sourceFeedURL, keyword: .dataItemSourceFeedURL)
|
||||
|
||||
return descriptor
|
||||
}
|
||||
|
||||
func add(toDescriptor descriptor: NSAppleEventDescriptor, value: String?, keyword: AEKeyword) {
|
||||
|
||||
guard let value = value else { return }
|
||||
|
||||
let stringDescriptor = NSAppleEventDescriptor.init(string: value)
|
||||
descriptor.setDescriptor(stringDescriptor, forKeyword: keyword)
|
||||
}
|
||||
}
|
||||
|
||||
private extension AEEventClass {
|
||||
|
||||
static let editDataItemAppleEventClass = "EBlg".fourCharCode
|
||||
|
||||
}
|
||||
|
||||
private extension AEEventID {
|
||||
|
||||
static let editDataItemAppleEventID = "oitm".fourCharCode
|
||||
|
||||
}
|
||||
|
||||
private extension AEKeyword {
|
||||
|
||||
static let dataItemTitle = "titl".fourCharCode
|
||||
static let dataItemDescription = "desc".fourCharCode
|
||||
static let dataItemSummary = "summ".fourCharCode
|
||||
static let dataItemLink = "link".fourCharCode
|
||||
static let dataItemPermalink = "plnk".fourCharCode
|
||||
static let dataItemSubject = "subj".fourCharCode
|
||||
static let dataItemCreator = "crtr".fourCharCode
|
||||
static let dataItemCommentsURL = "curl".fourCharCode
|
||||
static let dataItemGUID = "guid".fourCharCode
|
||||
static let dataItemSourceName = "snam".fourCharCode
|
||||
static let dataItemSourceHomeURL = "hurl".fourCharCode
|
||||
static let dataItemSourceFeedURL = "furl".fourCharCode
|
||||
|
||||
}
|
||||
|
||||
private extension AEReturnID {
|
||||
|
||||
static let autoGenerate = AEReturnID(kAutoGenerateReturnID)
|
||||
}
|
||||
|
||||
private extension AETransactionID {
|
||||
|
||||
static let any = AETransactionID(kAnyTransactionID)
|
||||
|
||||
}
|
||||
|
||||
private extension TimeInterval {
|
||||
|
||||
static let AEDefaultTimeout = TimeInterval(kAEDefaultTimeout)
|
||||
|
||||
}
|
||||
#endif
|
49
RSCore/Sources/RSCore/Shared/SendToCommand.swift
Normal file
49
RSCore/Sources/RSCore/Shared/SendToCommand.swift
Normal file
@ -0,0 +1,49 @@
|
||||
//
|
||||
// SendToCommand.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 1/8/18.
|
||||
// Copyright © 2018 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
/// A type that sends an object's data to an external application.
|
||||
///
|
||||
/// Unlike UndoableCommand commands, you instantiate one of each of these and reuse them.
|
||||
///
|
||||
/// See NetNewsWire.
|
||||
|
||||
public protocol SendToCommand {
|
||||
|
||||
/// The title of the command.
|
||||
///
|
||||
/// Often the name of the target application.
|
||||
var title: String { get }
|
||||
/// The image for the command.
|
||||
///
|
||||
/// Often the icon of the target application.
|
||||
var image: RSImage? { get }
|
||||
|
||||
/// Determine whether an object can be sent to the target application.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - object: The object to test.
|
||||
/// - selectedText: The currently selected text.
|
||||
/// - Returns: `true` if the object can be sent, `false` otherwise.
|
||||
func canSendObject(_ object: Any?, selectedText: String?) -> Bool
|
||||
|
||||
/// Send an object to the target application.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - object: The object whose data to send.
|
||||
/// - selectedText: The currently selected text.
|
||||
func sendObject(_ object: Any?, selectedText: String?)
|
||||
}
|
||||
|
29
RSCore/Sources/RSCore/Shared/Set+Extensions.swift
Executable file
29
RSCore/Sources/RSCore/Shared/Set+Extensions.swift
Executable file
@ -0,0 +1,29 @@
|
||||
//
|
||||
// Set+Extensions.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 3/13/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public extension Set {
|
||||
|
||||
func anyObjectPassingTest( _ test: (Element) -> Bool) -> Element? {
|
||||
|
||||
guard let index = self.firstIndex(where: test) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return self[index]
|
||||
}
|
||||
|
||||
func anyObject() -> Element? {
|
||||
|
||||
if self.isEmpty {
|
||||
return nil
|
||||
}
|
||||
return self[startIndex]
|
||||
}
|
||||
}
|
378
RSCore/Sources/RSCore/Shared/String+RSCore.swift
Normal file
378
RSCore/Sources/RSCore/Shared/String+RSCore.swift
Normal file
@ -0,0 +1,378 @@
|
||||
//
|
||||
// String+RSCore.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 11/26/18.
|
||||
// Copyright © 2018 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CommonCrypto
|
||||
|
||||
public extension String {
|
||||
|
||||
func htmlByAddingLink(_ link: String, className: String? = nil) -> String {
|
||||
if let className = className {
|
||||
return "<a class=\"\(className)\" href=\"\(link)\">\(self)</a>"
|
||||
}
|
||||
return "<a href=\"\(link)\">\(self)</a>"
|
||||
}
|
||||
|
||||
func htmlBySurroundingWithTag(_ tag: String) -> String {
|
||||
return "<\(tag)>\(self)</\(tag)>"
|
||||
}
|
||||
|
||||
static func htmlWithLink(_ link: String) -> String {
|
||||
return link.htmlByAddingLink(link)
|
||||
}
|
||||
|
||||
func hmacUsingSHA1(key: String) -> String {
|
||||
var digest = [UInt8](repeating: 0, count: Int(CC_SHA1_DIGEST_LENGTH))
|
||||
CCHmac(CCHmacAlgorithm(kCCHmacAlgSHA1), key, key.count, self, self.count, &digest)
|
||||
let data = Data(digest)
|
||||
return data.map { String(format: "%02hhx", $0) }.joined()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public extension String {
|
||||
|
||||
/// An MD5 hash of the string's UTF-8 representation.
|
||||
var md5Hash: Data {
|
||||
self.data(using: .utf8)!.md5Hash
|
||||
}
|
||||
|
||||
/// A hexadecimal representaion of an MD5 hash of the string's UTF-8 representation.
|
||||
var md5String: String {
|
||||
self.md5Hash.hexadecimalString!
|
||||
}
|
||||
|
||||
/// Trims leading and trailing whitespace, and collapses other whitespace into a single space.
|
||||
var collapsingWhitespace: String {
|
||||
var dest = self
|
||||
dest = dest.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
||||
return dest.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
||||
}
|
||||
|
||||
/// Trims whitespace from the beginning and end of the string.
|
||||
var trimmingWhitespace: String {
|
||||
self.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
/// Returns `true` if the string contains any character from a set.
|
||||
private func containsAnyCharacter(from charset: CharacterSet) -> Bool {
|
||||
return self.rangeOfCharacter(from: charset) != nil
|
||||
}
|
||||
|
||||
/// Returns `true` if a string may be an IPv6 URL.
|
||||
private var mayBeIPv6URL: Bool {
|
||||
self.range(of: "\\[[0-9a-fA-F:]+\\]", options: .regularExpression) != nil
|
||||
}
|
||||
|
||||
private var hostMayBeLocalhost: Bool {
|
||||
guard let components = URLComponents(string: self) else { return false }
|
||||
|
||||
if let host = components.host {
|
||||
return host == "localhost"
|
||||
}
|
||||
|
||||
// If self is schemeless:
|
||||
if components.path.split(separator: "/", omittingEmptySubsequences: false).first == "localhost" { return true }
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/// Returns `true` if the string may be a URL.
|
||||
var mayBeURL: Bool {
|
||||
|
||||
let s = self.trimmingWhitespace
|
||||
|
||||
if (s.isEmpty || (!s.contains(".") && !s.mayBeIPv6URL && !s.hostMayBeLocalhost)) {
|
||||
return false
|
||||
}
|
||||
|
||||
let banned = CharacterSet.whitespacesAndNewlines.union(.controlCharacters).union(.illegalCharacters)
|
||||
|
||||
if s.containsAnyCharacter(from: banned) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
|
||||
}
|
||||
|
||||
/// Normalizes a URL that could begin with "feed:" or "feeds:", converting
|
||||
/// it to a URL beginning with "http:" or "https:"
|
||||
///
|
||||
/// Strategy:
|
||||
/// 1) Note whether or not this is a feed: or feeds: or other prefix
|
||||
/// 2) Strip the feed: or feeds: prefix
|
||||
/// 3) If the resulting string is not prefixed with http: or https:, then add http:// as a prefix
|
||||
///
|
||||
/// - Note: Must handle edge case (like boingboing.net) where the feed URL is
|
||||
/// feed:http://boingboing.net/feed
|
||||
var normalizedURL: String {
|
||||
|
||||
/// Prefix constants.
|
||||
/// - Note: The lack of colon on `http(s)` is intentional.
|
||||
enum Prefix {
|
||||
static let feed = "feed:"
|
||||
static let feeds = "feeds:"
|
||||
static let http = "http"
|
||||
static let https = "https"
|
||||
}
|
||||
|
||||
var s = self.trimmingWhitespace
|
||||
var wasFeeds = false
|
||||
|
||||
var lowercaseS = s.lowercased()
|
||||
|
||||
if lowercaseS.hasPrefix(Prefix.feeds) {
|
||||
wasFeeds = true
|
||||
s = s.stripping(prefix: Prefix.feeds)
|
||||
} else if lowercaseS.hasPrefix(Prefix.feed) {
|
||||
s = s.stripping(prefix: Prefix.feed)
|
||||
}
|
||||
|
||||
if s.hasPrefix("//") {
|
||||
s = s.stripping(prefix: "//")
|
||||
}
|
||||
|
||||
lowercaseS = s.lowercased()
|
||||
if !lowercaseS.hasPrefix(Prefix.http) {
|
||||
s = "\(wasFeeds ? Prefix.https : Prefix.http)://\(s)"
|
||||
}
|
||||
|
||||
// Handle top-level URLs missing a trailing slash, as in https://ranchero.com — make it http://ranchero.com/
|
||||
// We’re sticklers for this kind of thing.
|
||||
// History: it used to be that on Windows they were always fine with no trailing slash,
|
||||
// and on Macs the trailing slash would appear. In recent years you’ve seen no trailing slash
|
||||
// on Macs too, but we’re bucking that trend. We’re Mac people, doggone it. Keepers of the flame.
|
||||
// Add the slash.
|
||||
let componentsCount = s.components(separatedBy: "/").count
|
||||
if componentsCount == 3 {
|
||||
s = s.appending("/")
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
/// Removes a prefix from the beginning of a string.
|
||||
/// - Parameters:
|
||||
/// - prefix: The prefix to remove
|
||||
/// - caseSensitive: `true` if the prefix should be matched case-sensitively.
|
||||
/// - Returns: A new string with the prefix removed.
|
||||
func stripping(prefix: String, caseSensitive: Bool = false) -> String {
|
||||
let options: String.CompareOptions = caseSensitive ? .anchored : [.anchored, .caseInsensitive]
|
||||
|
||||
if let range = self.range(of: prefix, options: options) {
|
||||
return self.replacingCharacters(in: range, with: "")
|
||||
}
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
/// Removes a suffix from the end of a string.
|
||||
/// - Parameters:
|
||||
/// - suffix: The suffix to remove
|
||||
/// - caseSensitive: `true` if the suffix should be matched case-sensitively.
|
||||
/// - Returns: A new string with the suffix removed.
|
||||
func stripping(suffix: String, caseSensitive: Bool = false) -> String {
|
||||
let options: String.CompareOptions = caseSensitive ? [.backwards, .anchored] : [.backwards, .anchored, .caseInsensitive]
|
||||
|
||||
if let range = self.range(of: suffix, options: options) {
|
||||
return self.replacingCharacters(in: range, with: "")
|
||||
}
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
/// Removes an HTML tag and everything between its start and end tags.
|
||||
///
|
||||
/// - Parameter tag: The tag to remove.
|
||||
///
|
||||
/// - Returns: A new copy of `self` with the tag removed.
|
||||
///
|
||||
/// - Note: Doesn't work correctly with nested tags of the same name.
|
||||
private func removingTagAndContents(_ tag: String) -> String {
|
||||
return self.replacingOccurrences(of: "<\(tag).+?</\(tag)>", with: "", options: [.regularExpression, .caseInsensitive])
|
||||
}
|
||||
|
||||
/// Strips HTML from a string.
|
||||
/// - Parameter maxCharacters: The maximum characters in the return string.
|
||||
/// If `nil`, the whole string is used.
|
||||
func strippingHTML(maxCharacters: Int? = nil) -> String {
|
||||
if !self.contains("<") {
|
||||
|
||||
if let maxCharacters = maxCharacters, maxCharacters < count {
|
||||
let ix = self.index(self.startIndex, offsetBy: maxCharacters)
|
||||
return String(self[..<ix])
|
||||
}
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
var preflight = self
|
||||
|
||||
// NOTE: If performance on repeated invocations becomes an issue here, the regexes can be cached.
|
||||
let options: String.CompareOptions = [.regularExpression, .caseInsensitive]
|
||||
preflight = preflight.replacingOccurrences(of: "</?(?:blockquote|p|div)>", with: " ", options: options)
|
||||
preflight = preflight.replacingOccurrences(of: "<p>|</?div>|<br(?: ?/)?>|</li>", with: "\n", options: options)
|
||||
preflight = preflight.removingTagAndContents("script")
|
||||
preflight = preflight.removingTagAndContents("style")
|
||||
|
||||
var s = String()
|
||||
s.reserveCapacity(preflight.count)
|
||||
var lastCharacterWasSpace = false
|
||||
var charactersAdded = 0
|
||||
var level = 0
|
||||
|
||||
for var char in preflight {
|
||||
if char == "<" {
|
||||
level += 1
|
||||
} else if char == ">" {
|
||||
level -= 1
|
||||
} else if level == 0 {
|
||||
|
||||
if char == " " || char == "\r" || char == "\t" || char == "\n" {
|
||||
if lastCharacterWasSpace {
|
||||
continue
|
||||
} else {
|
||||
lastCharacterWasSpace = true
|
||||
}
|
||||
char = " "
|
||||
} else {
|
||||
lastCharacterWasSpace = false
|
||||
}
|
||||
|
||||
s.append(char)
|
||||
|
||||
if let maxCharacters = maxCharacters {
|
||||
charactersAdded += 1
|
||||
if (charactersAdded >= maxCharacters) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
/// A copy of an HTML string converted to plain text.
|
||||
///
|
||||
/// Replaces `p`, `blockquote`, `div`, `br`, and `li` tags with varying quantities
|
||||
/// of newlines, strips all other tags, and guarantees no more than two consecutive newlines.
|
||||
///
|
||||
/// - Returns: A copy of self, with HTML tags removed..
|
||||
func convertingToPlainText() -> String {
|
||||
if !self.contains("<") {
|
||||
return self
|
||||
}
|
||||
|
||||
var preflight = self
|
||||
|
||||
// NOTE: If performance on repeated invocations becomes an issue here, the regexes can be cached.
|
||||
let options: String.CompareOptions = [.regularExpression, .caseInsensitive]
|
||||
preflight = preflight.replacingOccurrences(of: "</?blockquote>|</p>", with: "\n\n", options: options)
|
||||
preflight = preflight.replacingOccurrences(of: "<p>|</?div>|<br(?: ?/)?>|</li>", with: "\n", options: options)
|
||||
|
||||
var s = String()
|
||||
s.reserveCapacity(preflight.count)
|
||||
var level = 0
|
||||
|
||||
for char in preflight {
|
||||
if char == "<" {
|
||||
level += 1
|
||||
} else if char == ">" {
|
||||
level -= 1
|
||||
} else if level == 0 {
|
||||
s.append(char)
|
||||
}
|
||||
}
|
||||
|
||||
return s.replacingOccurrences(of: "\\n{3,}", with: "\n\n", options: .regularExpression)
|
||||
}
|
||||
|
||||
|
||||
/// Returns a Boolean value indicating whether the string contains another string, case-insensitively.
|
||||
///
|
||||
/// - Parameter string: The string to search for.
|
||||
///
|
||||
/// - Returns: `true` if the string contains `string`; `false` otherswise.
|
||||
func caseInsensitiveContains(_ string: String) -> Bool {
|
||||
return self.range(of: string, options: .caseInsensitive) != nil
|
||||
}
|
||||
|
||||
/// Returns the string with the special XML characters (other than single-quote) ampersand-escaped.
|
||||
///
|
||||
/// The four escaped characters are `<`, `>`, `&`, and `"`.
|
||||
var escapingSpecialXMLCharacters: String {
|
||||
var escaped = String()
|
||||
|
||||
for char in self {
|
||||
switch char {
|
||||
case "&":
|
||||
escaped.append("&")
|
||||
case "<":
|
||||
escaped.append("<")
|
||||
case ">":
|
||||
escaped.append(">")
|
||||
case "\"":
|
||||
escaped.append(""")
|
||||
default:
|
||||
escaped.append(char)
|
||||
}
|
||||
}
|
||||
|
||||
return escaped
|
||||
}
|
||||
|
||||
/// Initializes a string with a run of tabs.
|
||||
///
|
||||
/// - Parameter tabCount: The number of tabs in the returned string. Must be greater than or equal to zero.
|
||||
init(tabCount: Int) {
|
||||
enum Cache {
|
||||
static var tabs: [Int: String] = [:]
|
||||
}
|
||||
|
||||
if let cachedString = Cache.tabs[tabCount] {
|
||||
self = cachedString
|
||||
} else {
|
||||
let s = String(repeating: "\t", count: tabCount)
|
||||
Cache.tabs[tabCount] = s
|
||||
self = s
|
||||
}
|
||||
}
|
||||
|
||||
/// Prepends tabs to a string.
|
||||
///
|
||||
/// - Parameter tabCount: The number of tabs to prepend. Must be greater than or equal to zero.
|
||||
///
|
||||
/// - Returns: The string with `numberOfTabs` tabs prepended.
|
||||
func prepending(tabCount: Int) -> String {
|
||||
|
||||
let tabs = String(tabCount: tabCount)
|
||||
return "\(tabs)\(self)"
|
||||
}
|
||||
|
||||
/// Returns the string with `http://` or `https://` removed from the beginning.
|
||||
var strippingHTTPOrHTTPSScheme: String {
|
||||
self.stripping(prefix: "http://").stripping(prefix: "https://")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public extension String {
|
||||
var isSingleEmoji: Bool { count == 1 && containsEmoji }
|
||||
|
||||
var containsEmoji: Bool { contains { $0.isEmoji } }
|
||||
|
||||
var containsOnlyEmoji: Bool { !isEmpty && !contains { !$0.isEmoji } }
|
||||
|
||||
var emojiString: String { emojis.map { String($0) }.reduce("", +) }
|
||||
|
||||
var emojis: [Character] { filter { $0.isEmoji } }
|
||||
|
||||
var emojiScalars: [UnicodeScalar] { filter { $0.isEmoji }.flatMap { $0.unicodeScalars } }
|
||||
}
|
76
RSCore/Sources/RSCore/Shared/UndoableCommand.swift
Normal file
76
RSCore/Sources/RSCore/Shared/UndoableCommand.swift
Normal file
@ -0,0 +1,76 @@
|
||||
//
|
||||
// UndoableCommand.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 10/24/17.
|
||||
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public protocol UndoableCommand: AnyObject {
|
||||
|
||||
var undoActionName: String { get }
|
||||
var redoActionName: String { get }
|
||||
var undoManager: UndoManager { get }
|
||||
|
||||
func perform() // must call registerUndo()
|
||||
func undo() // must call registerRedo()
|
||||
}
|
||||
|
||||
extension UndoableCommand {
|
||||
|
||||
public func registerUndo() {
|
||||
|
||||
undoManager.setActionName(undoActionName)
|
||||
undoManager.registerUndo(withTarget: self) { (target) in
|
||||
self.undo()
|
||||
}
|
||||
}
|
||||
|
||||
public func registerRedo() {
|
||||
|
||||
undoManager.setActionName(redoActionName)
|
||||
undoManager.registerUndo(withTarget: self) { (target) in
|
||||
self.perform()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Useful for view controllers.
|
||||
|
||||
public protocol UndoableCommandRunner: AnyObject {
|
||||
|
||||
var undoableCommands: [UndoableCommand] { get set }
|
||||
var undoManager: UndoManager? { get }
|
||||
|
||||
func runCommand(_ undoableCommand: UndoableCommand)
|
||||
func clearUndoableCommands()
|
||||
}
|
||||
|
||||
public extension UndoableCommandRunner {
|
||||
|
||||
func runCommand(_ undoableCommand: UndoableCommand) {
|
||||
|
||||
pushUndoableCommand(undoableCommand)
|
||||
undoableCommand.perform()
|
||||
}
|
||||
|
||||
func pushUndoableCommand(_ undoableCommand: UndoableCommand) {
|
||||
|
||||
undoableCommands += [undoableCommand]
|
||||
}
|
||||
|
||||
func clearUndoableCommands() {
|
||||
|
||||
// Useful, for example, when timeline is reloaded and the list of articles changes.
|
||||
// Otherwise things like Redo Mark Read are ambiguous.
|
||||
// (Do they apply to the previous articles or to the current articles?)
|
||||
|
||||
guard let undoManager = undoManager else {
|
||||
return
|
||||
}
|
||||
undoableCommands.forEach { undoManager.removeAllActions(withTarget: $0) }
|
||||
undoableCommands = [UndoableCommand]()
|
||||
}
|
||||
}
|
42
RSCore/Sources/RSCore/UIKit/UIResponder+RSCore.swift
Normal file
42
RSCore/Sources/RSCore/UIKit/UIResponder+RSCore.swift
Normal file
@ -0,0 +1,42 @@
|
||||
//
|
||||
// UIResponder+.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Maurice Parker on 11/17/20.
|
||||
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
|
||||
extension UIResponder {
|
||||
|
||||
private weak static var _currentFirstResponder: UIResponder? = nil
|
||||
|
||||
public static var isFirstResponderTextField: Bool {
|
||||
var isTextField = false
|
||||
if let firstResponder = UIResponder.currentFirstResponder {
|
||||
isTextField = firstResponder.isKind(of: UITextField.self) || firstResponder.isKind(of: UITextView.self) || firstResponder.isKind(of: UISearchBar.self)
|
||||
}
|
||||
|
||||
return isTextField
|
||||
}
|
||||
|
||||
public static var currentFirstResponder: UIResponder? {
|
||||
UIResponder._currentFirstResponder = nil
|
||||
UIApplication.shared.sendAction(#selector(findFirstResponder(sender:)), to: nil, from: nil, for: nil)
|
||||
return UIResponder._currentFirstResponder
|
||||
}
|
||||
|
||||
public static func resignCurrentFirstResponder() {
|
||||
if let responder = currentFirstResponder {
|
||||
responder.resignFirstResponder()
|
||||
}
|
||||
}
|
||||
|
||||
@objc internal func findFirstResponder(sender: AnyObject) {
|
||||
UIResponder._currentFirstResponder = self
|
||||
}
|
||||
|
||||
}
|
||||
#endif
|
41
RSCore/Sources/RSCore/UIKit/UIView+RSCore.swift
Normal file
41
RSCore/Sources/RSCore/UIKit/UIView+RSCore.swift
Normal file
@ -0,0 +1,41 @@
|
||||
//
|
||||
// UIView-Extensions.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Maurice Parker on 4/20/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
|
||||
extension UIView {
|
||||
|
||||
public func setFrameIfNotEqual(_ rect: CGRect) {
|
||||
if !self.frame.equalTo(rect) {
|
||||
self.frame = rect
|
||||
}
|
||||
}
|
||||
|
||||
public func addChildAndPin(_ view: UIView) {
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(view)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
safeAreaLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
safeAreaLayoutGuide.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
safeAreaLayoutGuide.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
safeAreaLayoutGuide.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
||||
])
|
||||
|
||||
}
|
||||
|
||||
public func asImage() -> UIImage {
|
||||
let renderer = UIGraphicsImageRenderer(bounds: bounds)
|
||||
return renderer.image { rendererContext in
|
||||
layer.render(in: rendererContext.cgContext)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
#endif
|
68
RSCore/Sources/RSCore/UIKit/UIViewController+RSCore.swift
Normal file
68
RSCore/Sources/RSCore/UIKit/UIViewController+RSCore.swift
Normal file
@ -0,0 +1,68 @@
|
||||
//
|
||||
// UIViewController-Extensions.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Maurice Parker on 4/15/19.
|
||||
// Copyright © 2019 Ranchero Software. All rights reserved.
|
||||
//
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
|
||||
extension UIViewController {
|
||||
|
||||
// MARK: Autolayout
|
||||
|
||||
public func addChildAndPinView(_ controller: UIViewController) {
|
||||
view.addChildAndPin(controller.view)
|
||||
addChild(controller)
|
||||
}
|
||||
|
||||
public func replaceChildAndPinView(_ controller: UIViewController) {
|
||||
view.subviews.forEach { $0.removeFromSuperview() }
|
||||
children.forEach { $0.removeFromParent() }
|
||||
addChildAndPinView(controller)
|
||||
}
|
||||
|
||||
// MARK: Error Handling
|
||||
|
||||
public func presentError(title: String, message: String, dismiss: (() -> Void)? = nil) {
|
||||
let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
||||
let dismissTitle = NSLocalizedString("OK", comment: "OK")
|
||||
let dismissAction = UIAlertAction(title: dismissTitle, style: .default) { _ in
|
||||
dismiss?()
|
||||
}
|
||||
alertController.addAction(dismissAction)
|
||||
self.present(alertController, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: SwiftUI
|
||||
|
||||
public struct ViewControllerHolder {
|
||||
public weak var value: UIViewController?
|
||||
}
|
||||
|
||||
public struct ViewControllerKey: EnvironmentKey {
|
||||
public static var defaultValue: ViewControllerHolder { return ViewControllerHolder(value: nil ) }
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
public var viewController: UIViewController? {
|
||||
get { return self[ViewControllerKey.self].value }
|
||||
set { self[ViewControllerKey.self].value = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
extension UIViewController {
|
||||
public func present<Content: View>(style: UIModalPresentationStyle = .automatic, @ViewBuilder builder: () -> Content) {
|
||||
let controller = UIHostingController(rootView: AnyView(EmptyView()))
|
||||
controller.modalPresentationStyle = style
|
||||
controller.rootView = AnyView(
|
||||
builder().environment(\.viewController, controller)
|
||||
)
|
||||
self.present(controller, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
#endif
|
42
RSCore/Sources/RSCore/UIKit/UIWindow+RSCore.swift
Normal file
42
RSCore/Sources/RSCore/UIKit/UIWindow+RSCore.swift
Normal file
@ -0,0 +1,42 @@
|
||||
//
|
||||
// UIViewController+.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Maurice Parker on 4/15/19.
|
||||
// Copyright © 2019 Ranchero Software. All rights reserved.
|
||||
//
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
|
||||
extension UIWindow {
|
||||
|
||||
public var topViewController: UIViewController? {
|
||||
|
||||
var top = self.rootViewController
|
||||
while true {
|
||||
if let presented = top?.presentedViewController {
|
||||
top = presented
|
||||
} else if let nav = top as? UINavigationController {
|
||||
top = nav.visibleViewController
|
||||
} else if let tab = top as? UITabBarController {
|
||||
top = tab.selectedViewController
|
||||
} else if let split = top as? UISplitViewController {
|
||||
switch split.displayMode {
|
||||
case .allVisible:
|
||||
top = split.viewControllers.first
|
||||
case .primaryHidden:
|
||||
top = split.viewControllers.last
|
||||
default:
|
||||
top = split.viewControllers.first
|
||||
}
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return top
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
#endif
|
37
RSCore/Sources/RSCoreObjC/NSData+RSCore.h
Executable file
37
RSCore/Sources/RSCoreObjC/NSData+RSCore.h
Executable file
@ -0,0 +1,37 @@
|
||||
//
|
||||
// NSData+RSCore.h
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 3/25/15.
|
||||
// Copyright (c) 2015 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
@import Foundation;
|
||||
|
||||
|
||||
BOOL RSEqualBytes(const void *bytes1, const void *bytes2, size_t length);
|
||||
|
||||
NSString *RSHexadecimalStringWithBytes(const unsigned char *bytes, NSUInteger numberOfBytes);
|
||||
|
||||
|
||||
@interface NSData (RSCore)
|
||||
|
||||
- (NSData *)rs_md5Hash;
|
||||
- (NSString *)rs_md5HashString;
|
||||
|
||||
- (BOOL)rs_dataIsPNG;
|
||||
- (BOOL)rs_dataIsGIF;
|
||||
- (BOOL)rs_dataIsJPEG;
|
||||
- (BOOL)rs_dataIsImage;
|
||||
|
||||
- (BOOL)rs_dataIsProbablyHTML;
|
||||
|
||||
- (BOOL)rs_dataBeginsWithBytes:(const void *)bytes length:(size_t)numberOfBytes;
|
||||
|
||||
- (NSString *)rs_noCopyString; //This data object must out-live returned string. May return nil.
|
||||
|
||||
/*If bytes are deadbeef, then string is @"deadbeef". Returns nil for empty data.*/
|
||||
|
||||
- (NSString *)rs_hexadecimalString;
|
||||
|
||||
@end
|
156
RSCore/Sources/RSCoreObjC/NSData+RSCore.m
Executable file
156
RSCore/Sources/RSCoreObjC/NSData+RSCore.m
Executable file
@ -0,0 +1,156 @@
|
||||
//
|
||||
// NSData+RSCore.m
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 3/25/15.
|
||||
// Copyright (c) 2015 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
#import <CommonCrypto/CommonDigest.h>
|
||||
#import "NSData+RSCore.h"
|
||||
|
||||
|
||||
@implementation NSData (RSCore)
|
||||
|
||||
|
||||
#pragma GCC diagnostic push
|
||||
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
|
||||
- (NSData *)rs_md5Hash {
|
||||
|
||||
unsigned char hash[CC_MD5_DIGEST_LENGTH];
|
||||
CC_MD5([self bytes], (CC_LONG)[self length], hash);
|
||||
|
||||
return [NSData dataWithBytes:(const void *)hash length:CC_MD5_DIGEST_LENGTH];
|
||||
}
|
||||
#pragma GCC diagnostic pop
|
||||
|
||||
- (NSString *)rs_md5HashString {
|
||||
|
||||
NSData *d = [self rs_md5Hash];
|
||||
return [d rs_hexadecimalString];
|
||||
}
|
||||
|
||||
BOOL RSEqualBytes(const void *bytes1, const void *bytes2, size_t length) {
|
||||
|
||||
return memcmp(bytes1, bytes2, length) == 0;
|
||||
}
|
||||
|
||||
|
||||
- (BOOL)rs_dataBeginsWithBytes:(const void *)bytes length:(size_t)numberOfBytes {
|
||||
|
||||
if ([self length] < numberOfBytes) {
|
||||
return NO;
|
||||
}
|
||||
|
||||
return RSEqualBytes([self bytes], bytes, numberOfBytes);
|
||||
}
|
||||
|
||||
|
||||
- (BOOL)rs_dataIsPNG {
|
||||
|
||||
/* http://www.w3.org/TR/PNG/#5PNG-file-signature : "The first eight bytes of a PNG datastream always contain the following (decimal) values: 137 80 78 71 13 10 26 10" */
|
||||
|
||||
static const Byte pngHeader[] = {137, 'P', 'N', 'G', 13, 10, 26, 10};
|
||||
return [self rs_dataBeginsWithBytes:pngHeader length:sizeof(pngHeader)];
|
||||
}
|
||||
|
||||
|
||||
- (BOOL)rs_dataIsGIF {
|
||||
|
||||
/* http://www.onicos.com/staff/iz/formats/gif.html */
|
||||
|
||||
static const Byte gifHeader1[] = {'G', 'I', 'F', '8', '7', 'a'};
|
||||
if ([self rs_dataBeginsWithBytes:gifHeader1 length:sizeof(gifHeader1)]) {
|
||||
return YES;
|
||||
}
|
||||
|
||||
static const Byte gifHeader2[] = {'G', 'I', 'F', '8', '9', 'a'};
|
||||
return [self rs_dataBeginsWithBytes:gifHeader2 length:sizeof(gifHeader2)];
|
||||
}
|
||||
|
||||
|
||||
- (BOOL)rs_dataIsJPEG {
|
||||
|
||||
const void *bytes = [self bytes];
|
||||
|
||||
static const Byte jpegHeader1[] = {'J', 'F', 'I', 'F'};
|
||||
|
||||
if (RSEqualBytes(bytes + 6, jpegHeader1, sizeof(jpegHeader1))) {
|
||||
return YES;
|
||||
}
|
||||
|
||||
static const Byte jpegHeader2[] = {'E', 'x', 'i', 'f'};
|
||||
return RSEqualBytes(bytes + 6, jpegHeader2, sizeof(jpegHeader2));
|
||||
}
|
||||
|
||||
|
||||
- (BOOL)rs_dataIsImage {
|
||||
|
||||
return [self rs_dataIsPNG] || [self rs_dataIsJPEG] || [self rs_dataIsGIF];
|
||||
}
|
||||
|
||||
|
||||
- (BOOL)rs_dataIsProbablyHTML {
|
||||
|
||||
NSString *s = [self rs_noCopyString];
|
||||
if (!s) {
|
||||
return NO;
|
||||
}
|
||||
|
||||
if (![s containsString:@">"] || ![s containsString:@">"]) {
|
||||
return NO;
|
||||
}
|
||||
|
||||
for (NSString *oneString in @[@"html", @"body"]) {
|
||||
NSRange range = [s rangeOfString:oneString options:NSCaseInsensitiveSearch];
|
||||
if (range.location == NSNotFound) {
|
||||
return NO;
|
||||
}
|
||||
}
|
||||
|
||||
return YES;
|
||||
}
|
||||
|
||||
|
||||
- (NSString *)rs_noCopyString {
|
||||
|
||||
NSDictionary *options = @{NSStringEncodingDetectionSuggestedEncodingsKey : @[@(NSUTF8StringEncoding)]};
|
||||
BOOL usedLossyConversion = NO;
|
||||
NSStringEncoding encoding = [NSString stringEncodingForData:self encodingOptions:options convertedString:nil usedLossyConversion:&usedLossyConversion];
|
||||
if (encoding == 0) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
return [[NSString alloc] initWithBytesNoCopy:(void *)self.bytes length:self.length encoding:encoding freeWhenDone:NO];
|
||||
}
|
||||
|
||||
|
||||
NSString *RSHexadecimalStringWithBytes(const Byte *bytes, NSUInteger numberOfBytes) {
|
||||
|
||||
if (numberOfBytes < 1) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
if (numberOfBytes == 16) {
|
||||
// Common case — MD5 hash, for example.
|
||||
return [NSString stringWithFormat:@"%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x", bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15]];
|
||||
}
|
||||
|
||||
NSMutableString *s = [[NSMutableString alloc] initWithString:@""];
|
||||
NSUInteger i = 0;
|
||||
|
||||
for (i = 0; i < numberOfBytes; i++) {
|
||||
[s appendString:[NSString stringWithFormat:@"%02x", bytes[i]]];
|
||||
}
|
||||
|
||||
return [s copy];
|
||||
}
|
||||
|
||||
|
||||
- (NSString *)rs_hexadecimalString {
|
||||
|
||||
return RSHexadecimalStringWithBytes([self bytes], [self length]);
|
||||
}
|
||||
|
||||
|
||||
@end
|
23
RSCore/Sources/RSCoreObjC/NSSharingService+RSCore.h
Normal file
23
RSCore/Sources/RSCoreObjC/NSSharingService+RSCore.h
Normal file
@ -0,0 +1,23 @@
|
||||
//
|
||||
// NSObject+NSSharingService_RSCore.h
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 11/3/24.
|
||||
//
|
||||
|
||||
@import AppKit;
|
||||
|
||||
@interface NSSharingService (NoDeprecationWarning)
|
||||
|
||||
// The only way to create custom UI — a Share menu, for instance —
|
||||
// is to use the unfortunately deprecated
|
||||
// +[NSSharingService sharingServicesForItems:].
|
||||
// This cover method allows us to not generate a warning.
|
||||
//
|
||||
// We know it’s deprecated, and we don’t want to be bugged
|
||||
// about it every time we build. (If anyone from Apple
|
||||
// is reading this — a replacement would be very welcome!)
|
||||
|
||||
+ (NSArray *)sharingServicesForItems_noDeprecationWarning:(NSArray *)items;
|
||||
|
||||
@end
|
22
RSCore/Sources/RSCoreObjC/NSSharingService+RSCore.m
Normal file
22
RSCore/Sources/RSCoreObjC/NSSharingService+RSCore.m
Normal file
@ -0,0 +1,22 @@
|
||||
//
|
||||
// NSSharingService+RSCore.m
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 11/3/24.
|
||||
//
|
||||
|
||||
#import "NSSharingService+RSCore.h"
|
||||
|
||||
@implementation NSSharingService (NoDeprecationWarning)
|
||||
|
||||
+ (NSArray *)sharingServicesForItems_noDeprecationWarning:(NSArray *)items {
|
||||
|
||||
#pragma clang diagnostic push
|
||||
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
|
||||
|
||||
return [NSSharingService sharingServicesForItems:items];
|
||||
|
||||
#pragma clang diagnostic pop
|
||||
}
|
||||
|
||||
@end
|
11
RSCore/Sources/RSCoreObjC/include/RSCore.h
Normal file
11
RSCore/Sources/RSCoreObjC/include/RSCore.h
Normal file
@ -0,0 +1,11 @@
|
||||
//
|
||||
// RSCore.h
|
||||
//
|
||||
//
|
||||
// Created by Maurice Parker on 11/20/20.
|
||||
//
|
||||
|
||||
@import Foundation;
|
||||
|
||||
#import "../NSData+RSCore.h"
|
||||
#import "../NSSharingService+RSCore.h"
|
@ -0,0 +1,71 @@
|
||||
//
|
||||
// IndeterminateProgressWindowController.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 8/28/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
public func runIndeterminateProgressWithMessage(_ message: String) {
|
||||
|
||||
IndeterminateProgressController.beginProgressWithMessage(message)
|
||||
}
|
||||
|
||||
public func stopIndeterminateProgress() {
|
||||
|
||||
IndeterminateProgressController.endProgress()
|
||||
}
|
||||
|
||||
private final class IndeterminateProgressController {
|
||||
|
||||
static var windowController: IndeterminateProgressWindowController?
|
||||
static var runningProgressWindow = false
|
||||
|
||||
static func beginProgressWithMessage(_ message: String) {
|
||||
|
||||
if runningProgressWindow {
|
||||
assertionFailure("Expected !runningProgressWindow.")
|
||||
endProgress()
|
||||
}
|
||||
|
||||
runningProgressWindow = true
|
||||
windowController = IndeterminateProgressWindowController(message: message)
|
||||
NSApplication.shared.runModal(for: windowController!.window!)
|
||||
}
|
||||
|
||||
static func endProgress() {
|
||||
|
||||
if !runningProgressWindow {
|
||||
assertionFailure("Expected runningProgressWindow.")
|
||||
return
|
||||
}
|
||||
|
||||
runningProgressWindow = false
|
||||
NSApplication.shared.stopModal()
|
||||
windowController?.close()
|
||||
windowController = nil
|
||||
}
|
||||
}
|
||||
|
||||
private final class IndeterminateProgressWindowController: NSWindowController {
|
||||
|
||||
@IBOutlet var messageLabel: NSTextField!
|
||||
@IBOutlet var progressIndicator: NSProgressIndicator!
|
||||
@objc dynamic var message = ""
|
||||
|
||||
convenience init(message: String) {
|
||||
self.init(window: nil)
|
||||
self.message = message
|
||||
Bundle.module.loadNibNamed("IndeterminateProgressWindow", owner: self, topLevelObjects: nil)
|
||||
}
|
||||
|
||||
override func windowDidLoad() {
|
||||
|
||||
progressIndicator.startAnimation(self)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
|
@ -0,0 +1,40 @@
|
||||
//
|
||||
// WebViewWindowController.swift
|
||||
// RSCore
|
||||
//
|
||||
// Created by Brent Simmons on 11/13/17.
|
||||
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
import WebKit
|
||||
|
||||
public final class WebViewWindowController: NSWindowController {
|
||||
|
||||
@IBOutlet private var webview: WKWebView!
|
||||
private var title: String!
|
||||
|
||||
public convenience init(title: String) {
|
||||
self.init(window: nil)
|
||||
self.title = title
|
||||
Bundle.module.loadNibNamed("WebViewWindow", owner: self, topLevelObjects: nil)
|
||||
}
|
||||
|
||||
public override func windowDidLoad() {
|
||||
|
||||
window!.title = title
|
||||
}
|
||||
|
||||
public func displayContents(of path: String) {
|
||||
|
||||
// We assume there might be images, style sheets, etc. contained by the folder that the file appears in, so we get read access to the parent folder.
|
||||
|
||||
let _ = self.window
|
||||
|
||||
let fileURL = URL(fileURLWithPath: path)
|
||||
let folderURL = fileURL.deletingLastPathComponent()
|
||||
|
||||
webview.loadFileURL(fileURL, allowingReadAccessTo: folderURL)
|
||||
}
|
||||
}
|
||||
#endif
|
3
RSCore/Sources/RSCoreResources/RSCoreResources.swift
Normal file
3
RSCore/Sources/RSCoreResources/RSCoreResources.swift
Normal file
@ -0,0 +1,3 @@
|
||||
struct RSCoreResources {
|
||||
var text = "Hello, World!"
|
||||
}
|
@ -0,0 +1,59 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="11198.2" systemVersion="15G1004" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
|
||||
<dependencies>
|
||||
<deployment identifier="macosx"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="11198.2"/>
|
||||
</dependencies>
|
||||
<objects>
|
||||
<customObject id="-2" userLabel="File's Owner" customClass="IndeterminateProgressWindowController" customModule="Rainier" customModuleProvider="target">
|
||||
<connections>
|
||||
<outlet property="messageLabel" destination="qcz-WI-q10" id="IJ9-M8-kzt"/>
|
||||
<outlet property="progressIndicator" destination="MFX-Q2-XtZ" id="jpc-TD-TWd"/>
|
||||
<outlet property="window" destination="NbJ-SP-fgw" id="e4d-eP-QkY"/>
|
||||
</connections>
|
||||
</customObject>
|
||||
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
|
||||
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
|
||||
<window allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" restorable="NO" hidesOnDeactivate="YES" oneShot="NO" releasedWhenClosed="NO" showsToolbarButton="NO" visibleAtLaunch="NO" frameAutosaveName="" animationBehavior="default" id="NbJ-SP-fgw" customClass="NSPanel">
|
||||
<windowStyleMask key="styleMask" titled="YES" utility="YES" HUD="YES"/>
|
||||
<windowPositionMask key="initialPositionMask" leftStrut="YES" rightStrut="YES" topStrut="YES" bottomStrut="YES"/>
|
||||
<rect key="contentRect" x="272" y="172" width="267" height="85"/>
|
||||
<rect key="screenRect" x="0.0" y="0.0" width="1440" height="877"/>
|
||||
<view key="contentView" id="gai-Qn-u7b">
|
||||
<rect key="frame" x="0.0" y="0.0" width="267" height="85"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<subviews>
|
||||
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="qcz-WI-q10">
|
||||
<rect key="frame" x="90" y="46" width="87" height="19"/>
|
||||
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" sendsActionOnEndEditing="YES" alignment="center" title="Message…" id="84N-t0-TEo">
|
||||
<font key="font" metaFont="systemBold" size="15"/>
|
||||
<color key="textColor" white="0.96999999999999997" alpha="1" colorSpace="calibratedWhite"/>
|
||||
<color key="backgroundColor" name="controlColor" catalog="System" colorSpace="catalog"/>
|
||||
</textFieldCell>
|
||||
<connections>
|
||||
<binding destination="-2" name="value" keyPath="message" id="EL8-wI-e9T">
|
||||
<dictionary key="options">
|
||||
<bool key="NSAllowsEditingMultipleValuesSelection" value="NO"/>
|
||||
<bool key="NSRaisesForNotApplicableKeys" value="NO"/>
|
||||
</dictionary>
|
||||
</binding>
|
||||
</connections>
|
||||
</textField>
|
||||
<progressIndicator wantsLayer="YES" horizontalHuggingPriority="750" verticalHuggingPriority="750" maxValue="100" bezeled="NO" indeterminate="YES" style="bar" translatesAutoresizingMaskIntoConstraints="NO" id="MFX-Q2-XtZ">
|
||||
<rect key="frame" x="20" y="19" width="227" height="20"/>
|
||||
</progressIndicator>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstItem="qcz-WI-q10" firstAttribute="top" secondItem="gai-Qn-u7b" secondAttribute="top" constant="20" symbolic="YES" id="6hM-39-Lu6"/>
|
||||
<constraint firstItem="qcz-WI-q10" firstAttribute="centerX" secondItem="gai-Qn-u7b" secondAttribute="centerX" id="B2C-49-Glg"/>
|
||||
<constraint firstItem="MFX-Q2-XtZ" firstAttribute="centerX" secondItem="gai-Qn-u7b" secondAttribute="centerX" id="Byx-UU-xWP"/>
|
||||
<constraint firstItem="MFX-Q2-XtZ" firstAttribute="leading" secondItem="gai-Qn-u7b" secondAttribute="leading" constant="20" symbolic="YES" id="J05-i6-91Y"/>
|
||||
<constraint firstAttribute="bottom" secondItem="MFX-Q2-XtZ" secondAttribute="bottom" constant="20" symbolic="YES" id="ZRz-VV-P8J"/>
|
||||
<constraint firstAttribute="trailing" secondItem="MFX-Q2-XtZ" secondAttribute="trailing" constant="20" symbolic="YES" id="k2g-Jl-o6g"/>
|
||||
<constraint firstItem="MFX-Q2-XtZ" firstAttribute="top" secondItem="qcz-WI-q10" secondAttribute="bottom" constant="8" symbolic="YES" id="xxr-Ok-3Bb"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<point key="canvasLocation" x="-71.5" y="-167.5"/>
|
||||
</window>
|
||||
</objects>
|
||||
</document>
|
45
RSCore/Sources/RSCoreResources/Resources/WebViewWindow.xib
Normal file
45
RSCore/Sources/RSCoreResources/Resources/WebViewWindow.xib
Normal file
@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="13526" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="13526"/>
|
||||
<plugIn identifier="com.apple.WebKit2IBPlugin" version="13526"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<objects>
|
||||
<customObject id="-2" userLabel="File's Owner" customClass="WebViewWindowController" customModule="RSCore" customModuleProvider="target">
|
||||
<connections>
|
||||
<outlet property="webview" destination="E6W-Ua-YwP" id="dYj-pg-M6n"/>
|
||||
<outlet property="window" destination="QvC-M9-y7g" id="EnP-ca-jeo"/>
|
||||
</connections>
|
||||
</customObject>
|
||||
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
|
||||
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
|
||||
<window title="Window" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" oneShot="NO" releasedWhenClosed="NO" animationBehavior="default" id="QvC-M9-y7g">
|
||||
<windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
|
||||
<windowPositionMask key="initialPositionMask" leftStrut="YES" rightStrut="YES" topStrut="YES" bottomStrut="YES"/>
|
||||
<rect key="contentRect" x="206" y="471" width="480" height="270"/>
|
||||
<rect key="screenRect" x="0.0" y="0.0" width="1440" height="877"/>
|
||||
<value key="minSize" type="size" width="480" height="270"/>
|
||||
<value key="maxSize" type="size" width="2048" height="2048"/>
|
||||
<view key="contentView" wantsLayer="YES" id="EiT-Mj-1SZ">
|
||||
<rect key="frame" x="0.0" y="0.0" width="480" height="270"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<subviews>
|
||||
<wkWebView wantsLayer="YES" translatesAutoresizingMaskIntoConstraints="NO" id="E6W-Ua-YwP">
|
||||
<rect key="frame" x="0.0" y="0.0" width="480" height="270"/>
|
||||
<wkWebViewConfiguration key="configuration">
|
||||
<audiovisualMediaTypes key="mediaTypesRequiringUserActionForPlayback" none="YES"/>
|
||||
<wkPreferences key="preferences"/>
|
||||
</wkWebViewConfiguration>
|
||||
</wkWebView>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstItem="E6W-Ua-YwP" firstAttribute="top" secondItem="EiT-Mj-1SZ" secondAttribute="top" id="hlG-XK-mDH"/>
|
||||
<constraint firstAttribute="bottom" secondItem="E6W-Ua-YwP" secondAttribute="bottom" id="q8o-nj-5qj"/>
|
||||
<constraint firstAttribute="trailing" secondItem="E6W-Ua-YwP" secondAttribute="trailing" id="s2P-HZ-c08"/>
|
||||
<constraint firstItem="E6W-Ua-YwP" firstAttribute="leading" secondItem="EiT-Mj-1SZ" secondAttribute="leading" id="tcr-Wl-0J1"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</window>
|
||||
</objects>
|
||||
</document>
|
7
RSCore/Tests/LinuxMain.swift
Normal file
7
RSCore/Tests/LinuxMain.swift
Normal file
@ -0,0 +1,7 @@
|
||||
import XCTest
|
||||
|
||||
import RSCoreTests
|
||||
|
||||
var tests = [XCTestCaseEntry]()
|
||||
tests += RSCoreTests.allTests()
|
||||
XCTMain(tests)
|
189
RSCore/Tests/RSCoreTests/Data+RSCoreTests.swift
Normal file
189
RSCore/Tests/RSCoreTests/Data+RSCoreTests.swift
Normal file
@ -0,0 +1,189 @@
|
||||
//
|
||||
// Data+RSCoreTests.swift
|
||||
// RSCoreTests
|
||||
//
|
||||
// Created by Nate Weaver on 2020-01-12.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
import Foundation
|
||||
@testable import RSCore
|
||||
|
||||
//class Data_RSCoreTests: XCTestCase {
|
||||
// var bigHTML: String!
|
||||
//
|
||||
// var pngData: Data!
|
||||
// var jpegData: Data!
|
||||
// var gifData: Data!
|
||||
//
|
||||
// lazy var bundle = Bundle(for: type(of: self))
|
||||
//
|
||||
// override func setUp() {
|
||||
// let htmlFile = bundle.url(forResource: "test", withExtension: "html")!
|
||||
// bigHTML = try? String(contentsOf: htmlFile)
|
||||
//
|
||||
// let pngURL = bundle.url(forResource: "icon", withExtension: "png")!
|
||||
// pngData = try! Data(contentsOf: pngURL)
|
||||
//
|
||||
// let jpegURL = bundle.url(forResource: "icon", withExtension: "jpg")!
|
||||
// jpegData = try! Data(contentsOf: jpegURL)
|
||||
//
|
||||
// let gifURL = bundle.url(forResource: "icon", withExtension: "gif")!
|
||||
// gifData = try! Data(contentsOf: gifURL)
|
||||
// }
|
||||
//
|
||||
// func testIsProbablyHTMLEncodings() {
|
||||
//
|
||||
// let utf8 = bigHTML.data(using: .utf8)!
|
||||
// XCTAssertTrue(utf8.isProbablyHTML)
|
||||
//
|
||||
// let utf16 = bigHTML.data(using: .utf16)!
|
||||
// XCTAssertTrue(utf16.isProbablyHTML)
|
||||
//
|
||||
// let utf16Little = bigHTML.data(using: .utf16LittleEndian)!
|
||||
// XCTAssertTrue(utf16Little.isProbablyHTML)
|
||||
//
|
||||
// let utf16Big = bigHTML.data(using: .utf16BigEndian)!
|
||||
// XCTAssertTrue(utf16Big.isProbablyHTML)
|
||||
//
|
||||
// let shiftJIS = bigHTML.data(using: .shiftJIS)!
|
||||
// XCTAssertTrue(shiftJIS.isProbablyHTML)
|
||||
//
|
||||
// let japaneseEUC = bigHTML.data(using: .japaneseEUC)!
|
||||
// XCTAssertTrue(japaneseEUC.isProbablyHTML)
|
||||
//
|
||||
// }
|
||||
//
|
||||
// func testIsProbablyHTMLTags() {
|
||||
//
|
||||
// let noLT = "html body".data(using: .utf8)!
|
||||
// XCTAssertFalse(noLT.isProbablyHTML)
|
||||
//
|
||||
// let noBody = "<html><head></head></html>".data(using: .utf8)!
|
||||
// XCTAssertFalse(noBody.isProbablyHTML)
|
||||
//
|
||||
// let noHead = "<body>foo</body>".data(using: .utf8)!
|
||||
// XCTAssertFalse(noHead.isProbablyHTML)
|
||||
//
|
||||
// let lowerHTMLLowerBODY = "<html><body></body></html>".data(using: .utf8)!
|
||||
// XCTAssertTrue(lowerHTMLLowerBODY.isProbablyHTML)
|
||||
//
|
||||
// let upperHTMLUpperBODY = "<HTML><BODY></BODY></HTML>".data(using: .utf8)!
|
||||
// XCTAssertTrue(upperHTMLUpperBODY.isProbablyHTML)
|
||||
//
|
||||
// let lowerHTMLUpperBODY = "<html><BODY></BODY></html>".data(using: .utf8)!
|
||||
// XCTAssertTrue(lowerHTMLUpperBODY.isProbablyHTML)
|
||||
//
|
||||
// let upperHTMLLowerBODY = "<HTML><body></body></HTML>".data(using: .utf8)!
|
||||
// XCTAssertTrue(upperHTMLLowerBODY.isProbablyHTML)
|
||||
//
|
||||
// }
|
||||
//
|
||||
// func testIsProbablyHTMLPerformance() {
|
||||
// let utf8 = bigHTML.data(using: .utf8)!
|
||||
//
|
||||
// self.measure {
|
||||
// for _ in 0 ..< 10000 {
|
||||
// let _ = utf8.isProbablyHTML
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func testIsImage() {
|
||||
// XCTAssertTrue(pngData.isPNG)
|
||||
// XCTAssertTrue(jpegData.isJPEG)
|
||||
// XCTAssertTrue(gifData.isGIF)
|
||||
//
|
||||
// XCTAssertTrue(pngData.isImage)
|
||||
// XCTAssertTrue(jpegData.isImage)
|
||||
// XCTAssertTrue(gifData.isImage)
|
||||
// }
|
||||
//
|
||||
// // Shouldn't crash.
|
||||
// func testDataIsTooSmall() {
|
||||
// let data = Data(count: 2)
|
||||
// XCTAssertFalse(data.isJPEG)
|
||||
// }
|
||||
//
|
||||
// func testMD5() {
|
||||
// let foobarData = "foobar".data(using: .utf8)!
|
||||
// XCTAssertEqual(foobarData.md5String, "3858f62230ac3c915f300c664312c63f")
|
||||
//
|
||||
// let emptyData = Data()
|
||||
// XCTAssertEqual(emptyData.md5String, "d41d8cd98f00b204e9800998ecf8427e")
|
||||
// }
|
||||
//
|
||||
// func testHexadecimalString() {
|
||||
//
|
||||
// let data = Data([1, 2, 3, 4])
|
||||
// XCTAssertEqual(data.hexadecimalString!, "01020304")
|
||||
//
|
||||
// var deadbeef = UInt32(bigEndian: 0xDEADBEEF)
|
||||
// let data2 = Data(bytes: &deadbeef, count: MemoryLayout.size(ofValue: deadbeef))
|
||||
// XCTAssertEqual(data2.hexadecimalString!, "deadbeef")
|
||||
//
|
||||
// }
|
||||
//
|
||||
//}
|
||||
|
||||
// Tests to compare the result of Data+RSCore with the old Objective-C versions.
|
||||
//extension Data_RSCoreTests {
|
||||
//
|
||||
// func testCompare_isProbablyHTML() {
|
||||
// let noLT = "html body".data(using: .utf8)!
|
||||
// XCTAssertEqual(noLT.isProbablyHTML, (noLT as NSData).rs_dataIsProbablyHTML())
|
||||
//
|
||||
// let noBody = "<html><head></head></html>".data(using: .utf8)!
|
||||
// XCTAssertEqual(noBody.isProbablyHTML, (noBody as NSData).rs_dataIsProbablyHTML())
|
||||
//
|
||||
// let noHead = "<body>foo</body>".data(using: .utf8)!
|
||||
// XCTAssertEqual(noHead.isProbablyHTML, (noHead as NSData).rs_dataIsProbablyHTML())
|
||||
//
|
||||
// let lowerHTMLLowerBODY = "<html><body></body></html>".data(using: .utf8)!
|
||||
// XCTAssertEqual(lowerHTMLLowerBODY.isProbablyHTML, (lowerHTMLLowerBODY as NSData).rs_dataIsProbablyHTML())
|
||||
//
|
||||
// let upperHTMLUpperBODY = "<HTML><BODY></BODY></HTML>".data(using: .utf8)!
|
||||
// XCTAssertEqual(upperHTMLUpperBODY.isProbablyHTML, (upperHTMLUpperBODY as NSData).rs_dataIsProbablyHTML())
|
||||
//
|
||||
// let lowerHTMLUpperBODY = "<html><BODY></BODY></html>".data(using: .utf8)!
|
||||
// XCTAssertEqual(lowerHTMLUpperBODY.isProbablyHTML, (lowerHTMLUpperBODY as NSData).rs_dataIsProbablyHTML())
|
||||
//
|
||||
// let upperHTMLLowerBODY = "<HTML><body></body></HTML>".data(using: .utf8)!
|
||||
// XCTAssertEqual(upperHTMLLowerBODY.isProbablyHTML, (upperHTMLLowerBODY as NSData).rs_dataIsProbablyHTML())
|
||||
//
|
||||
// }
|
||||
//
|
||||
// func testCompare_isImage() {
|
||||
// XCTAssertEqual(pngData.isPNG, (pngData as NSData).rs_dataIsPNG())
|
||||
// XCTAssertEqual(jpegData.isJPEG, (jpegData as NSData).rs_dataIsJPEG())
|
||||
// XCTAssertEqual(gifData.isGIF, (gifData as NSData).rs_dataIsGIF())
|
||||
//
|
||||
// XCTAssertEqual(pngData.isImage, (pngData as NSData).rs_dataIsImage())
|
||||
// XCTAssertEqual(jpegData.isImage, (jpegData as NSData).rs_dataIsImage())
|
||||
// XCTAssertEqual(gifData.isImage, (gifData as NSData).rs_dataIsImage())
|
||||
// }
|
||||
//
|
||||
// func testCompare_MD5() {
|
||||
// let foobarData = "foobar".data(using: .utf8)!
|
||||
// let emptyData = Data()
|
||||
//
|
||||
// XCTAssertEqual(foobarData.md5Hash, (foobarData as NSData).rs_md5Hash())
|
||||
// XCTAssertEqual(emptyData.md5Hash, (emptyData as NSData).rs_md5Hash())
|
||||
//
|
||||
// XCTAssertEqual(foobarData.md5String, (foobarData as NSData).rs_md5HashString())
|
||||
// XCTAssertEqual(emptyData.md5String, (emptyData as NSData).rs_md5HashString())
|
||||
// }
|
||||
//
|
||||
// func testCompare_hexadecimalString() {
|
||||
//
|
||||
// let data = Data([1, 2, 3, 4])
|
||||
// XCTAssertEqual(data.hexadecimalString!, (data as NSData).rs_hexadecimalString())
|
||||
//
|
||||
// var deadbeef = UInt32(bigEndian: 0xDEADBEEF)
|
||||
// let data2 = Data(bytes: &deadbeef, count: MemoryLayout.size(ofValue: deadbeef))
|
||||
// XCTAssertEqual(data2.hexadecimalString!, (data2 as NSData).rs_hexadecimalString())
|
||||
//
|
||||
// }
|
||||
//
|
||||
//}
|
71
RSCore/Tests/RSCoreTests/MacroProcessorTests.swift
Normal file
71
RSCore/Tests/RSCoreTests/MacroProcessorTests.swift
Normal file
@ -0,0 +1,71 @@
|
||||
//
|
||||
// MacroProcessorTests.swift
|
||||
// RSCoreTests
|
||||
//
|
||||
// Created by Nate Weaver on 2020-01-01.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import RSCore
|
||||
|
||||
class MacroProcessorTests: XCTestCase {
|
||||
let substitutions = ["one": "1", "two": "2"]
|
||||
|
||||
func testMacroProcessor() {
|
||||
var template = "foo [[one]] bar [[two]] baz"
|
||||
var expected = "foo 1 bar 2 baz"
|
||||
var result = try! MacroProcessor.renderedText(withTemplate: template, substitutions: substitutions)
|
||||
XCTAssertEqual(result, expected)
|
||||
|
||||
template = "[[one]] foo [[two]] bar"
|
||||
expected = "1 foo 2 bar"
|
||||
result = try! MacroProcessor.renderedText(withTemplate: template, substitutions: substitutions)
|
||||
XCTAssertEqual(result, expected)
|
||||
|
||||
template = "foo [[one]] bar [[two]]"
|
||||
expected = "foo 1 bar 2"
|
||||
result = try! MacroProcessor.renderedText(withTemplate: template, substitutions: substitutions)
|
||||
XCTAssertEqual(result, expected)
|
||||
|
||||
// Nonexistant key
|
||||
template = "foo [[nonexistant]] bar"
|
||||
expected = template
|
||||
result = try! MacroProcessor.renderedText(withTemplate: template, substitutions: substitutions)
|
||||
XCTAssertEqual(result, expected)
|
||||
|
||||
// Equal delimiters
|
||||
template = "foo |one| bar |two| baz"
|
||||
expected = "foo 1 bar 2 baz"
|
||||
result = try! MacroProcessor.renderedText(withTemplate: template, substitutions: substitutions, macroStart: "|", macroEnd: "|")
|
||||
XCTAssertEqual(result, expected)
|
||||
}
|
||||
|
||||
func testEmptyDelimiters() {
|
||||
do {
|
||||
let template = "foo bar"
|
||||
let _ = try MacroProcessor.renderedText(withTemplate: template, substitutions: substitutions, macroStart: "")
|
||||
XCTFail("Error should be thrown")
|
||||
} catch {
|
||||
// Success
|
||||
}
|
||||
|
||||
do {
|
||||
let template = "foo bar"
|
||||
let _ = try MacroProcessor.renderedText(withTemplate: template, substitutions: substitutions, macroEnd: "")
|
||||
XCTFail("Error should be thrown")
|
||||
} catch {
|
||||
// Success
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Macro replacement shouldn't be recursive
|
||||
func testMacroInSubstitutions() {
|
||||
let substitutions = ["one": "[[two]]", "two": "2"]
|
||||
let template = "foo [[one]] bar"
|
||||
let expected = "foo [[two]] bar"
|
||||
let result = try! MacroProcessor.renderedText(withTemplate: template, substitutions: substitutions)
|
||||
XCTAssertEqual(result, expected)
|
||||
}
|
||||
}
|
323
RSCore/Tests/RSCoreTests/MainThreadOperationTests.swift
Normal file
323
RSCore/Tests/RSCoreTests/MainThreadOperationTests.swift
Normal file
@ -0,0 +1,323 @@
|
||||
//
|
||||
// MainThreadOperationTests.swift
|
||||
// RSCoreTests
|
||||
//
|
||||
// Created by Brent Simmons on 1/17/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import RSCore
|
||||
|
||||
class MainThreadOperationTests: XCTestCase {
|
||||
|
||||
func testSingleOperation() {
|
||||
let queue = MainThreadOperationQueue()
|
||||
var operationDidRun = false
|
||||
let singleOperationDidRunExpectation = expectation(description: "singleOperationDidRun")
|
||||
let operation = MainThreadBlockOperation {
|
||||
operationDidRun = true
|
||||
XCTAssertTrue(operationDidRun)
|
||||
singleOperationDidRunExpectation.fulfill()
|
||||
}
|
||||
queue.add(operation)
|
||||
|
||||
waitForExpectations(timeout: 1.0, handler: nil)
|
||||
XCTAssertTrue(queue.pendingOperationsCount == 0)
|
||||
}
|
||||
|
||||
func testOperationAndDependency() {
|
||||
let queue = MainThreadOperationQueue()
|
||||
var operationIndex = 0
|
||||
|
||||
let parentOperationExpectation = expectation(description: "parentOperation")
|
||||
let parentOperation = MainThreadBlockOperation {
|
||||
XCTAssertTrue(operationIndex == 0)
|
||||
operationIndex += 1
|
||||
parentOperationExpectation.fulfill()
|
||||
}
|
||||
|
||||
let childOperationExpectation = expectation(description: "childOperation")
|
||||
let childOperation = MainThreadBlockOperation {
|
||||
XCTAssertTrue(operationIndex == 1)
|
||||
operationIndex += 1
|
||||
childOperationExpectation.fulfill()
|
||||
}
|
||||
|
||||
queue.make(childOperation, dependOn: parentOperation)
|
||||
queue.add(parentOperation)
|
||||
queue.add(childOperation)
|
||||
|
||||
waitForExpectations(timeout: 1.0, handler: nil)
|
||||
XCTAssertTrue(queue.pendingOperationsCount == 0)
|
||||
}
|
||||
|
||||
func testOperationAndDependencyAddedOutOfOrder() {
|
||||
let queue = MainThreadOperationQueue()
|
||||
var operationIndex = 0
|
||||
|
||||
let parentOperationExpectation = expectation(description: "parentOperation")
|
||||
let parentOperation = MainThreadBlockOperation {
|
||||
XCTAssertTrue(operationIndex == 0)
|
||||
operationIndex += 1
|
||||
parentOperationExpectation.fulfill()
|
||||
}
|
||||
|
||||
let childOperationExpectation = expectation(description: "childOperation")
|
||||
let childOperation = MainThreadBlockOperation {
|
||||
XCTAssertTrue(operationIndex == 1)
|
||||
operationIndex += 1
|
||||
childOperationExpectation.fulfill()
|
||||
}
|
||||
|
||||
queue.make(childOperation, dependOn: parentOperation)
|
||||
queue.add(childOperation)
|
||||
queue.add(parentOperation)
|
||||
|
||||
waitForExpectations(timeout: 1.0, handler: nil)
|
||||
XCTAssertTrue(queue.pendingOperationsCount == 0)
|
||||
}
|
||||
|
||||
func testOperationAndTwoDependenciesAddedOutOfOrder() {
|
||||
let queue = MainThreadOperationQueue()
|
||||
var operationIndex = 0
|
||||
|
||||
let parentOperationExpectation = expectation(description: "parentOperation")
|
||||
let parentOperation = MainThreadBlockOperation {
|
||||
XCTAssertTrue(operationIndex == 0)
|
||||
operationIndex += 1
|
||||
parentOperationExpectation.fulfill()
|
||||
}
|
||||
|
||||
let childOperationExpectation = expectation(description: "childOperation")
|
||||
let childOperation = MainThreadBlockOperation {
|
||||
XCTAssertTrue(operationIndex == 1)
|
||||
operationIndex += 1
|
||||
childOperationExpectation.fulfill()
|
||||
}
|
||||
|
||||
let childOperationExpectation2 = expectation(description: "childOperation2")
|
||||
let childOperation2 = MainThreadBlockOperation {
|
||||
XCTAssertTrue(operationIndex == 2)
|
||||
operationIndex += 1
|
||||
childOperationExpectation2.fulfill()
|
||||
}
|
||||
|
||||
queue.make(childOperation, dependOn: parentOperation)
|
||||
queue.make(childOperation2, dependOn: parentOperation)
|
||||
queue.add(childOperation)
|
||||
queue.add(childOperation2)
|
||||
queue.add(parentOperation)
|
||||
|
||||
waitForExpectations(timeout: 1.0, handler: nil)
|
||||
XCTAssertTrue(queue.pendingOperationsCount == 0)
|
||||
}
|
||||
|
||||
func testChildOperationWithTwoDependencies() {
|
||||
let queue = MainThreadOperationQueue()
|
||||
var operationIndex = 0
|
||||
|
||||
let parentOperationExpectation = expectation(description: "parentOperation")
|
||||
let parentOperation = MainThreadBlockOperation {
|
||||
XCTAssertTrue(operationIndex == 0)
|
||||
operationIndex += 1
|
||||
parentOperationExpectation.fulfill()
|
||||
}
|
||||
|
||||
let parentOperationExpectation2 = expectation(description: "parentOperation2")
|
||||
let parentOperation2 = MainThreadBlockOperation {
|
||||
XCTAssertTrue(operationIndex == 1)
|
||||
operationIndex += 1
|
||||
parentOperationExpectation2.fulfill()
|
||||
}
|
||||
|
||||
let childOperationExpectation = expectation(description: "childOperation")
|
||||
let childOperation = MainThreadBlockOperation {
|
||||
XCTAssertTrue(operationIndex == 2)
|
||||
operationIndex += 1
|
||||
childOperationExpectation.fulfill()
|
||||
}
|
||||
|
||||
queue.make(childOperation, dependOn: parentOperation)
|
||||
queue.make(childOperation, dependOn: parentOperation2)
|
||||
queue.add(childOperation)
|
||||
queue.add(parentOperation)
|
||||
queue.add(parentOperation2)
|
||||
|
||||
waitForExpectations(timeout: 1.0, handler: nil)
|
||||
XCTAssertTrue(queue.pendingOperationsCount == 0)
|
||||
}
|
||||
|
||||
func testAddingManyOperations() {
|
||||
let queue = MainThreadOperationQueue()
|
||||
let operationsCount = 1000
|
||||
var operationIndex = 0
|
||||
var operations = [MainThreadBlockOperation]()
|
||||
|
||||
for i in 0..<operationsCount {
|
||||
let operationExpectation = expectation(description: "Operation \(i)")
|
||||
let operation = MainThreadBlockOperation {
|
||||
XCTAssertTrue(operationIndex == i)
|
||||
operationIndex += 1
|
||||
operationExpectation.fulfill()
|
||||
}
|
||||
operations.append(operation)
|
||||
}
|
||||
|
||||
queue.addOperations(operations)
|
||||
waitForExpectations(timeout: 1.0, handler: nil)
|
||||
XCTAssertTrue(queue.pendingOperationsCount == 0)
|
||||
}
|
||||
|
||||
func testAddingManyOperationsAndCancelingManyOperations() {
|
||||
let queue = MainThreadOperationQueue()
|
||||
let operationsCount = 1000
|
||||
var operations = [MainThreadBlockOperation]()
|
||||
|
||||
for _ in 0..<operationsCount {
|
||||
let operation = MainThreadBlockOperation {
|
||||
XCTAssertTrue(false)
|
||||
}
|
||||
operations.append(operation)
|
||||
}
|
||||
|
||||
queue.addOperations(operations)
|
||||
queue.cancelOperations(operations)
|
||||
XCTAssertTrue(queue.pendingOperationsCount == 0)
|
||||
}
|
||||
|
||||
func testAddingManyOperationsWithCompletionBlocks() {
|
||||
let queue = MainThreadOperationQueue()
|
||||
let operationsCount = 100
|
||||
var operationIndex = 0
|
||||
var operations = [MainThreadBlockOperation]()
|
||||
|
||||
for i in 0..<operationsCount {
|
||||
let operationExpectation = expectation(description: "Operation \(i)")
|
||||
let operationCompletionBlockExpectation = expectation(description: "Operation Completion \(i)")
|
||||
let operation = MainThreadBlockOperation {
|
||||
XCTAssertTrue(operationIndex == i)
|
||||
operationExpectation.fulfill()
|
||||
}
|
||||
operation.completionBlock = { completedOperation in
|
||||
XCTAssert(operation === completedOperation)
|
||||
XCTAssertTrue(operationIndex == i)
|
||||
operationIndex += 1
|
||||
operationCompletionBlockExpectation.fulfill()
|
||||
}
|
||||
operations.append(operation)
|
||||
}
|
||||
|
||||
queue.addOperations(operations)
|
||||
waitForExpectations(timeout: 1.0, handler: nil)
|
||||
XCTAssertTrue(queue.pendingOperationsCount == 0)
|
||||
}
|
||||
|
||||
func testCancelingDisownsOperation() {
|
||||
|
||||
final class SlowFinishingOperation: MainThreadOperation {
|
||||
|
||||
let didCancelExpectation: XCTestExpectation
|
||||
|
||||
// MainThreadOperation
|
||||
var isCanceled = false {
|
||||
didSet {
|
||||
if isCanceled {
|
||||
didCancelExpectation.fulfill()
|
||||
}
|
||||
}
|
||||
}
|
||||
var id: Int?
|
||||
var operationDelegate: MainThreadOperationDelegate?
|
||||
var name: String?
|
||||
var completionBlock: MainThreadOperation.MainThreadOperationCompletionBlock?
|
||||
|
||||
var didStartRunBlock: (() -> ())?
|
||||
|
||||
init(didCancelExpectation: XCTestExpectation) {
|
||||
self.didCancelExpectation = didCancelExpectation
|
||||
}
|
||||
|
||||
func run() {
|
||||
guard let block = didStartRunBlock else {
|
||||
XCTFail("Unable to test cancelation of running operation.")
|
||||
return
|
||||
}
|
||||
block()
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
if let self = self {
|
||||
XCTAssert(false, "This code should not be executed.")
|
||||
self.operationDelegate?.operationDidComplete(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let queue = MainThreadOperationQueue()
|
||||
let didCancelExpectation = expectation(description: "Did Cancel Operation")
|
||||
let completionBlockDidRunExpectation = expectation(description: "Completion Block Did Run")
|
||||
|
||||
// Using an Optional allows us to control this scope's ownership of the operation.
|
||||
var operation: SlowFinishingOperation? = {
|
||||
let operation = SlowFinishingOperation(didCancelExpectation: didCancelExpectation)
|
||||
operation.didStartRunBlock = { [weak operation] in
|
||||
guard let operation = operation else {
|
||||
XCTFail("Could not cancel slow finishing operation because it seems to be prematurely disowned.")
|
||||
return
|
||||
}
|
||||
queue.cancelOperation(operation)
|
||||
}
|
||||
operation.completionBlock = { _ in
|
||||
XCTAssertTrue(Thread.isMainThread)
|
||||
completionBlockDidRunExpectation.fulfill()
|
||||
}
|
||||
return operation
|
||||
}()
|
||||
|
||||
// The queue should take ownership of the operation (asserted below).
|
||||
queue.add(operation!)
|
||||
|
||||
// Verify something other than this scope has ownership of the operation.
|
||||
weak var addedOperation = operation!
|
||||
operation = nil
|
||||
XCTAssertNil(operation)
|
||||
XCTAssertNotNil(addedOperation, "Perhaps the queue did not take ownership of the operation?")
|
||||
|
||||
let didDisownOperationExpectation = expectation(description: "Did Disown Operation")
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak addedOperation] in
|
||||
XCTAssertNil(addedOperation, "Perhaps the queue did not disown the operation?")
|
||||
didDisownOperationExpectation.fulfill()
|
||||
}
|
||||
|
||||
// Wait for the operation to start running, cancel and complete.
|
||||
waitForExpectations(timeout: 1)
|
||||
}
|
||||
|
||||
func testCancellingOperationsWithName() {
|
||||
let queue = MainThreadOperationQueue()
|
||||
queue.suspend()
|
||||
|
||||
let operationsCount = 100
|
||||
for i in 0..<operationsCount {
|
||||
let operation = MainThreadBlockOperation {
|
||||
}
|
||||
operation.name = "\(i)"
|
||||
queue.add(operation)
|
||||
|
||||
let operation2 = MainThreadBlockOperation {
|
||||
}
|
||||
operation2.name = "foo"
|
||||
queue.add(operation2)
|
||||
}
|
||||
|
||||
queue.resume()
|
||||
queue.cancelOperations(named: "33")
|
||||
queue.cancelOperations(named: "99")
|
||||
queue.cancelOperations(named: "654")
|
||||
queue.cancelOperations(named: "foo")
|
||||
XCTAssert(queue.pendingOperationsCount == 98)
|
||||
|
||||
queue.cancelAllOperations()
|
||||
XCTAssert(queue.pendingOperationsCount == 0)
|
||||
}
|
||||
}
|
15
RSCore/Tests/RSCoreTests/RSCoreTests.swift
Normal file
15
RSCore/Tests/RSCoreTests/RSCoreTests.swift
Normal file
@ -0,0 +1,15 @@
|
||||
import XCTest
|
||||
@testable import RSCore
|
||||
|
||||
final class RSCoreTests: XCTestCase {
|
||||
func testExample() {
|
||||
// This is an example of a functional test case.
|
||||
// Use XCTAssert and related functions to verify your tests produce the correct
|
||||
// results.
|
||||
XCTAssertEqual(RSCore().text, "Hello, World!")
|
||||
}
|
||||
|
||||
static var allTests = [
|
||||
("testExample", testExample),
|
||||
]
|
||||
}
|
185
RSCore/Tests/RSCoreTests/String+RSCoreTests.swift
Normal file
185
RSCore/Tests/RSCoreTests/String+RSCoreTests.swift
Normal file
@ -0,0 +1,185 @@
|
||||
//
|
||||
// String+RSCore.swift
|
||||
// RSCoreTests
|
||||
//
|
||||
// Created by Nate Weaver on 2020-01-14.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
class String_RSCore: XCTestCase {
|
||||
|
||||
func testCollapsingWhitespace() {
|
||||
|
||||
let str = " lots\t\tof random\n\nwhitespace\r\n"
|
||||
let expected = "lots of random whitespace"
|
||||
XCTAssertEqual(str.collapsingWhitespace, expected)
|
||||
|
||||
}
|
||||
|
||||
func testTrimmingWhitespace() {
|
||||
let str = " lots\t\tof random\n\nwhitespace\r\n"
|
||||
let expected = "lots\t\tof random\n\nwhitespace"
|
||||
XCTAssertEqual(str.trimmingWhitespace, expected)
|
||||
|
||||
// Ported old tests
|
||||
var s = "\tfoo\n\n\t\r\t"
|
||||
var result = s.trimmingWhitespace
|
||||
XCTAssertEqual(result, "foo")
|
||||
|
||||
s = "\t\n\n\t\r\t"
|
||||
result = s.trimmingWhitespace
|
||||
XCTAssertEqual(result, "")
|
||||
|
||||
s = "\t"
|
||||
result = s.trimmingWhitespace
|
||||
XCTAssertEqual(result, "")
|
||||
|
||||
s = ""
|
||||
result = s.trimmingWhitespace
|
||||
XCTAssertEqual(result, "")
|
||||
|
||||
s = "\nfoo\n"
|
||||
result = s.trimmingWhitespace
|
||||
XCTAssertEqual(result, "foo")
|
||||
|
||||
s = "\nfoo"
|
||||
result = s.trimmingWhitespace
|
||||
XCTAssertEqual(result, "foo")
|
||||
|
||||
s = "foo\n"
|
||||
result = s.trimmingWhitespace
|
||||
XCTAssertEqual(result, "foo")
|
||||
|
||||
s = "fo\n\n\n\n\n\no\n"
|
||||
result = s.trimmingWhitespace
|
||||
XCTAssertEqual(result, "fo\n\n\n\n\n\no")
|
||||
}
|
||||
|
||||
func testStrippingPrefix() {
|
||||
let str = "foobar"
|
||||
let expected = "bar"
|
||||
|
||||
XCTAssertEqual(str.stripping(prefix: "foo", caseSensitive: true), expected)
|
||||
XCTAssertEqual(str.stripping(prefix: "FOO"), expected)
|
||||
XCTAssertEqual(str.stripping(prefix: "FOO", caseSensitive: true), str)
|
||||
|
||||
let s2 = "foofoobar"
|
||||
let expected2 = "foobar"
|
||||
XCTAssertEqual(s2.stripping(prefix: "foo", caseSensitive: true), expected2)
|
||||
XCTAssertEqual(s2.stripping(prefix: "FOO"), expected2)
|
||||
XCTAssertEqual(s2.stripping(prefix: "FOO", caseSensitive: true), s2)
|
||||
|
||||
let s3 = "barfoo"
|
||||
let expected3 = "barfoo"
|
||||
XCTAssertEqual(s3.stripping(prefix: "foo", caseSensitive: true), expected3)
|
||||
XCTAssertEqual(s3.stripping(prefix: "FOO"), expected3)
|
||||
XCTAssertEqual(s3.stripping(prefix: "FOO", caseSensitive: true), expected3)
|
||||
}
|
||||
|
||||
func testStrippingSuffix() {
|
||||
let s = "foobar"
|
||||
let expected = "foo"
|
||||
XCTAssertEqual(s.stripping(suffix: "bar", caseSensitive: true), expected)
|
||||
XCTAssertEqual(s.stripping(suffix: "BAR"), expected)
|
||||
XCTAssertEqual(s.stripping(suffix: "BAR", caseSensitive: true), s)
|
||||
|
||||
let s2 = "foobarbar"
|
||||
let expected2 = "foobar"
|
||||
XCTAssertEqual(s2.stripping(suffix: "bar", caseSensitive: true), expected2)
|
||||
XCTAssertEqual(s2.stripping(suffix: "BAR"), expected2)
|
||||
XCTAssertEqual(s2.stripping(suffix: "BAR", caseSensitive: true), s2)
|
||||
|
||||
let s3 = "foobar"
|
||||
let expected3 = "foobar"
|
||||
XCTAssertEqual(s3.stripping(suffix: "foo", caseSensitive: true), expected3)
|
||||
XCTAssertEqual(s3.stripping(suffix: "FOO"), expected3)
|
||||
XCTAssertEqual(s3.stripping(suffix: "FOO", caseSensitive: true), expected3)
|
||||
}
|
||||
|
||||
func testEscapingSpecialXMLCharacters() {
|
||||
|
||||
let str = #"<foo attr="value">bar&baz</foo>"#
|
||||
let expected = "<foo attr="value">bar&baz</foo>"
|
||||
XCTAssertEqual(str.escapingSpecialXMLCharacters, expected)
|
||||
|
||||
}
|
||||
|
||||
func testStrippingHTTPOrHTTPSScheme() {
|
||||
|
||||
let http = "http://ranchero.com/"
|
||||
let expected = "ranchero.com/"
|
||||
XCTAssertEqual(http.strippingHTTPOrHTTPSScheme, expected)
|
||||
|
||||
let https = "https://ranchero.com/"
|
||||
XCTAssertEqual(https.strippingHTTPOrHTTPSScheme, expected)
|
||||
|
||||
let noreplacement = "example://ranchero.com/"
|
||||
XCTAssertEqual(noreplacement.strippingHTTPOrHTTPSScheme, noreplacement)
|
||||
|
||||
}
|
||||
|
||||
func testNormalizedURL() {
|
||||
|
||||
// feeds:
|
||||
let feeds = "feeds:daringfireball.net"
|
||||
XCTAssertEqual(feeds.normalizedURL, "https://daringfireball.net/")
|
||||
|
||||
let feedsWithHTTPS = "feeds:https://daringfireball.net/"
|
||||
XCTAssertEqual(feedsWithHTTPS.normalizedURL, "https://daringfireball.net/")
|
||||
|
||||
let feedsWithHTTPSAndSlashes = "feeds://https://daringfireball.net/"
|
||||
XCTAssertEqual(feedsWithHTTPSAndSlashes.normalizedURL, "https://daringfireball.net/")
|
||||
|
||||
// feed:
|
||||
|
||||
let feed = "feed:daringfireball.net"
|
||||
XCTAssertEqual(feed.normalizedURL, "http://daringfireball.net/")
|
||||
|
||||
let feedWithHTTPS = "feed:https://daringfireball.net/"
|
||||
XCTAssertEqual(feedWithHTTPS.normalizedURL, "https://daringfireball.net/")
|
||||
|
||||
let feedWithHTTPSAndSlashes = "feed://https://daringfireball.net/"
|
||||
XCTAssertEqual(feedWithHTTPSAndSlashes.normalizedURL, "https://daringfireball.net/")
|
||||
|
||||
// bare
|
||||
let https = "https://daringfireball.net/"
|
||||
XCTAssertEqual(https.normalizedURL, "https://daringfireball.net/")
|
||||
|
||||
// bare
|
||||
let http = "http://daringfireball.net/"
|
||||
XCTAssertEqual(http.normalizedURL, "http://daringfireball.net/")
|
||||
|
||||
}
|
||||
|
||||
func testMD5StringPerformance() {
|
||||
|
||||
let s1 = "These are the times that try men’s souls."
|
||||
let s2 = "These are the times that men’s souls."
|
||||
let s3 = "These ar th time that try men’s souls."
|
||||
let s4 = "These are the times that try men’s."
|
||||
let s5 = "These are the that try men’s souls."
|
||||
let s6 = "These are times that try men’s souls."
|
||||
let s7 = "are the times that try men’s souls."
|
||||
let s8 = "These the times that try men’s souls."
|
||||
let s9 = "These are the times tht try men’s souls."
|
||||
let s10 = "These are the times that try men's souls."
|
||||
|
||||
self.measure {
|
||||
for _ in 0..<1000 {
|
||||
let _ = s1.md5String
|
||||
let _ = s2.md5String
|
||||
let _ = s3.md5String
|
||||
let _ = s4.md5String
|
||||
let _ = s5.md5String
|
||||
let _ = s6.md5String
|
||||
let _ = s7.md5String
|
||||
let _ = s8.md5String
|
||||
let _ = s9.md5String
|
||||
let _ = s10.md5String
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
9
RSCore/Tests/RSCoreTests/XCTestManifests.swift
Normal file
9
RSCore/Tests/RSCoreTests/XCTestManifests.swift
Normal file
@ -0,0 +1,9 @@
|
||||
import XCTest
|
||||
|
||||
#if !canImport(ObjectiveC)
|
||||
public func allTests() -> [XCTestCaseEntry] {
|
||||
return [
|
||||
testCase(RSCoreTests.allTests),
|
||||
]
|
||||
}
|
||||
#endif
|
Loading…
x
Reference in New Issue
Block a user