Fix macOS 14 deprecation warnings. Make RSCore a local module.

This commit is contained in:
Brent Simmons 2024-11-03 22:13:01 -08:00
parent 813500b55a
commit def4b95fbc
82 changed files with 6711 additions and 91 deletions

View File

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

View File

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

View File

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

View File

@ -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
View 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
View 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
View 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.
Theres 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.)

View File

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

View File

@ -0,0 +1,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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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)
}
}

View File

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

View File

@ -0,0 +1,3 @@
struct RSCore {
var text = "Hello, World!"
}

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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())
}
}

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

View File

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

View File

@ -0,0 +1,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) }
}
}

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

View File

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

View File

@ -0,0 +1,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)
}
}

View 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

View File

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

View File

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

View File

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

View 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 its 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 dont 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
}
}
}
}

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

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

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

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

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

View 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

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

View 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

View 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?)
}

View 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]
}
}

View 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/
// Were 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 youve seen no trailing slash
// on Macs too, but were bucking that trend. Were 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("&amp;")
case "<":
escaped.append("&lt;")
case ">":
escaped.append("&gt;")
case "\"":
escaped.append("&quot;")
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 } }
}

View 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]()
}
}

View 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

View 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

View 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

View 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

View 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

View 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

View 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 its deprecated, and we dont 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

View 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

View 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"

View File

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

View File

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

View File

@ -0,0 +1,3 @@
struct RSCoreResources {
var text = "Hello, World!"
}

View File

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

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

View File

@ -0,0 +1,7 @@
import XCTest
import RSCoreTests
var tests = [XCTestCaseEntry]()
tests += RSCoreTests.allTests()
XCTMain(tests)

View 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())
//
// }
//
//}

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

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

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

View 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 = "&lt;foo attr=&quot;value&quot;&gt;bar&amp;baz&lt;/foo&gt;"
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 mens souls."
let s2 = "These are the times that mens souls."
let s3 = "These ar th time that try mens souls."
let s4 = "These are the times that try mens."
let s5 = "These are the that try mens souls."
let s6 = "These are times that try mens souls."
let s7 = "are the times that try mens souls."
let s8 = "These the times that try mens souls."
let s9 = "These are the times tht try mens 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
}
}
}
}

View File

@ -0,0 +1,9 @@
import XCTest
#if !canImport(ObjectiveC)
public func allTests() -> [XCTestCaseEntry] {
return [
testCase(RSCoreTests.allTests),
]
}
#endif