From 2461e937bf3a95898c465c17e6802c807774224a Mon Sep 17 00:00:00 2001 From: Brent Simmons Date: Wed, 20 Mar 2024 20:49:15 -0700 Subject: [PATCH] Replace RSCore with several local modules. Update code as needed. --- Account/Package.swift | 10 +- Account/Sources/Account/Account.swift | 2 +- Account/Sources/Account/AccountManager.swift | 1 - .../Sources/Account/AccountMetadataFile.swift | 2 +- .../CloudKit/CloudKitAccountDelegate.swift | 3 +- .../CloudKit/CloudKitAccountZone.swift | 3 +- .../CloudKitAccountZoneDelegate.swift | 2 +- .../CloudKit/CloudKitArticlesZone.swift | 2 +- .../CloudKitArticlesZoneDelegate.swift | 2 +- .../CloudKitReceiveStatusOperation.swift | 2 +- .../CloudKitRemoteNotificationOperation.swift | 3 +- .../CloudKitSendStatusOperation.swift | 3 +- Account/Sources/Account/Container.swift | 1 - Account/Sources/Account/Feed.swift | 2 +- .../Account/FeedFinder/FeedFinder.swift | 1 - .../Sources/Account/FeedMetadataFile.swift | 2 +- .../Feedbin/FeedbinAccountDelegate.swift | 2 +- .../Account/Feedbin/FeedbinEntry.swift | 1 - .../Account/Feedbin/FeedbinSubscription.swift | 1 - .../Account/Feedly/FeedlyAPICaller.swift | 1 - .../Feedly/FeedlyAccountDelegate.swift | 2 +- .../OAuthAccountAuthorizationOperation.swift | 2 +- .../FeedlyAddExistingFeedOperation.swift | 2 +- .../FeedlyAddNewFeedOperation.swift | 2 +- ...teFeedsForCollectionFoldersOperation.swift | 1 + .../FeedlyDownloadArticlesOperation.swift | 2 +- .../Feedly/Operations/FeedlyOperation.swift | 2 +- .../Operations/FeedlySyncAllOperation.swift | 2 +- .../FeedlySyncStreamContentsOperation.swift | 2 +- Account/Sources/Account/Folder.swift | 2 +- .../LocalAccount/LocalAccountDelegate.swift | 2 +- .../LocalAccount/LocalAccountRefresher.swift | 2 +- .../NewsBlurAccountDelegate+Internal.swift | 2 +- .../NewsBlur/Models/NewsBlurFeed.swift | 1 - .../NewsBlur/Models/NewsBlurStory.swift | 1 - .../NewsBlur/Models/NewsBlurStoryHash.swift | 1 - .../NewsBlur/NewsBlurAccountDelegate.swift | 1 - Account/Sources/Account/OPMLFile.swift | 2 +- .../ReaderAPI/ReaderAPIAccountDelegate.swift | 2 +- .../Account/ReaderAPI/ReaderAPIEntry.swift | 1 - .../ReaderAPI/ReaderAPISubscription.swift | 1 - .../FeedlyCheckpointOperationTests.swift | 1 - ...dsForCollectionFoldersOperationTests.swift | 1 - .../FeedlyGetCollectionsOperationTests.swift | 1 - ...eedlyGetStreamContentsOperationTests.swift | 1 - .../FeedlyGetStreamIdsOperationTests.swift | 1 - .../Feedly/FeedlyLogoutOperationTests.swift | 1 - ...orCollectionsAsFoldersOperationTests.swift | 1 - .../Feedly/FeedlyOperationTests.swift | 1 - ...aniseParsedItemsByFeedOperationTests.swift | 1 - ...edlyRefreshAccessTokenOperationTests.swift | 1 - ...dlySendArticleStatusesOperationTests.swift | 1 - ...edlySyncStreamContentsOperationTests.swift | 1 - AppKitExtras/.gitignore | 8 + AppKitExtras/Package.swift | 30 + .../Sources/AppKitExtras/AppKitExtras.swift | 2 + .../Sources/AppKitExtras/FourCharCode.swift | 37 + .../Sources/AppKitExtras/Keyboard.swift | 147 ++++ .../KeyboardDelegateProtocol.swift | 18 + .../AppKitExtras/NSAppearance+RSCore.swift | 24 + .../NSAppleEventDescriptor+RSCore.swift | 30 + .../Sources/AppKitExtras/NSImage+RSCore.swift | 29 + .../AppKitExtras/NSMenu+Extensions.swift | 31 + .../AppKitExtras/NSOutlineView+RSCore.swift | 183 ++++ .../AppKitExtras/NSPasteboard+RSCore.swift | 63 ++ .../AppKitExtras/NSResponder-Extensions.swift | 31 + .../AppKitExtras/NSTableView+RSCore.swift | 108 +++ .../AppKitExtras/NSToolbar+RSCore.swift | 17 + .../Sources/AppKitExtras/NSView+RSCore.swift | 99 +++ .../AppKitExtras/NSWindow-Extensions.swift | 95 ++ .../NSWindowController+RSCore.swift | 23 + .../AppKitExtras/NSWorkspace+RSCore.swift | 77 ++ .../AppKitExtras/PasteboardWriterOwner.swift | 15 + .../RSDarkModeAdaptingToolbarButton.swift | 41 + .../Sources/AppKitExtras/RSToolbarItem.swift | 65 ++ .../AppKitExtras/URLPasteboardWriter.swift | 47 + .../Sources/AppKitExtras/UserApp.swift | 140 +++ .../AppKitExtrasTests/AppKitExtrasTests.swift | 12 + Articles/Package.swift | 4 +- Articles/Sources/Articles/DatabaseID.swift | 2 +- ArticlesDatabase/Package.swift | 4 +- .../ArticlesDatabaseCompatibility.swift | 1 - .../ArticlesDatabase/SearchTable.swift | 1 - .../ArticlesDatabase/StatusesTable.swift | 1 - CloudKitExtras/.gitignore | 8 + CloudKitExtras/Package.swift | 30 + .../CloudKitExtras/CloudKitError.swift | 98 +++ .../Sources/CloudKitExtras/CloudKitZone.swift | 816 ++++++++++++++++++ .../CloudKitExtras/CloudKitZoneResult.swift | 81 ++ .../CloudKitExtrasTests.swift | 12 + Core/.gitignore | 8 + Core/Package.swift | 39 + Core/Sources/Core/BatchUpdate.swift | 81 ++ Core/Sources/Core/BinaryDiskCache.swift | 74 ++ Core/Sources/Core/Blocks.swift | 23 + Core/Sources/Core/CoalescingQueue.swift | 97 +++ Core/Sources/Core/DisplayNameProvider.swift | 29 + Core/Sources/Core/MacroProcessor.swift | 86 ++ .../Core/MainThreadBlockOperation.swift | 33 + Core/Sources/Core/MainThreadOperation.swift | 99 +++ .../Core/MainThreadOperationQueue.swift | 477 ++++++++++ Core/Sources/Core/ManagedResourceFile.swift | 127 +++ Core/Sources/Core/OPMLRepresentable.swift | 21 + Core/Sources/Core/Platform.swift | 59 ++ Core/Sources/Core/RSAppMovementMonitor.swift | 154 ++++ Core/Sources/Core/RSImage.swift | 217 +++++ Core/Sources/Core/RSScreen.swift | 25 + Core/Sources/Core/Renamable.swift | 23 + Core/Sources/Core/SendToBlogEditorApp.swift | 143 +++ Core/Sources/Core/SendToCommand.swift | 49 ++ Core/Sources/Core/UndoableCommand.swift | 76 ++ ...ndeterminateProgressWindowController.swift | 71 ++ .../AppKit/WebViewWindowController.swift | 40 + .../Resources/IndeterminateProgressWindow.xib | 59 ++ .../CoreResources/Resources/WebViewWindow.xib | 45 + Core/Tests/CoreTests/CoreTests.swift | 12 + FoundationExtras/.gitignore | 8 + FoundationExtras/Package.swift | 24 + .../FoundationExtras/Array+RSCore.swift | 28 + .../FoundationExtras/Calendar+RSCore.swift | 30 + .../FoundationExtras/Character+RSCore.swift | 21 + .../FoundationExtras/Data+RSCore.swift | 180 ++++ .../FoundationExtras/Date+Extensions.swift | 29 + .../FoundationExtras/FileManager+RSCore.swift | 112 +++ .../Sources/FoundationExtras/Geometry.swift | 50 ++ .../FoundationExtras/PropertyList.swift | 32 + .../FoundationExtras/Set+Extensions.swift | 29 + .../FoundationExtras/String+RSCore.swift | 369 ++++++++ .../FoundationExtrasTests.swift | 12 + Mac/AppAssets.swift | 63 +- Mac/AppDelegate.swift | 4 +- .../FolderInspectorViewController.swift | 1 - .../AddFeed/AddFeedController.swift | 3 +- .../AddFeed/AddFeedWindowController.swift | 1 - Mac/MainWindow/AddFeed/FolderTreeMenu.swift | 2 +- .../Detail/DetailViewController.swift | 1 - Mac/MainWindow/Detail/DetailWebView.swift | 2 +- .../Detail/DetailWebViewController.swift | 2 +- .../Keyboard/DetailKeyboardDelegate.swift | 4 +- .../Keyboard/MainWIndowKeyboardHandler.swift | 6 +- Mac/MainWindow/MainWindowController.swift | 3 +- Mac/MainWindow/NNW3/NNW3Document.swift | 2 +- .../NNW3/NNW3ImportController.swift | 2 +- .../SharingServicePickerDelegate.swift | 2 +- Mac/MainWindow/Sidebar/Cell/SidebarCell.swift | 1 - .../Sidebar/Cell/SidebarCellLayout.swift | 1 - .../Keyboard/SidebarKeyboardDelegate.swift | 4 +- Mac/MainWindow/Sidebar/PasteboardFeed.swift | 2 +- Mac/MainWindow/Sidebar/PasteboardFolder.swift | 2 +- .../Renaming/RenameWindowController.swift | 2 +- .../Sidebar/SidebarOutlineDataSource.swift | 3 +- .../Sidebar/SidebarOutlineView.swift | 2 +- .../Sidebar/SidebarStatusBarView.swift | 2 +- ...idebarViewController+ContextualMenus.swift | 3 +- .../Sidebar/SidebarViewController.swift | 2 +- .../Timeline/ArticlePasteboardWriter.swift | 2 +- .../Timeline/Cell/TimelineCellLayout.swift | 1 - .../Timeline/Cell/TimelineTableCellView.swift | 1 - .../Keyboard/TimelineKeyboardDelegate.swift | 4 +- .../Timeline/TimelineTableView.swift | 2 +- ...melineViewController+ContextualMenus.swift | 3 +- .../Timeline/TimelineViewController.swift | 2 +- .../AccountsPreferencesViewController.swift | 2 +- .../Accounts/AddAccountsView.swift | 1 - .../GeneralPrefencesViewController.swift | 1 - .../PreferencesControlsBackgroundView.swift | 1 - Mac/Scriptability/Account+Scriptability.swift | 2 +- Mac/Scriptability/Folder+Scriptability.swift | 2 +- NetNewsWire.xcodeproj/project.pbxproj | 215 +++-- .../xcshareddata/swiftpm/Package.resolved | 123 ++- Shared/Activity/ActivityManager.swift | 1 - .../Article Rendering/ArticleRenderer.swift | 2 +- .../ArticleStyles/ArticleThemesManager.swift | 1 - Shared/Commands/DeleteCommand.swift | 2 +- Shared/Commands/MarkStatusCommand.swift | 2 +- Shared/Exporters/OPMLExporter.swift | 1 - .../SendToMarsEditCommand.swift | 3 +- .../SendToMicroBlogCommand.swift | 3 +- Shared/Extensions/ArticleUtilities.swift | 1 - Shared/Extensions/IconImage.swift | 3 +- Shared/Extensions/Node-Extensions.swift | 2 +- Shared/Extensions/RSImage-AppIcons.swift | 2 +- Shared/Extensions/RSImage-Extensions.swift | 4 +- Shared/Extensions/SmallIconProvider.swift | 1 - Shared/Extensions/URL-Extensions.swift | 2 +- Shared/Favicons/FaviconDownloader.swift | 2 +- Shared/Favicons/FaviconGenerator.swift | 1 - Shared/Favicons/SingleFaviconDownloader.swift | 3 +- Shared/Images/AuthorAvatarDownloader.swift | 2 +- Shared/Images/FeaturedImageDownloader.swift | 1 - Shared/Images/FeedIconDownloader.swift | 2 +- Shared/Images/ImageDownloader.swift | 3 +- Shared/Importers/DefaultFeedsImporter.swift | 1 - .../ExtensionContainersFile.swift | 2 +- Shared/SidebarItem/SidebarItem.swift | 2 +- Shared/SmartFeeds/PseudoFeed.swift | 3 +- Shared/SmartFeeds/SearchFeedDelegate.swift | 1 - .../SearchTimelineFeedDelegate.swift | 1 - Shared/SmartFeeds/SmartFeed.swift | 2 +- Shared/SmartFeeds/SmartFeedDelegate.swift | 2 +- .../SmartFeedPasteboardWriter.swift | 1 - Shared/SmartFeeds/SmartFeedsController.swift | 2 +- Shared/SmartFeeds/StarredFeedDelegate.swift | 1 - Shared/SmartFeeds/TodayFeedDelegate.swift | 1 - Shared/SmartFeeds/UnreadFeed.swift | 1 - Shared/Timeline/FetchRequestOperation.swift | 1 - .../Tree/FolderTreeControllerDelegate.swift | 1 - Shared/Widget/WidgetDataEncoder.swift | 1 - SyncDatabase/Package.swift | 2 - .../Sources/SyncDatabase/SyncDatabase.swift | 1 - .../SyncDatabase/SyncStatusTable.swift | 1 - UIKitExtras/.gitignore | 8 + UIKitExtras/Package.swift | 24 + .../UIKitExtras/UIResponder+RSCore.swift | 42 + .../Sources/UIKitExtras/UIView+RSCore.swift | 41 + .../UIKitExtras/UIViewController+RSCore.swift | 68 ++ .../Sources/UIKitExtras/UIWindow+RSCore.swift | 42 + .../UIKitExtrasTests/UIKitExtrasTests.swift | 12 + iOS/Add/AddFeedFolderViewController.swift | 2 +- iOS/Add/AddFeedViewController.swift | 1 - iOS/Add/AddFolderViewController.swift | 2 +- iOS/AppAssets.swift | 3 +- iOS/AppDelegate.swift | 2 +- iOS/Article/WebViewController.swift | 2 +- iOS/ErrorHandler.swift | 1 - iOS/Feeds/Cell/FeedTableViewCell.swift | 1 - iOS/Feeds/Cell/FeedTableViewCellLayout.swift | 1 - .../Cell/FeedTableViewSectionHeader.swift | 1 + .../FeedTableViewSectionHeaderLayout.swift | 1 - iOS/Feeds/FeedsViewController+Drop.swift | 1 - iOS/Feeds/SidebarViewController.swift | 2 +- iOS/SceneCoordinator.swift | 1 - iOS/Settings/AddAccountViewController.swift | 1 - .../ShareFolderPickerController.swift | 1 - iOS/ShareExtension/ShareViewController.swift | 1 - .../TimelineAccessibilityCellLayout.swift | 1 - .../Cell/TimelineDefaultCellLayout.swift | 1 - iOS/Timeline/Cell/TimelineTableViewCell.swift | 1 - iOS/Timeline/TimelineViewController.swift | 2 +- .../UIViewController-Extensions.swift | 1 - 240 files changed, 6052 insertions(+), 384 deletions(-) create mode 100644 AppKitExtras/.gitignore create mode 100644 AppKitExtras/Package.swift create mode 100644 AppKitExtras/Sources/AppKitExtras/AppKitExtras.swift create mode 100644 AppKitExtras/Sources/AppKitExtras/FourCharCode.swift create mode 100644 AppKitExtras/Sources/AppKitExtras/Keyboard.swift create mode 100644 AppKitExtras/Sources/AppKitExtras/KeyboardDelegateProtocol.swift create mode 100644 AppKitExtras/Sources/AppKitExtras/NSAppearance+RSCore.swift create mode 100644 AppKitExtras/Sources/AppKitExtras/NSAppleEventDescriptor+RSCore.swift create mode 100644 AppKitExtras/Sources/AppKitExtras/NSImage+RSCore.swift create mode 100644 AppKitExtras/Sources/AppKitExtras/NSMenu+Extensions.swift create mode 100755 AppKitExtras/Sources/AppKitExtras/NSOutlineView+RSCore.swift create mode 100644 AppKitExtras/Sources/AppKitExtras/NSPasteboard+RSCore.swift create mode 100755 AppKitExtras/Sources/AppKitExtras/NSResponder-Extensions.swift create mode 100755 AppKitExtras/Sources/AppKitExtras/NSTableView+RSCore.swift create mode 100644 AppKitExtras/Sources/AppKitExtras/NSToolbar+RSCore.swift create mode 100644 AppKitExtras/Sources/AppKitExtras/NSView+RSCore.swift create mode 100755 AppKitExtras/Sources/AppKitExtras/NSWindow-Extensions.swift create mode 100644 AppKitExtras/Sources/AppKitExtras/NSWindowController+RSCore.swift create mode 100644 AppKitExtras/Sources/AppKitExtras/NSWorkspace+RSCore.swift create mode 100644 AppKitExtras/Sources/AppKitExtras/PasteboardWriterOwner.swift create mode 100644 AppKitExtras/Sources/AppKitExtras/RSDarkModeAdaptingToolbarButton.swift create mode 100755 AppKitExtras/Sources/AppKitExtras/RSToolbarItem.swift create mode 100644 AppKitExtras/Sources/AppKitExtras/URLPasteboardWriter.swift create mode 100644 AppKitExtras/Sources/AppKitExtras/UserApp.swift create mode 100644 AppKitExtras/Tests/AppKitExtrasTests/AppKitExtrasTests.swift create mode 100644 CloudKitExtras/.gitignore create mode 100644 CloudKitExtras/Package.swift create mode 100644 CloudKitExtras/Sources/CloudKitExtras/CloudKitError.swift create mode 100644 CloudKitExtras/Sources/CloudKitExtras/CloudKitZone.swift create mode 100644 CloudKitExtras/Sources/CloudKitExtras/CloudKitZoneResult.swift create mode 100644 CloudKitExtras/Tests/CloudKitExtrasTests/CloudKitExtrasTests.swift create mode 100644 Core/.gitignore create mode 100644 Core/Package.swift create mode 100644 Core/Sources/Core/BatchUpdate.swift create mode 100644 Core/Sources/Core/BinaryDiskCache.swift create mode 100644 Core/Sources/Core/Blocks.swift create mode 100644 Core/Sources/Core/CoalescingQueue.swift create mode 100644 Core/Sources/Core/DisplayNameProvider.swift create mode 100644 Core/Sources/Core/MacroProcessor.swift create mode 100644 Core/Sources/Core/MainThreadBlockOperation.swift create mode 100644 Core/Sources/Core/MainThreadOperation.swift create mode 100644 Core/Sources/Core/MainThreadOperationQueue.swift create mode 100644 Core/Sources/Core/ManagedResourceFile.swift create mode 100644 Core/Sources/Core/OPMLRepresentable.swift create mode 100644 Core/Sources/Core/Platform.swift create mode 100644 Core/Sources/Core/RSAppMovementMonitor.swift create mode 100644 Core/Sources/Core/RSImage.swift create mode 100644 Core/Sources/Core/RSScreen.swift create mode 100644 Core/Sources/Core/Renamable.swift create mode 100644 Core/Sources/Core/SendToBlogEditorApp.swift create mode 100644 Core/Sources/Core/SendToCommand.swift create mode 100644 Core/Sources/Core/UndoableCommand.swift create mode 100644 Core/Sources/CoreResources/AppKit/IndeterminateProgressWindowController.swift create mode 100644 Core/Sources/CoreResources/AppKit/WebViewWindowController.swift create mode 100644 Core/Sources/CoreResources/Resources/IndeterminateProgressWindow.xib create mode 100644 Core/Sources/CoreResources/Resources/WebViewWindow.xib create mode 100644 Core/Tests/CoreTests/CoreTests.swift create mode 100644 FoundationExtras/.gitignore create mode 100644 FoundationExtras/Package.swift create mode 100644 FoundationExtras/Sources/FoundationExtras/Array+RSCore.swift create mode 100644 FoundationExtras/Sources/FoundationExtras/Calendar+RSCore.swift create mode 100644 FoundationExtras/Sources/FoundationExtras/Character+RSCore.swift create mode 100644 FoundationExtras/Sources/FoundationExtras/Data+RSCore.swift create mode 100755 FoundationExtras/Sources/FoundationExtras/Date+Extensions.swift create mode 100644 FoundationExtras/Sources/FoundationExtras/FileManager+RSCore.swift create mode 100644 FoundationExtras/Sources/FoundationExtras/Geometry.swift create mode 100644 FoundationExtras/Sources/FoundationExtras/PropertyList.swift create mode 100755 FoundationExtras/Sources/FoundationExtras/Set+Extensions.swift create mode 100644 FoundationExtras/Sources/FoundationExtras/String+RSCore.swift create mode 100644 FoundationExtras/Tests/FoundationExtrasTests/FoundationExtrasTests.swift create mode 100644 UIKitExtras/.gitignore create mode 100644 UIKitExtras/Package.swift create mode 100644 UIKitExtras/Sources/UIKitExtras/UIResponder+RSCore.swift create mode 100644 UIKitExtras/Sources/UIKitExtras/UIView+RSCore.swift create mode 100644 UIKitExtras/Sources/UIKitExtras/UIViewController+RSCore.swift create mode 100644 UIKitExtras/Sources/UIKitExtras/UIWindow+RSCore.swift create mode 100644 UIKitExtras/Tests/UIKitExtrasTests/UIKitExtrasTests.swift diff --git a/Account/Package.swift b/Account/Package.swift index 001214d39..e6d064f09 100644 --- a/Account/Package.swift +++ b/Account/Package.swift @@ -11,27 +11,29 @@ let package = Package( targets: ["Account"]), ], dependencies: [ - .package(url: "https://github.com/Ranchero-Software/RSCore.git", .upToNextMinor(from: "1.0.0")), .package(url: "https://github.com/Ranchero-Software/RSParser.git", .upToNextMajor(from: "2.0.2")), .package(url: "https://github.com/Ranchero-Software/RSWeb.git", .upToNextMajor(from: "1.0.0")), .package(path: "../Articles"), .package(path: "../ArticlesDatabase"), .package(path: "../Secrets"), .package(path: "../Database"), - .package(path: "../SyncDatabase") + .package(path: "../SyncDatabase"), + .package(path: "../Core"), + .package(path: "../CloudKitExtras") ], targets: [ .target( name: "Account", dependencies: [ - "RSCore", "RSParser", "RSWeb", "Articles", "ArticlesDatabase", "Secrets", "SyncDatabase", - "Database" + "Database", + "Core", + "CloudKitExtras" ]), .testTarget( name: "AccountTests", diff --git a/Account/Sources/Account/Account.swift b/Account/Sources/Account/Account.swift index d8a213ff7..d39162704 100644 --- a/Account/Sources/Account/Account.swift +++ b/Account/Sources/Account/Account.swift @@ -11,7 +11,6 @@ import UIKit #endif import Foundation -import RSCore import Articles import RSParser import Database @@ -19,6 +18,7 @@ import ArticlesDatabase import RSWeb import os.log import Secrets +import Core // Main thread only. diff --git a/Account/Sources/Account/AccountManager.swift b/Account/Sources/Account/AccountManager.swift index ae9d91904..fa1e19bc7 100644 --- a/Account/Sources/Account/AccountManager.swift +++ b/Account/Sources/Account/AccountManager.swift @@ -7,7 +7,6 @@ // import Foundation -import RSCore import RSWeb import Articles import ArticlesDatabase diff --git a/Account/Sources/Account/AccountMetadataFile.swift b/Account/Sources/Account/AccountMetadataFile.swift index d3bc67d65..6cd8014ce 100644 --- a/Account/Sources/Account/AccountMetadataFile.swift +++ b/Account/Sources/Account/AccountMetadataFile.swift @@ -8,7 +8,7 @@ import Foundation import os.log -import RSCore +import Core final class AccountMetadataFile { diff --git a/Account/Sources/Account/CloudKit/CloudKitAccountDelegate.swift b/Account/Sources/Account/CloudKit/CloudKitAccountDelegate.swift index 1fc0337f0..1391e34b2 100644 --- a/Account/Sources/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Account/Sources/Account/CloudKit/CloudKitAccountDelegate.swift @@ -11,12 +11,13 @@ import CloudKit import SystemConfiguration import os.log import SyncDatabase -import RSCore import RSParser import Articles import ArticlesDatabase import RSWeb import Secrets +import Core +import CloudKitExtras enum CloudKitAccountDelegateError: LocalizedError { case invalidParameter diff --git a/Account/Sources/Account/CloudKit/CloudKitAccountZone.swift b/Account/Sources/Account/CloudKit/CloudKitAccountZone.swift index 0e49f2890..118f177e6 100644 --- a/Account/Sources/Account/CloudKit/CloudKitAccountZone.swift +++ b/Account/Sources/Account/CloudKit/CloudKitAccountZone.swift @@ -8,10 +8,11 @@ import Foundation import os.log -import RSCore import RSWeb import RSParser import CloudKit +import FoundationExtras +import CloudKitExtras enum CloudKitAccountZoneError: LocalizedError { case unknown diff --git a/Account/Sources/Account/CloudKit/CloudKitAccountZoneDelegate.swift b/Account/Sources/Account/CloudKit/CloudKitAccountZoneDelegate.swift index 4b11514bb..1ddffaa5a 100644 --- a/Account/Sources/Account/CloudKit/CloudKitAccountZoneDelegate.swift +++ b/Account/Sources/Account/CloudKit/CloudKitAccountZoneDelegate.swift @@ -10,8 +10,8 @@ import Foundation import os.log import RSWeb import CloudKit -import RSCore import Articles +import CloudKitExtras class CloudKitAcountZoneDelegate: CloudKitZoneDelegate { diff --git a/Account/Sources/Account/CloudKit/CloudKitArticlesZone.swift b/Account/Sources/Account/CloudKit/CloudKitArticlesZone.swift index 13b148d2b..e1c1c1641 100644 --- a/Account/Sources/Account/CloudKit/CloudKitArticlesZone.swift +++ b/Account/Sources/Account/CloudKit/CloudKitArticlesZone.swift @@ -8,12 +8,12 @@ import Foundation import os.log -import RSCore import RSParser import RSWeb import CloudKit import Articles import SyncDatabase +import CloudKitExtras final class CloudKitArticlesZone: CloudKitZone { diff --git a/Account/Sources/Account/CloudKit/CloudKitArticlesZoneDelegate.swift b/Account/Sources/Account/CloudKit/CloudKitArticlesZoneDelegate.swift index a94d69b35..c15a58660 100644 --- a/Account/Sources/Account/CloudKit/CloudKitArticlesZoneDelegate.swift +++ b/Account/Sources/Account/CloudKit/CloudKitArticlesZoneDelegate.swift @@ -8,7 +8,6 @@ import Foundation import os.log -import RSCore import RSParser import RSWeb import CloudKit @@ -16,6 +15,7 @@ import SyncDatabase import Articles import ArticlesDatabase import Database +import CloudKitExtras class CloudKitArticlesZoneDelegate: CloudKitZoneDelegate { diff --git a/Account/Sources/Account/CloudKit/CloudKitReceiveStatusOperation.swift b/Account/Sources/Account/CloudKit/CloudKitReceiveStatusOperation.swift index 012942db5..d73c35564 100644 --- a/Account/Sources/Account/CloudKit/CloudKitReceiveStatusOperation.swift +++ b/Account/Sources/Account/CloudKit/CloudKitReceiveStatusOperation.swift @@ -8,7 +8,7 @@ import Foundation import os.log -import RSCore +import Core class CloudKitReceiveStatusOperation: MainThreadOperation { diff --git a/Account/Sources/Account/CloudKit/CloudKitRemoteNotificationOperation.swift b/Account/Sources/Account/CloudKit/CloudKitRemoteNotificationOperation.swift index 008b84846..7ae382ef1 100644 --- a/Account/Sources/Account/CloudKit/CloudKitRemoteNotificationOperation.swift +++ b/Account/Sources/Account/CloudKit/CloudKitRemoteNotificationOperation.swift @@ -7,9 +7,8 @@ // import Foundation - import os.log -import RSCore +import Core class CloudKitRemoteNotificationOperation: MainThreadOperation { diff --git a/Account/Sources/Account/CloudKit/CloudKitSendStatusOperation.swift b/Account/Sources/Account/CloudKit/CloudKitSendStatusOperation.swift index 5ef1efb0c..38a7e2811 100644 --- a/Account/Sources/Account/CloudKit/CloudKitSendStatusOperation.swift +++ b/Account/Sources/Account/CloudKit/CloudKitSendStatusOperation.swift @@ -9,10 +9,11 @@ import Foundation import Articles import os.log -import RSCore import RSWeb import SyncDatabase import Database +import Core +import CloudKitExtras class CloudKitSendStatusOperation: MainThreadOperation { diff --git a/Account/Sources/Account/Container.swift b/Account/Sources/Account/Container.swift index a5d1714db..adb37f35a 100644 --- a/Account/Sources/Account/Container.swift +++ b/Account/Sources/Account/Container.swift @@ -8,7 +8,6 @@ // import Foundation -import RSCore import Articles extension Notification.Name { diff --git a/Account/Sources/Account/Feed.swift b/Account/Sources/Account/Feed.swift index 2aec0abfd..14c5db871 100644 --- a/Account/Sources/Account/Feed.swift +++ b/Account/Sources/Account/Feed.swift @@ -7,9 +7,9 @@ // import Foundation -import RSCore import RSWeb import Articles +import Core public final class Feed: Renamable, DisplayNameProvider, UnreadCountProvider, Hashable { diff --git a/Account/Sources/Account/FeedFinder/FeedFinder.swift b/Account/Sources/Account/FeedFinder/FeedFinder.swift index e81c5160c..19b892a1f 100644 --- a/Account/Sources/Account/FeedFinder/FeedFinder.swift +++ b/Account/Sources/Account/FeedFinder/FeedFinder.swift @@ -9,7 +9,6 @@ import Foundation import RSParser import RSWeb -import RSCore class FeedFinder { diff --git a/Account/Sources/Account/FeedMetadataFile.swift b/Account/Sources/Account/FeedMetadataFile.swift index 33a7a84b8..acbbbb7eb 100644 --- a/Account/Sources/Account/FeedMetadataFile.swift +++ b/Account/Sources/Account/FeedMetadataFile.swift @@ -8,7 +8,7 @@ import Foundation import os.log -import RSCore +import Core final class FeedMetadataFile { diff --git a/Account/Sources/Account/Feedbin/FeedbinAccountDelegate.swift b/Account/Sources/Account/Feedbin/FeedbinAccountDelegate.swift index 46564f8b9..8f3f3e27b 100644 --- a/Account/Sources/Account/Feedbin/FeedbinAccountDelegate.swift +++ b/Account/Sources/Account/Feedbin/FeedbinAccountDelegate.swift @@ -7,13 +7,13 @@ // import Articles -import RSCore import Database import RSParser import RSWeb import SyncDatabase import os.log import Secrets +import Core public enum FeedbinAccountDelegateError: String, Error { case invalidParameter = "There was an invalid parameter passed." diff --git a/Account/Sources/Account/Feedbin/FeedbinEntry.swift b/Account/Sources/Account/Feedbin/FeedbinEntry.swift index 27e23a140..d555e39ae 100644 --- a/Account/Sources/Account/Feedbin/FeedbinEntry.swift +++ b/Account/Sources/Account/Feedbin/FeedbinEntry.swift @@ -8,7 +8,6 @@ import Foundation import RSParser -import RSCore final class FeedbinEntry: Decodable { diff --git a/Account/Sources/Account/Feedbin/FeedbinSubscription.swift b/Account/Sources/Account/Feedbin/FeedbinSubscription.swift index d789d1deb..2fd1a656b 100644 --- a/Account/Sources/Account/Feedbin/FeedbinSubscription.swift +++ b/Account/Sources/Account/Feedbin/FeedbinSubscription.swift @@ -7,7 +7,6 @@ // import Foundation -import RSCore import RSParser struct FeedbinSubscription: Hashable, Codable { diff --git a/Account/Sources/Account/Feedly/FeedlyAPICaller.swift b/Account/Sources/Account/Feedly/FeedlyAPICaller.swift index 01c1cd3c2..3124b5dea 100644 --- a/Account/Sources/Account/Feedly/FeedlyAPICaller.swift +++ b/Account/Sources/Account/Feedly/FeedlyAPICaller.swift @@ -7,7 +7,6 @@ // import Foundation -import RSCore import RSWeb import Secrets diff --git a/Account/Sources/Account/Feedly/FeedlyAccountDelegate.swift b/Account/Sources/Account/Feedly/FeedlyAccountDelegate.swift index 23618d1c2..68654e582 100644 --- a/Account/Sources/Account/Feedly/FeedlyAccountDelegate.swift +++ b/Account/Sources/Account/Feedly/FeedlyAccountDelegate.swift @@ -7,12 +7,12 @@ // import Articles -import RSCore import RSParser import RSWeb import SyncDatabase import os.log import Secrets +import Core final class FeedlyAccountDelegate: AccountDelegate { diff --git a/Account/Sources/Account/Feedly/OAuthAccountAuthorizationOperation.swift b/Account/Sources/Account/Feedly/OAuthAccountAuthorizationOperation.swift index 8dcfc801c..8b3264e65 100644 --- a/Account/Sources/Account/Feedly/OAuthAccountAuthorizationOperation.swift +++ b/Account/Sources/Account/Feedly/OAuthAccountAuthorizationOperation.swift @@ -8,8 +8,8 @@ import Foundation import AuthenticationServices -import RSCore import Secrets +import Core public protocol OAuthAccountAuthorizationOperationDelegate: AnyObject { func oauthAccountAuthorizationOperation(_ operation: OAuthAccountAuthorizationOperation, didCreate account: Account) diff --git a/Account/Sources/Account/Feedly/Operations/FeedlyAddExistingFeedOperation.swift b/Account/Sources/Account/Feedly/Operations/FeedlyAddExistingFeedOperation.swift index ed0824663..0a872360b 100644 --- a/Account/Sources/Account/Feedly/Operations/FeedlyAddExistingFeedOperation.swift +++ b/Account/Sources/Account/Feedly/Operations/FeedlyAddExistingFeedOperation.swift @@ -9,8 +9,8 @@ import Foundation import os.log import RSWeb -import RSCore import Secrets +import Core class FeedlyAddExistingFeedOperation: FeedlyOperation, FeedlyOperationDelegate, FeedlyCheckpointOperationDelegate { diff --git a/Account/Sources/Account/Feedly/Operations/FeedlyAddNewFeedOperation.swift b/Account/Sources/Account/Feedly/Operations/FeedlyAddNewFeedOperation.swift index 6a97c1fc8..f1dd6c3c4 100644 --- a/Account/Sources/Account/Feedly/Operations/FeedlyAddNewFeedOperation.swift +++ b/Account/Sources/Account/Feedly/Operations/FeedlyAddNewFeedOperation.swift @@ -10,8 +10,8 @@ import Foundation import os.log import SyncDatabase import RSWeb -import RSCore import Secrets +import Core class FeedlyAddNewFeedOperation: FeedlyOperation, FeedlyOperationDelegate, FeedlySearchOperationDelegate, FeedlyCheckpointOperationDelegate { diff --git a/Account/Sources/Account/Feedly/Operations/FeedlyCreateFeedsForCollectionFoldersOperation.swift b/Account/Sources/Account/Feedly/Operations/FeedlyCreateFeedsForCollectionFoldersOperation.swift index bc13136fc..a4ce57405 100644 --- a/Account/Sources/Account/Feedly/Operations/FeedlyCreateFeedsForCollectionFoldersOperation.swift +++ b/Account/Sources/Account/Feedly/Operations/FeedlyCreateFeedsForCollectionFoldersOperation.swift @@ -8,6 +8,7 @@ import Foundation import os.log +import Core /// Single responsibility is to accurately reflect Collections and their Feeds as Folders and their Feeds. final class FeedlyCreateFeedsForCollectionFoldersOperation: FeedlyOperation { diff --git a/Account/Sources/Account/Feedly/Operations/FeedlyDownloadArticlesOperation.swift b/Account/Sources/Account/Feedly/Operations/FeedlyDownloadArticlesOperation.swift index 550ee34d7..1cd537b82 100644 --- a/Account/Sources/Account/Feedly/Operations/FeedlyDownloadArticlesOperation.swift +++ b/Account/Sources/Account/Feedly/Operations/FeedlyDownloadArticlesOperation.swift @@ -8,8 +8,8 @@ import Foundation import os.log -import RSCore import RSWeb +import Core class FeedlyDownloadArticlesOperation: FeedlyOperation { diff --git a/Account/Sources/Account/Feedly/Operations/FeedlyOperation.swift b/Account/Sources/Account/Feedly/Operations/FeedlyOperation.swift index a4d1c0ff1..814398121 100644 --- a/Account/Sources/Account/Feedly/Operations/FeedlyOperation.swift +++ b/Account/Sources/Account/Feedly/Operations/FeedlyOperation.swift @@ -8,7 +8,7 @@ import Foundation import RSWeb -import RSCore +import Core protocol FeedlyOperationDelegate: AnyObject { func feedlyOperation(_ operation: FeedlyOperation, didFailWith error: Error) diff --git a/Account/Sources/Account/Feedly/Operations/FeedlySyncAllOperation.swift b/Account/Sources/Account/Feedly/Operations/FeedlySyncAllOperation.swift index 6cbde0443..7920a133e 100644 --- a/Account/Sources/Account/Feedly/Operations/FeedlySyncAllOperation.swift +++ b/Account/Sources/Account/Feedly/Operations/FeedlySyncAllOperation.swift @@ -10,8 +10,8 @@ import Foundation import os.log import SyncDatabase import RSWeb -import RSCore import Secrets +import Core /// Compose the operations necessary to get the entire set of articles, feeds and folders with the statuses the user expects between now and a certain date in the past. final class FeedlySyncAllOperation: FeedlyOperation { diff --git a/Account/Sources/Account/Feedly/Operations/FeedlySyncStreamContentsOperation.swift b/Account/Sources/Account/Feedly/Operations/FeedlySyncStreamContentsOperation.swift index ef1770967..659106361 100644 --- a/Account/Sources/Account/Feedly/Operations/FeedlySyncStreamContentsOperation.swift +++ b/Account/Sources/Account/Feedly/Operations/FeedlySyncStreamContentsOperation.swift @@ -9,9 +9,9 @@ import Foundation import os.log import RSParser -import RSCore import RSWeb import Secrets +import Core final class FeedlySyncStreamContentsOperation: FeedlyOperation, FeedlyOperationDelegate, FeedlyGetStreamContentsOperationDelegate, FeedlyCheckpointOperationDelegate { diff --git a/Account/Sources/Account/Folder.swift b/Account/Sources/Account/Folder.swift index a671b5a5f..ff311a008 100644 --- a/Account/Sources/Account/Folder.swift +++ b/Account/Sources/Account/Folder.swift @@ -8,7 +8,7 @@ import Foundation import Articles -import RSCore +import Core public final class Folder: Renamable, Container, DisplayNameProvider, UnreadCountProvider, Hashable { diff --git a/Account/Sources/Account/LocalAccount/LocalAccountDelegate.swift b/Account/Sources/Account/LocalAccount/LocalAccountDelegate.swift index cadcab2a1..434939c81 100644 --- a/Account/Sources/Account/LocalAccount/LocalAccountDelegate.swift +++ b/Account/Sources/Account/LocalAccount/LocalAccountDelegate.swift @@ -8,12 +8,12 @@ import Foundation import os.log -import RSCore import RSParser import Articles import ArticlesDatabase import RSWeb import Secrets +import Core public enum LocalAccountDelegateError: String, Error { case invalidParameter = "An invalid parameter was used." diff --git a/Account/Sources/Account/LocalAccount/LocalAccountRefresher.swift b/Account/Sources/Account/LocalAccount/LocalAccountRefresher.swift index 20af6aee4..799051495 100644 --- a/Account/Sources/Account/LocalAccount/LocalAccountRefresher.swift +++ b/Account/Sources/Account/LocalAccount/LocalAccountRefresher.swift @@ -7,11 +7,11 @@ // import Foundation -import RSCore import RSParser import RSWeb import Articles import ArticlesDatabase +import FoundationExtras protocol LocalAccountRefresherDelegate { func localAccountRefresher(_ refresher: LocalAccountRefresher, requestCompletedFor: Feed) diff --git a/Account/Sources/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift b/Account/Sources/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift index fa6eab17c..9f9f61fe0 100644 --- a/Account/Sources/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift +++ b/Account/Sources/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift @@ -8,12 +8,12 @@ // import Articles -import RSCore import Database import RSParser import RSWeb import SyncDatabase import os.log +import Core extension NewsBlurAccountDelegate { diff --git a/Account/Sources/Account/NewsBlur/Models/NewsBlurFeed.swift b/Account/Sources/Account/NewsBlur/Models/NewsBlurFeed.swift index f0b0300bb..ddf544883 100644 --- a/Account/Sources/Account/NewsBlur/Models/NewsBlurFeed.swift +++ b/Account/Sources/Account/NewsBlur/Models/NewsBlurFeed.swift @@ -7,7 +7,6 @@ // import Foundation -import RSCore import RSParser typealias NewsBlurFolder = NewsBlurFeedsResponse.Folder diff --git a/Account/Sources/Account/NewsBlur/Models/NewsBlurStory.swift b/Account/Sources/Account/NewsBlur/Models/NewsBlurStory.swift index e4878b287..3360fb588 100644 --- a/Account/Sources/Account/NewsBlur/Models/NewsBlurStory.swift +++ b/Account/Sources/Account/NewsBlur/Models/NewsBlurStory.swift @@ -7,7 +7,6 @@ // import Foundation -import RSCore import RSParser typealias NewsBlurStory = NewsBlurStoriesResponse.Story diff --git a/Account/Sources/Account/NewsBlur/Models/NewsBlurStoryHash.swift b/Account/Sources/Account/NewsBlur/Models/NewsBlurStoryHash.swift index 48286df00..7e8dc4799 100644 --- a/Account/Sources/Account/NewsBlur/Models/NewsBlurStoryHash.swift +++ b/Account/Sources/Account/NewsBlur/Models/NewsBlurStoryHash.swift @@ -7,7 +7,6 @@ // import Foundation -import RSCore import RSParser typealias NewsBlurStoryHash = NewsBlurStoryHashesResponse.StoryHash diff --git a/Account/Sources/Account/NewsBlur/NewsBlurAccountDelegate.swift b/Account/Sources/Account/NewsBlur/NewsBlurAccountDelegate.swift index e21a5a741..86f9d8f1d 100644 --- a/Account/Sources/Account/NewsBlur/NewsBlurAccountDelegate.swift +++ b/Account/Sources/Account/NewsBlur/NewsBlurAccountDelegate.swift @@ -7,7 +7,6 @@ // import Articles -import RSCore import Database import RSParser import RSWeb diff --git a/Account/Sources/Account/OPMLFile.swift b/Account/Sources/Account/OPMLFile.swift index 134358c35..8bac59063 100644 --- a/Account/Sources/Account/OPMLFile.swift +++ b/Account/Sources/Account/OPMLFile.swift @@ -8,8 +8,8 @@ import Foundation import os.log -import RSCore import RSParser +import Core final class OPMLFile { diff --git a/Account/Sources/Account/ReaderAPI/ReaderAPIAccountDelegate.swift b/Account/Sources/Account/ReaderAPI/ReaderAPIAccountDelegate.swift index 0fa88fe85..26620a732 100644 --- a/Account/Sources/Account/ReaderAPI/ReaderAPIAccountDelegate.swift +++ b/Account/Sources/Account/ReaderAPI/ReaderAPIAccountDelegate.swift @@ -7,13 +7,13 @@ // import Articles -import RSCore import RSParser import RSWeb import SyncDatabase import os.log import Secrets import Database +import Core public enum ReaderAPIAccountDelegateError: LocalizedError { case unknown diff --git a/Account/Sources/Account/ReaderAPI/ReaderAPIEntry.swift b/Account/Sources/Account/ReaderAPI/ReaderAPIEntry.swift index 93de146f8..a9dfadf9a 100644 --- a/Account/Sources/Account/ReaderAPI/ReaderAPIEntry.swift +++ b/Account/Sources/Account/ReaderAPI/ReaderAPIEntry.swift @@ -8,7 +8,6 @@ import Foundation import RSParser -import RSCore struct ReaderAPIEntryWrapper: Codable { let id: String diff --git a/Account/Sources/Account/ReaderAPI/ReaderAPISubscription.swift b/Account/Sources/Account/ReaderAPI/ReaderAPISubscription.swift index e74491ca6..3296c72ea 100644 --- a/Account/Sources/Account/ReaderAPI/ReaderAPISubscription.swift +++ b/Account/Sources/Account/ReaderAPI/ReaderAPISubscription.swift @@ -7,7 +7,6 @@ // import Foundation -import RSCore import RSParser /* diff --git a/Account/Tests/AccountTests/Feedly/FeedlyCheckpointOperationTests.swift b/Account/Tests/AccountTests/Feedly/FeedlyCheckpointOperationTests.swift index 565edb8d2..b5bb4bb8e 100644 --- a/Account/Tests/AccountTests/Feedly/FeedlyCheckpointOperationTests.swift +++ b/Account/Tests/AccountTests/Feedly/FeedlyCheckpointOperationTests.swift @@ -8,7 +8,6 @@ import XCTest @testable import Account -import RSCore class FeedlyCheckpointOperationTests: XCTestCase { diff --git a/Account/Tests/AccountTests/Feedly/FeedlyCreateFeedsForCollectionFoldersOperationTests.swift b/Account/Tests/AccountTests/Feedly/FeedlyCreateFeedsForCollectionFoldersOperationTests.swift index 9390b3066..7771b2237 100644 --- a/Account/Tests/AccountTests/Feedly/FeedlyCreateFeedsForCollectionFoldersOperationTests.swift +++ b/Account/Tests/AccountTests/Feedly/FeedlyCreateFeedsForCollectionFoldersOperationTests.swift @@ -8,7 +8,6 @@ import XCTest @testable import Account -import RSCore class FeedlyCreateFeedsForCollectionFoldersOperationTests: XCTestCase { diff --git a/Account/Tests/AccountTests/Feedly/FeedlyGetCollectionsOperationTests.swift b/Account/Tests/AccountTests/Feedly/FeedlyGetCollectionsOperationTests.swift index 8639b670b..9c50f6169 100644 --- a/Account/Tests/AccountTests/Feedly/FeedlyGetCollectionsOperationTests.swift +++ b/Account/Tests/AccountTests/Feedly/FeedlyGetCollectionsOperationTests.swift @@ -9,7 +9,6 @@ import XCTest @testable import Account import os.log -import RSCore class FeedlyGetCollectionsOperationTests: XCTestCase { diff --git a/Account/Tests/AccountTests/Feedly/FeedlyGetStreamContentsOperationTests.swift b/Account/Tests/AccountTests/Feedly/FeedlyGetStreamContentsOperationTests.swift index 992ecd4c0..736d70264 100644 --- a/Account/Tests/AccountTests/Feedly/FeedlyGetStreamContentsOperationTests.swift +++ b/Account/Tests/AccountTests/Feedly/FeedlyGetStreamContentsOperationTests.swift @@ -8,7 +8,6 @@ import XCTest @testable import Account -import RSCore class FeedlyGetStreamContentsOperationTests: XCTestCase { diff --git a/Account/Tests/AccountTests/Feedly/FeedlyGetStreamIdsOperationTests.swift b/Account/Tests/AccountTests/Feedly/FeedlyGetStreamIdsOperationTests.swift index c4357507b..971a4556f 100644 --- a/Account/Tests/AccountTests/Feedly/FeedlyGetStreamIdsOperationTests.swift +++ b/Account/Tests/AccountTests/Feedly/FeedlyGetStreamIdsOperationTests.swift @@ -8,7 +8,6 @@ import XCTest @testable import Account -import RSCore class FeedlyGetStreamIdsOperationTests: XCTestCase { diff --git a/Account/Tests/AccountTests/Feedly/FeedlyLogoutOperationTests.swift b/Account/Tests/AccountTests/Feedly/FeedlyLogoutOperationTests.swift index 9e6b45f6d..1d47dfd33 100644 --- a/Account/Tests/AccountTests/Feedly/FeedlyLogoutOperationTests.swift +++ b/Account/Tests/AccountTests/Feedly/FeedlyLogoutOperationTests.swift @@ -8,7 +8,6 @@ import XCTest @testable import Account -import RSCore import Secrets class FeedlyLogoutOperationTests: XCTestCase { diff --git a/Account/Tests/AccountTests/Feedly/FeedlyMirrorCollectionsAsFoldersOperationTests.swift b/Account/Tests/AccountTests/Feedly/FeedlyMirrorCollectionsAsFoldersOperationTests.swift index 940ef1d44..2a0902591 100644 --- a/Account/Tests/AccountTests/Feedly/FeedlyMirrorCollectionsAsFoldersOperationTests.swift +++ b/Account/Tests/AccountTests/Feedly/FeedlyMirrorCollectionsAsFoldersOperationTests.swift @@ -8,7 +8,6 @@ import XCTest @testable import Account -import RSCore class FeedlyMirrorCollectionsAsFoldersOperationTests: XCTestCase { diff --git a/Account/Tests/AccountTests/Feedly/FeedlyOperationTests.swift b/Account/Tests/AccountTests/Feedly/FeedlyOperationTests.swift index 7492baa90..1cbb3635f 100644 --- a/Account/Tests/AccountTests/Feedly/FeedlyOperationTests.swift +++ b/Account/Tests/AccountTests/Feedly/FeedlyOperationTests.swift @@ -9,7 +9,6 @@ import XCTest @testable import Account import RSWeb -import RSCore class FeedlyOperationTests: XCTestCase { diff --git a/Account/Tests/AccountTests/Feedly/FeedlyOrganiseParsedItemsByFeedOperationTests.swift b/Account/Tests/AccountTests/Feedly/FeedlyOrganiseParsedItemsByFeedOperationTests.swift index 654e9a3b0..445bad9ca 100644 --- a/Account/Tests/AccountTests/Feedly/FeedlyOrganiseParsedItemsByFeedOperationTests.swift +++ b/Account/Tests/AccountTests/Feedly/FeedlyOrganiseParsedItemsByFeedOperationTests.swift @@ -9,7 +9,6 @@ import XCTest @testable import Account import RSParser -import RSCore class FeedlyOrganiseParsedItemsByFeedOperationTests: XCTestCase { diff --git a/Account/Tests/AccountTests/Feedly/FeedlyRefreshAccessTokenOperationTests.swift b/Account/Tests/AccountTests/Feedly/FeedlyRefreshAccessTokenOperationTests.swift index 6058b5888..df9921b09 100644 --- a/Account/Tests/AccountTests/Feedly/FeedlyRefreshAccessTokenOperationTests.swift +++ b/Account/Tests/AccountTests/Feedly/FeedlyRefreshAccessTokenOperationTests.swift @@ -9,7 +9,6 @@ import XCTest @testable import Account import RSWeb -import RSCore import Secrets class FeedlyRefreshAccessTokenOperationTests: XCTestCase { diff --git a/Account/Tests/AccountTests/Feedly/FeedlySendArticleStatusesOperationTests.swift b/Account/Tests/AccountTests/Feedly/FeedlySendArticleStatusesOperationTests.swift index 61874468a..543ae07f7 100644 --- a/Account/Tests/AccountTests/Feedly/FeedlySendArticleStatusesOperationTests.swift +++ b/Account/Tests/AccountTests/Feedly/FeedlySendArticleStatusesOperationTests.swift @@ -10,7 +10,6 @@ import XCTest @testable import Account import SyncDatabase import Articles -import RSCore class FeedlySendArticleStatusesOperationTests: XCTestCase { diff --git a/Account/Tests/AccountTests/Feedly/FeedlySyncStreamContentsOperationTests.swift b/Account/Tests/AccountTests/Feedly/FeedlySyncStreamContentsOperationTests.swift index 11819e05f..337d968b2 100644 --- a/Account/Tests/AccountTests/Feedly/FeedlySyncStreamContentsOperationTests.swift +++ b/Account/Tests/AccountTests/Feedly/FeedlySyncStreamContentsOperationTests.swift @@ -8,7 +8,6 @@ import XCTest @testable import Account -import RSCore class FeedlySyncStreamContentsOperationTests: XCTestCase { diff --git a/AppKitExtras/.gitignore b/AppKitExtras/.gitignore new file mode 100644 index 000000000..0023a5340 --- /dev/null +++ b/AppKitExtras/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/AppKitExtras/Package.swift b/AppKitExtras/Package.swift new file mode 100644 index 000000000..2bd2d20b8 --- /dev/null +++ b/AppKitExtras/Package.swift @@ -0,0 +1,30 @@ +// swift-tools-version: 5.10 + +import PackageDescription + +let package = Package( + name: "AppKitExtras", + platforms: [.macOS(.v14), .iOS(.v17)], + products: [ + .library( + name: "AppKitExtras", + targets: ["AppKitExtras"]), + ], + dependencies: [ + .package(path: "../FoundationExtras") + ], + targets: [ + .target( + name: "AppKitExtras", + dependencies: [ + "FoundationExtras", + ], + swiftSettings: [ + .enableExperimentalFeature("StrictConcurrency") + ] + ), + .testTarget( + name: "AppKitExtrasTests", + dependencies: ["AppKitExtras"]), + ] +) diff --git a/AppKitExtras/Sources/AppKitExtras/AppKitExtras.swift b/AppKitExtras/Sources/AppKitExtras/AppKitExtras.swift new file mode 100644 index 000000000..08b22b80f --- /dev/null +++ b/AppKitExtras/Sources/AppKitExtras/AppKitExtras.swift @@ -0,0 +1,2 @@ +// The Swift Programming Language +// https://docs.swift.org/swift-book diff --git a/AppKitExtras/Sources/AppKitExtras/FourCharCode.swift b/AppKitExtras/Sources/AppKitExtras/FourCharCode.swift new file mode 100644 index 000000000..9f3c0ebf4 --- /dev/null +++ b/AppKitExtras/Sources/AppKitExtras/FourCharCode.swift @@ -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) + } +} + diff --git a/AppKitExtras/Sources/AppKitExtras/Keyboard.swift b/AppKitExtras/Sources/AppKitExtras/Keyboard.swift new file mode 100644 index 000000000..7e10e38bd --- /dev/null +++ b/AppKitExtras/Sources/AppKitExtras/Keyboard.swift @@ -0,0 +1,147 @@ +// +// Keyboard.swift +// RSCore +// +// Created by Brent Simmons on 12/19/17. +// Copyright © 2017 Ranchero Software, LLC. All rights reserved. +// +#if os(macOS) +import AppKit + +// To get, for instance, the keyboard integer value for "\r": "\r".keyboardIntegerValue (returns 13) + +public struct KeyboardConstant { + + public static let lineFeedKey = "\n".keyboardIntegerValue + public static let returnKey = "\r".keyboardIntegerValue + public static let spaceKey = " ".keyboardIntegerValue +} + +public extension String { + + var keyboardIntegerValue: Int? { + if isEmpty { + return nil + } + let utf16String = utf16 + let startIndex = utf16String.startIndex + if startIndex == utf16String.endIndex { + return nil + } + return Int(utf16String[startIndex]) + } +} + +@MainActor public struct KeyboardShortcut: Hashable { + + public let key: KeyboardKey + public let actionString: String + + public init?(dictionary: [String: Any]) { + + guard let key = KeyboardKey(dictionary: dictionary) else { + return nil + } + guard let actionString = dictionary["action"] as? String else { + return nil + } + + self.key = key + self.actionString = actionString + } + + public func perform(with view: NSView) { + + let action = NSSelectorFromString(actionString) + NSApplication.shared.sendAction(action, to: nil, from: view) + } + + public static func findMatchingShortcut(in shortcuts: Set, key: KeyboardKey) -> KeyboardShortcut? { + + for shortcut in shortcuts { + if shortcut.key == key { + return shortcut + } + } + return nil + } +} + +public struct KeyboardKey: Hashable, Sendable { + + public let shiftKeyDown: Bool + public let optionKeyDown: Bool + public let commandKeyDown: Bool + public let controlKeyDown: Bool + public let integerValue: Int // unmodified character as Int + + init(integerValue: Int, shiftKeyDown: Bool, optionKeyDown: Bool, commandKeyDown: Bool, controlKeyDown: Bool) { + + self.integerValue = integerValue + + self.shiftKeyDown = shiftKeyDown + self.optionKeyDown = optionKeyDown + self.commandKeyDown = commandKeyDown + self.controlKeyDown = controlKeyDown + } + + static let deleteKeyCode = 127 + + public init(with event: NSEvent) { + + let flags = event.modifierFlags + let shiftKeyDown = flags.contains(.shift) + let optionKeyDown = flags.contains(.option) + let commandKeyDown = flags.contains(.command) + let controlKeyDown = flags.contains(.control) + + let integerValue = event.charactersIgnoringModifiers?.keyboardIntegerValue ?? 0 + + self.init(integerValue: integerValue, shiftKeyDown: shiftKeyDown, optionKeyDown: optionKeyDown, commandKeyDown: commandKeyDown, controlKeyDown: controlKeyDown) + } + + public init?(dictionary: [String: Any]) { + + guard let s = dictionary["key"] as? String else { + return nil + } + + var integerValue = 0 + + switch(s) { + case "[space]": + integerValue = " ".keyboardIntegerValue! + case "[uparrow]": + integerValue = NSUpArrowFunctionKey + case "[downarrow]": + integerValue = NSDownArrowFunctionKey + case "[leftarrow]": + integerValue = NSLeftArrowFunctionKey + case "[rightarrow]": + integerValue = NSRightArrowFunctionKey + case "[return]": + integerValue = NSCarriageReturnCharacter + case "[enter]": + integerValue = NSEnterCharacter + case "[delete]": + integerValue = KeyboardKey.deleteKeyCode + case "[deletefunction]": + integerValue = NSDeleteFunctionKey + case "[tab]": + integerValue = NSTabCharacter + default: + guard let unwrappedIntegerValue = s.keyboardIntegerValue else { + return nil + } + integerValue = unwrappedIntegerValue + } + + let shiftKeyDown = dictionary["shiftModifier"] as? Bool ?? false + let optionKeyDown = dictionary["optionModifier"] as? Bool ?? false + let commandKeyDown = dictionary["commandModifier"] as? Bool ?? false + let controlKeyDown = dictionary["controlModifier"] as? Bool ?? false + + self.init(integerValue: integerValue, shiftKeyDown: shiftKeyDown, optionKeyDown: optionKeyDown, commandKeyDown: commandKeyDown, controlKeyDown: controlKeyDown) + } +} +#endif diff --git a/AppKitExtras/Sources/AppKitExtras/KeyboardDelegateProtocol.swift b/AppKitExtras/Sources/AppKitExtras/KeyboardDelegateProtocol.swift new file mode 100644 index 000000000..90c260ff1 --- /dev/null +++ b/AppKitExtras/Sources/AppKitExtras/KeyboardDelegateProtocol.swift @@ -0,0 +1,18 @@ +// +// KeyboardDelegateProtocol.swift +// NetNewsWire +// +// Created by Brent Simmons on 10/11/16. +// Copyright © 2016 Ranchero Software, LLC. All rights reserved. +// +#if os(macOS) +import AppKit + +//let keypadEnter: unichar = 3 + +@objc public protocol KeyboardDelegate: AnyObject { + + // Return true if handled. + @MainActor func keydown(_: NSEvent, in view: NSView) -> Bool +} +#endif diff --git a/AppKitExtras/Sources/AppKitExtras/NSAppearance+RSCore.swift b/AppKitExtras/Sources/AppKitExtras/NSAppearance+RSCore.swift new file mode 100644 index 000000000..fd2ed73de --- /dev/null +++ b/AppKitExtras/Sources/AppKitExtras/NSAppearance+RSCore.swift @@ -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 diff --git a/AppKitExtras/Sources/AppKitExtras/NSAppleEventDescriptor+RSCore.swift b/AppKitExtras/Sources/AppKitExtras/NSAppleEventDescriptor+RSCore.swift new file mode 100644 index 000000000..fcfcbcf94 --- /dev/null +++ b/AppKitExtras/Sources/AppKitExtras/NSAppleEventDescriptor+RSCore.swift @@ -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 diff --git a/AppKitExtras/Sources/AppKitExtras/NSImage+RSCore.swift b/AppKitExtras/Sources/AppKitExtras/NSImage+RSCore.swift new file mode 100644 index 000000000..739829ab4 --- /dev/null +++ b/AppKitExtras/Sources/AppKitExtras/NSImage+RSCore.swift @@ -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 diff --git a/AppKitExtras/Sources/AppKitExtras/NSMenu+Extensions.swift b/AppKitExtras/Sources/AppKitExtras/NSMenu+Extensions.swift new file mode 100644 index 000000000..115e71b63 --- /dev/null +++ b/AppKitExtras/Sources/AppKitExtras/NSMenu+Extensions.swift @@ -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 diff --git a/AppKitExtras/Sources/AppKitExtras/NSOutlineView+RSCore.swift b/AppKitExtras/Sources/AppKitExtras/NSOutlineView+RSCore.swift new file mode 100755 index 000000000..a16152fba --- /dev/null +++ b/AppKitExtras/Sources/AppKitExtras/NSOutlineView+RSCore.swift @@ -0,0 +1,183 @@ +// +// NSOutlineView+Extensions.swift +// RSCore +// +// Created by Brent Simmons on 9/6/16. +// Copyright © 2016 Ranchero Software, LLC. All rights reserved. +// +#if os(macOS) +import AppKit + +public extension NSOutlineView { + + var selectedItems: [AnyObject] { + if selectionIsEmpty { + return [AnyObject]() + } + + return selectedRowIndexes.compactMap { (oneIndex) -> AnyObject? in + return item(atRow: oneIndex) as AnyObject + } + } + + var firstSelectedRow: Int? { + + if selectionIsEmpty { + return nil + } + return selectedRowIndexes.first + } + + var lastSelectedRow: Int? { + + if selectionIsEmpty { + return nil + } + return selectedRowIndexes.last + } + + @IBAction func selectPreviousRow(_ sender: Any?) { + + guard var row = firstSelectedRow else { + return + } + + if row < 1 { + return + } + while true { + row -= 1 + if row < 0 { + return + } + if canSelect(row) { + selectRowAndScrollToVisible(row) + return + } + } + } + + @IBAction func selectNextRow(_ sender: Any?) { + + // If no selectedRow, end up at first selectable row. + var row = lastSelectedRow ?? -1 + + while true { + row += 1 + if let _ = item(atRow: row) { + if canSelect(row) { + selectRowAndScrollToVisible(row) + return + } + } + else { + return // if there are no more items, we’re out of rows + } + } + } + + @IBAction func collapseSelectedRows(_ sender: Any?) { + + for item in selectedItems { + if isExpandable(item) && isItemExpanded(item) { + animator().collapseItem(item) + } + } + } + + @IBAction func expandSelectedRows(_ sender: Any?) { + + for item in selectedItems { + if isExpandable(item) && !isItemExpanded(item) { + animator().expandItem(item) + } + } + } + + @IBAction func expandAll(_ sender: Any?) { + + expandAllChildren(of: nil) + } + + @IBAction func collapseAllExceptForGroupItems(_ sender: Any?) { + + collapseAllChildren(of: nil, exceptForGroupItems: true) + } + + func expandAllChildren(of item: Any?) { + + guard let childItems = children(of: item) else { + return + } + + for child in childItems { + if !isItemExpanded(child) && isExpandable(child) { + animator().expandItem(child, expandChildren: true) + } + expandAllChildren(of: child) + } + } + + func collapseAllChildren(of item: Any?, exceptForGroupItems: Bool) { + + guard let childItems = children(of: item) else { + return + } + + for child in childItems { + collapseAllChildren(of: child, exceptForGroupItems: exceptForGroupItems) + if exceptForGroupItems && isGroupItem(child) { + continue + } + if isItemExpanded(child) { + animator().collapseItem(child, collapseChildren: true) + } + } + } + + func children(of item: Any?) -> [Any]? { + + var children = [Any]() + for indexOfItem in 0.. 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 + diff --git a/AppKitExtras/Sources/AppKitExtras/NSPasteboard+RSCore.swift b/AppKitExtras/Sources/AppKitExtras/NSPasteboard+RSCore.swift new file mode 100644 index 000000000..b22696b29 --- /dev/null +++ b/AppKitExtras/Sources/AppKitExtras/NSPasteboard+RSCore.swift @@ -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 diff --git a/AppKitExtras/Sources/AppKitExtras/NSResponder-Extensions.swift b/AppKitExtras/Sources/AppKitExtras/NSResponder-Extensions.swift new file mode 100755 index 000000000..80e4dcc6f --- /dev/null +++ b/AppKitExtras/Sources/AppKitExtras/NSResponder-Extensions.swift @@ -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 diff --git a/AppKitExtras/Sources/AppKitExtras/NSTableView+RSCore.swift b/AppKitExtras/Sources/AppKitExtras/NSTableView+RSCore.swift new file mode 100755 index 000000000..bfffba77b --- /dev/null +++ b/AppKitExtras/Sources/AppKitExtras/NSTableView+RSCore.swift @@ -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 diff --git a/AppKitExtras/Sources/AppKitExtras/NSToolbar+RSCore.swift b/AppKitExtras/Sources/AppKitExtras/NSToolbar+RSCore.swift new file mode 100644 index 000000000..1396e59cb --- /dev/null +++ b/AppKitExtras/Sources/AppKitExtras/NSToolbar+RSCore.swift @@ -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 diff --git a/AppKitExtras/Sources/AppKitExtras/NSView+RSCore.swift b/AppKitExtras/Sources/AppKitExtras/NSView+RSCore.swift new file mode 100644 index 000000000..5ea65ccd6 --- /dev/null +++ b/AppKitExtras/Sources/AppKitExtras/NSView+RSCore.swift @@ -0,0 +1,99 @@ +// +// NSView+Extensions.swift +// RSCore +// +// Created by Maurice Parker on 11/12/19. +// Copyright © 2019 Ranchero Software, LLC. All rights reserved. +// +#if os(macOS) +import AppKit +import FoundationExtras + +extension NSView { + + public func asImage() -> NSImage { + let rep = bitmapImageRepForCachingDisplay(in: bounds)! + cacheDisplay(in: bounds, to: rep) + + let img = NSImage(size: bounds.size) + img.addRepresentation(rep) + return img + } + +} + +public extension NSView { + + /// Keeps a subview at same size as receiver. + /// + /// - Parameter subview: The subview to constrain. Must be a descendant of `self`. + func addFullSizeConstraints(forSubview subview: NSView) { + NSLayoutConstraint.activate([ + subview.leadingAnchor.constraint(equalTo: leadingAnchor), + subview.trailingAnchor.constraint(equalTo: trailingAnchor), + subview.topAnchor.constraint(equalTo: topAnchor), + subview.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } + + /// Sets the view's frame if it's different from the current frame. + /// + /// - Parameter rect: The new frame. + func setFrame(ifNotEqualTo rect: NSRect) { + if self.frame != rect { + self.frame = rect + } + } + + /// A boolean indicating whether the view is or is descended from the first responder. + var isOrIsDescendedFromFirstResponder: Bool { + guard let firstResponder = self.window?.firstResponder as? NSView else { + return false + } + + return self.isDescendant(of: firstResponder) + } + + /// A boolean indicating whether the view should draw as active. + var shouldDrawAsActive: Bool { + return (self.window?.isMainWindow ?? false) && self.isOrIsDescendedFromFirstResponder + } + + /// Vertically centers a rectangle in the view's bounds. + /// - Parameter rect: The rectangle to center. + /// - Returns: A new rectangle, vertically centered in the view's bounds. + func verticallyCenteredRect(_ rect: NSRect) -> NSRect { + return rect.centeredVertically(in: self.bounds) + } + + /// Horizontally centers a rectangle in the view's bounds. + /// - Parameter rect: The rectangle to center. + /// - Returns: A new rectangle, horizontally centered in the view's bounds. + func horizontallyCenteredRect(_ rect: NSRect) -> NSRect { + return rect.centeredHorizontally(in: self.bounds) + } + + /// Centers a rectangle in the view's bounds. + /// - Parameter rect: The rectangle to center. + /// - Returns: A new rectangle, both horizontally and vertically centered in the view's bounds. + func centeredRect(_ rect: NSRect) -> NSRect { + return rect.centered(in: self.bounds) + } + + /// The view's enclosing table view, if any. + var enclosingTableView: NSTableView? { + var nomad = self.superview + + while nomad != nil { + if let nomad = nomad as? NSTableView { + return nomad + } + + nomad = nomad!.superview + } + + return nil + } + +} +#endif diff --git a/AppKitExtras/Sources/AppKitExtras/NSWindow-Extensions.swift b/AppKitExtras/Sources/AppKitExtras/NSWindow-Extensions.swift new file mode 100755 index 000000000..9e3c63a06 --- /dev/null +++ b/AppKitExtras/Sources/AppKitExtras/NSWindow-Extensions.swift @@ -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 diff --git a/AppKitExtras/Sources/AppKitExtras/NSWindowController+RSCore.swift b/AppKitExtras/Sources/AppKitExtras/NSWindowController+RSCore.swift new file mode 100644 index 000000000..901054254 --- /dev/null +++ b/AppKitExtras/Sources/AppKitExtras/NSWindowController+RSCore.swift @@ -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 diff --git a/AppKitExtras/Sources/AppKitExtras/NSWorkspace+RSCore.swift b/AppKitExtras/Sources/AppKitExtras/NSWorkspace+RSCore.swift new file mode 100644 index 000000000..4c594d6de --- /dev/null +++ b/AppKitExtras/Sources/AppKitExtras/NSWorkspace+RSCore.swift @@ -0,0 +1,77 @@ +// +// NSWorkspace+RSCore.swift +// RSCore +// +// Created by Brent Simmons on 9/3/18. +// Copyright © 2018 Ranchero Software, LLC. All rights reserved. +// +#if os(macOS) +import AppKit + +public extension NSWorkspace { + + /// Get the file path to the default app for a given scheme such as "feed:" + func defaultApp(forURLScheme scheme: String) -> String? { + guard let url = URL(string: scheme) else { + return nil + } + return urlForApplication(toOpen: url)?.path + } + + /// Get the bundle ID for the default app for a given scheme such as "feed:" + func defaultAppBundleID(forURLScheme scheme: String) -> String? { + guard let path = defaultApp(forURLScheme: scheme) else { + return nil + } + return bundleID(for: path) + } + + /// Set the file path that should be the default app for a given scheme such as "feed:" + /// It really just uses the bundle ID for the app, so there’s no guarantee that the actual path will be respected later. + /// (In other words, you can’t specify one app over another if they have the same bundle ID.) + @discardableResult + func setDefaultApp(forURLScheme scheme: String, to path: String) -> Bool { + guard let bundleID = bundleID(for: path) else { + return false + } + return setDefaultAppBundleID(forURLScheme: scheme, to: bundleID) + } + + /// Set the bundle ID for the app that should be default for a given scheme such as "feed:" + @discardableResult + func setDefaultAppBundleID(forURLScheme scheme: String, to bundleID: String) -> Bool { + return LSSetDefaultHandlerForURLScheme(scheme as CFString, bundleID as CFString) == noErr + } + + /// Get the file paths to apps that can handle a given scheme such as "feed:" + func apps(forURLScheme scheme: String) -> Set { + guard let url = URL(string: scheme) else { + return Set() + } + guard let appURLs = LSCopyApplicationURLsForURL(url as CFURL, .viewer)?.takeRetainedValue() as [AnyObject]? else { + return Set() + } + 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 { + 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 diff --git a/AppKitExtras/Sources/AppKitExtras/PasteboardWriterOwner.swift b/AppKitExtras/Sources/AppKitExtras/PasteboardWriterOwner.swift new file mode 100644 index 000000000..de4f2c3f9 --- /dev/null +++ b/AppKitExtras/Sources/AppKitExtras/PasteboardWriterOwner.swift @@ -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 diff --git a/AppKitExtras/Sources/AppKitExtras/RSDarkModeAdaptingToolbarButton.swift b/AppKitExtras/Sources/AppKitExtras/RSDarkModeAdaptingToolbarButton.swift new file mode 100644 index 000000000..be10f3bea --- /dev/null +++ b/AppKitExtras/Sources/AppKitExtras/RSDarkModeAdaptingToolbarButton.swift @@ -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 diff --git a/AppKitExtras/Sources/AppKitExtras/RSToolbarItem.swift b/AppKitExtras/Sources/AppKitExtras/RSToolbarItem.swift new file mode 100755 index 000000000..0a05bcd0e --- /dev/null +++ b/AppKitExtras/Sources/AppKitExtras/RSToolbarItem.swift @@ -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 diff --git a/AppKitExtras/Sources/AppKitExtras/URLPasteboardWriter.swift b/AppKitExtras/Sources/AppKitExtras/URLPasteboardWriter.swift new file mode 100644 index 000000000..cdba5483f --- /dev/null +++ b/AppKitExtras/Sources/AppKitExtras/URLPasteboardWriter.swift @@ -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 diff --git a/AppKitExtras/Sources/AppKitExtras/UserApp.swift b/AppKitExtras/Sources/AppKitExtras/UserApp.swift new file mode 100644 index 000000000..974f82875 --- /dev/null +++ b/AppKitExtras/Sources/AppKitExtras/UserApp.swift @@ -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 + diff --git a/AppKitExtras/Tests/AppKitExtrasTests/AppKitExtrasTests.swift b/AppKitExtras/Tests/AppKitExtrasTests/AppKitExtrasTests.swift new file mode 100644 index 000000000..2fa72fb5e --- /dev/null +++ b/AppKitExtras/Tests/AppKitExtrasTests/AppKitExtrasTests.swift @@ -0,0 +1,12 @@ +import XCTest +@testable import AppKitExtras + +final class AppKitExtrasTests: XCTestCase { + func testExample() throws { + // XCTest Documentation + // https://developer.apple.com/documentation/xctest + + // Defining Test Cases and Test Methods + // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods + } +} diff --git a/Articles/Package.swift b/Articles/Package.swift index 2c765979b..1b0d06432 100644 --- a/Articles/Package.swift +++ b/Articles/Package.swift @@ -11,13 +11,13 @@ let package = Package( targets: ["Articles"]), ], dependencies: [ - .package(url: "https://github.com/Ranchero-Software/RSCore.git", .upToNextMinor(from: "1.0.0")), + .package(path: "../FoundationExtras") ], targets: [ .target( name: "Articles", dependencies: [ - "RSCore" + "FoundationExtras" ], swiftSettings: [ .enableExperimentalFeature("StrictConcurrency") diff --git a/Articles/Sources/Articles/DatabaseID.swift b/Articles/Sources/Articles/DatabaseID.swift index dff70912c..d036c4ed2 100644 --- a/Articles/Sources/Articles/DatabaseID.swift +++ b/Articles/Sources/Articles/DatabaseID.swift @@ -7,7 +7,7 @@ // import Foundation -import RSCore +import FoundationExtras class DatabaseIDCache: @unchecked Sendable { diff --git a/ArticlesDatabase/Package.swift b/ArticlesDatabase/Package.swift index c7dac9c3e..fcfd0d681 100644 --- a/ArticlesDatabase/Package.swift +++ b/ArticlesDatabase/Package.swift @@ -12,21 +12,21 @@ let package = Package( targets: ["ArticlesDatabase"]), ], dependencies: [ - .package(url: "https://github.com/Ranchero-Software/RSCore.git", .upToNextMinor(from: "1.0.0")), .package(url: "https://github.com/Ranchero-Software/RSParser.git", .upToNextMajor(from: "2.0.2")), .package(path: "../Articles"), .package(path: "../Database"), .package(path: "../FMDB"), + .package(path: "../FoundationExtras"), ], targets: [ .target( name: "ArticlesDatabase", dependencies: [ - "RSCore", "Database", "RSParser", "Articles", "FMDB", + "FoundationExtras" ]), ] ) diff --git a/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesDatabaseCompatibility.swift b/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesDatabaseCompatibility.swift index 08fe0452a..a4901f0ec 100644 --- a/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesDatabaseCompatibility.swift +++ b/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesDatabaseCompatibility.swift @@ -7,7 +7,6 @@ // import Foundation -import RSCore import Database import RSParser import Articles diff --git a/ArticlesDatabase/Sources/ArticlesDatabase/SearchTable.swift b/ArticlesDatabase/Sources/ArticlesDatabase/SearchTable.swift index 1805c1a1c..f79e6fbec 100644 --- a/ArticlesDatabase/Sources/ArticlesDatabase/SearchTable.swift +++ b/ArticlesDatabase/Sources/ArticlesDatabase/SearchTable.swift @@ -7,7 +7,6 @@ // import Foundation -import RSCore import Database import Articles import RSParser diff --git a/ArticlesDatabase/Sources/ArticlesDatabase/StatusesTable.swift b/ArticlesDatabase/Sources/ArticlesDatabase/StatusesTable.swift index 069a20c7a..bad388694 100644 --- a/ArticlesDatabase/Sources/ArticlesDatabase/StatusesTable.swift +++ b/ArticlesDatabase/Sources/ArticlesDatabase/StatusesTable.swift @@ -7,7 +7,6 @@ // import Foundation -import RSCore import Database import Articles import FMDB diff --git a/CloudKitExtras/.gitignore b/CloudKitExtras/.gitignore new file mode 100644 index 000000000..0023a5340 --- /dev/null +++ b/CloudKitExtras/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/CloudKitExtras/Package.swift b/CloudKitExtras/Package.swift new file mode 100644 index 000000000..1883e89f3 --- /dev/null +++ b/CloudKitExtras/Package.swift @@ -0,0 +1,30 @@ +// swift-tools-version: 5.10 + +import PackageDescription + +let package = Package( + name: "CloudKitExtras", + platforms: [.macOS(.v14), .iOS(.v17)], + products: [ + .library( + name: "CloudKitExtras", + targets: ["CloudKitExtras"]), + ], + dependencies: [ + .package(path: "../FoundationExtras") + ], + targets: [ + .target( + name: "CloudKitExtras", + dependencies: [ + "FoundationExtras", + ], + swiftSettings: [ + .enableExperimentalFeature("StrictConcurrency") + ] + ), + .testTarget( + name: "CloudKitExtrasTests", + dependencies: ["CloudKitExtras"]), + ] +) diff --git a/CloudKitExtras/Sources/CloudKitExtras/CloudKitError.swift b/CloudKitExtras/Sources/CloudKitExtras/CloudKitError.swift new file mode 100644 index 000000000..2d0e1ded7 --- /dev/null +++ b/CloudKitExtras/Sources/CloudKitExtras/CloudKitError.swift @@ -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") + } + } + +} diff --git a/CloudKitExtras/Sources/CloudKitExtras/CloudKitZone.swift b/CloudKitExtras/Sources/CloudKitExtras/CloudKitZone.swift new file mode 100644 index 000000000..83b4361dc --- /dev/null +++ b/CloudKitExtras/Sources/CloudKitExtras/CloudKitZone.swift @@ -0,0 +1,816 @@ +// +// CloudKitZone.swift +// RSCore +// +// Created by Maurice Parker on 3/21/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import CloudKit +import os.log +import FoundationExtras + +public enum CloudKitZoneError: LocalizedError { + case userDeletedZone + case corruptAccount + case unknown + + public var errorDescription: String? { + switch self { + case .userDeletedZone: + return NSLocalizedString("The iCloud data was deleted. Please remove the application iCloud account and add it again to continue using the application's iCloud support.", comment: "User deleted zone.") + case .corruptAccount: + return NSLocalizedString("There is an unrecoverable problem with your application iCloud account. Please make sure you have iCloud and iCloud Drive enabled in System Preferences. Then remove the application iCloud account and add it again.", comment: "Corrupt account.") + default: + return NSLocalizedString("An unexpected CloudKit error occurred.", comment: "An unexpected CloudKit error occurred.") + } + } +} + +public protocol CloudKitZoneDelegate: class { + func cloudKitDidModify(changed: [CKRecord], deleted: [CloudKitRecordKey], completion: @escaping (Result) -> Void); +} + +public typealias CloudKitRecordKey = (recordType: CKRecord.RecordType, recordID: CKRecord.ID) + +public protocol CloudKitZone: class { + + static var qualityOfService: QualityOfService { get } + + var zoneID: CKRecordZone.ID { get } + + var log: OSLog { get } + + var container: CKContainer? { get } + var database: CKDatabase? { get } + var delegate: CloudKitZoneDelegate? { get set } + + /// Reset the change token used to determine what point in time we are doing changes fetches + func resetChangeToken() + + /// Generates a new CKRecord.ID using a UUID for the record's name + func generateRecordID() -> CKRecord.ID + + /// Subscribe to changes at a zone level + func subscribeToZoneChanges() + + /// Process a remove notification + func receiveRemoteNotification(userInfo: [AnyHashable : Any], completion: @escaping () -> Void) + +} + +public extension CloudKitZone { + + // My observation has been that QoS is treated differently for CloudKit operations on macOS vs iOS. + // .userInitiated is too aggressive on iOS and can lead the UI slowing down and appearing to block. + // .default (or lower) on macOS will sometimes hang for extended periods of time and appear to hang. + static var qualityOfService: QualityOfService { + #if os(macOS) || targetEnvironment(macCatalyst) + return .userInitiated + #else + return .default + #endif + } + + var oldChangeTokenKey: String { + return "cloudkit.server.token.\(zoneID.zoneName)" + } + + var changeTokenKey: String { + return "cloudkit.server.token.\(zoneID.zoneName).\(zoneID.ownerName)" + } + + var changeToken: CKServerChangeToken? { + get { + guard let tokenData = UserDefaults.standard.object(forKey: changeTokenKey) as? Data else { return nil } + return try? NSKeyedUnarchiver.unarchivedObject(ofClass: CKServerChangeToken.self, from: tokenData) + } + set { + guard let token = newValue, let data = try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: false) else { + UserDefaults.standard.removeObject(forKey: changeTokenKey) + return + } + UserDefaults.standard.set(data, forKey: changeTokenKey) + } + } + + /// Moves the change token to the new key name. This can eventually be removed. + func migrateChangeToken() { + if let tokenData = UserDefaults.standard.object(forKey: oldChangeTokenKey) as? Data, + let oldChangeToken = try? NSKeyedUnarchiver.unarchivedObject(ofClass: CKServerChangeToken.self, from: tokenData) { + changeToken = oldChangeToken + UserDefaults.standard.removeObject(forKey: oldChangeTokenKey) + } + } + + /// Reset the change token used to determine what point in time we are doing changes fetches + func resetChangeToken() { + changeToken = nil + } + + func generateRecordID() -> CKRecord.ID { + return CKRecord.ID(recordName: UUID().uuidString, zoneID: zoneID) + } + + func retryIfPossible(after: Double, block: @escaping () -> ()) { + let delayTime = DispatchTime.now() + after + DispatchQueue.main.asyncAfter(deadline: delayTime, execute: { + block() + }) + } + + func receiveRemoteNotification(userInfo: [AnyHashable : Any], completion: @escaping () -> Void) { + let note = CKRecordZoneNotification(fromRemoteNotificationDictionary: userInfo) + guard note?.recordZoneID?.zoneName == zoneID.zoneName else { + completion() + return + } + + fetchChangesInZone() { result in + if case .failure(let error) = result { + os_log(.error, log: self.log, "%@ zone remote notification fetch error: %@", self.zoneID.zoneName, error.localizedDescription) + } + completion() + } + } + + /// Retrieves the zone record for this zone only. If the record isn't found it will be created. + func fetchZoneRecord(completion: @escaping (Result) -> 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) { + guard let database = database else { + completion(.failure(CloudKitZoneError.unknown)) + return + } + + database.save(CKRecordZone(zoneID: zoneID)) { (recordZone, error) in + if let error = error { + DispatchQueue.main.async { + completion(.failure(CloudKitError(error))) + } + } else { + DispatchQueue.main.async { + completion(.success(())) + } + } + } + } + + /// Subscribes to zone changes + func subscribeToZoneChanges() { + let subscription = CKRecordZoneSubscription(zoneID: zoneID) + + let info = CKSubscription.NotificationInfo() + info.shouldSendContentAvailable = true + subscription.notificationInfo = info + + save(subscription) { result in + if case .failure(let error) = result { + os_log(.error, log: self.log, "%@ zone subscribe to changes error: %@", self.zoneID.zoneName, error.localizedDescription) + } + } + } + + /// Issue a CKQuery and return the resulting CKRecords. + func query(_ ckQuery: CKQuery, desiredKeys: [String]? = nil, completion: @escaping (Result<[CKRecord], Error>) -> Void) { + var records = [CKRecord]() + + let op = CKQueryOperation(query: ckQuery) + op.qualityOfService = Self.qualityOfService + + if let desiredKeys = desiredKeys { + op.desiredKeys = desiredKeys + } + + op.recordFetchedBlock = { record in + records.append(record) + } + + op.queryCompletionBlock = { [weak self] (cursor, error) in + guard let self = self else { + completion(.failure(CloudKitZoneError.unknown)) + return + } + + switch CloudKitZoneResult.resolve(error) { + case .success: + DispatchQueue.main.async { + if let cursor = cursor { + self.query(cursor: cursor, desiredKeys: desiredKeys, carriedRecords: records, completion: completion) + } else { + completion(.success(records)) + } + } + case .zoneNotFound: + self.createZoneRecord() { result in + switch result { + case .success: + self.query(ckQuery, desiredKeys: desiredKeys, completion: completion) + case .failure(let error): + DispatchQueue.main.async { + completion(.failure(error)) + } + } + } + case .retry(let timeToWait): + os_log(.error, log: self.log, "%@ zone query retry in %f seconds.", self.zoneID.zoneName, timeToWait) + self.retryIfPossible(after: timeToWait) { + self.query(ckQuery, desiredKeys: desiredKeys, completion: completion) + } + case .userDeletedZone: + DispatchQueue.main.async { + completion(.failure(CloudKitZoneError.userDeletedZone)) + } + default: + DispatchQueue.main.async { + completion(.failure(CloudKitError(error!))) + } + } + } + + database?.add(op) + } + + /// Query CKRecords using a CKQuery Cursor + func query(cursor: CKQueryOperation.Cursor, desiredKeys: [String]? = nil, carriedRecords: [CKRecord], completion: @escaping (Result<[CKRecord], Error>) -> Void) { + var records = carriedRecords + + let op = CKQueryOperation(cursor: cursor) + op.qualityOfService = Self.qualityOfService + + if let desiredKeys = desiredKeys { + op.desiredKeys = desiredKeys + } + + op.recordFetchedBlock = { record in + records.append(record) + } + + op.queryCompletionBlock = { [weak self] (newCursor, error) in + guard let self = self else { + completion(.failure(CloudKitZoneError.unknown)) + return + } + + switch CloudKitZoneResult.resolve(error) { + case .success: + DispatchQueue.main.async { + if let newCursor = newCursor { + self.query(cursor: newCursor, desiredKeys: desiredKeys, carriedRecords: records, completion: completion) + } else { + completion(.success(records)) + } + } + case .zoneNotFound: + self.createZoneRecord() { result in + switch result { + case .success: + self.query(cursor: cursor, desiredKeys: desiredKeys, carriedRecords: records, completion: completion) + case .failure(let error): + DispatchQueue.main.async { + completion(.failure(error)) + } + } + } + case .retry(let timeToWait): + os_log(.error, log: self.log, "%@ zone query retry in %f seconds.", self.zoneID.zoneName, timeToWait) + self.retryIfPossible(after: timeToWait) { + self.query(cursor: cursor, desiredKeys: desiredKeys, carriedRecords: records, completion: completion) + } + case .userDeletedZone: + DispatchQueue.main.async { + completion(.failure(CloudKitZoneError.userDeletedZone)) + } + default: + DispatchQueue.main.async { + completion(.failure(CloudKitError(error!))) + } + } + } + + database?.add(op) + } + + + /// Fetch a CKRecord by using its externalID + func fetch(externalID: String?, completion: @escaping (Result) -> 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) { + modify(recordsToSave: [record], recordIDsToDelete: [], completion: completion) + } + + /// Save the CKRecords + func save(_ records: [CKRecord], completion: @escaping (Result) -> 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) { + 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) -> 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) { + + 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) { + + 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) { + modify(recordsToSave: [], recordIDsToDelete: [recordID], completion: completion) + } + + /// Delete CKRecords + func delete(recordIDs: [CKRecord.ID], completion: @escaping (Result) -> Void) { + modify(recordsToSave: [], recordIDsToDelete: recordIDs, completion: completion) + } + + /// Delete a CKRecord using its externalID + func delete(externalID: String?, completion: @escaping (Result) -> 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) { + 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) { + 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) { + 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) { + + 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) + } + +} diff --git a/CloudKitExtras/Sources/CloudKitExtras/CloudKitZoneResult.swift b/CloudKitExtras/Sources/CloudKitExtras/CloudKitZoneResult.swift new file mode 100644 index 000000000..30efa7b5a --- /dev/null +++ b/CloudKitExtras/Sources/CloudKitExtras/CloudKitZoneResult.swift @@ -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 + } + +} diff --git a/CloudKitExtras/Tests/CloudKitExtrasTests/CloudKitExtrasTests.swift b/CloudKitExtras/Tests/CloudKitExtrasTests/CloudKitExtrasTests.swift new file mode 100644 index 000000000..7cd08150f --- /dev/null +++ b/CloudKitExtras/Tests/CloudKitExtrasTests/CloudKitExtrasTests.swift @@ -0,0 +1,12 @@ +import XCTest +@testable import CloudKitExtras + +final class CloudKitExtrasTests: XCTestCase { + func testExample() throws { + // XCTest Documentation + // https://developer.apple.com/documentation/xctest + + // Defining Test Cases and Test Methods + // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods + } +} diff --git a/Core/.gitignore b/Core/.gitignore new file mode 100644 index 000000000..0023a5340 --- /dev/null +++ b/Core/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Core/Package.swift b/Core/Package.swift new file mode 100644 index 000000000..e53319224 --- /dev/null +++ b/Core/Package.swift @@ -0,0 +1,39 @@ +// swift-tools-version: 5.10 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "Core", + platforms: [.macOS(.v14), .iOS(.v17)], + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library(name: "Core", targets: ["Core"]), + .library(name: "CoreResources", type: .static, targets: ["CoreResources"]) + ], + dependencies: [ + .package(path: "../AppKitExtras") + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "Core", + dependencies: [ + "AppKitExtras", + ], + swiftSettings: [ + .enableExperimentalFeature("StrictConcurrency") + ] + ), + .target( + name: "CoreResources", + resources: [ + .process("Resources/WebViewWindow.xib"), + .process("Resources/IndeterminateProgressWindow.xib") + ]), + .testTarget( + name: "CoreTests", + dependencies: ["Core"]), + ] +) diff --git a/Core/Sources/Core/BatchUpdate.swift b/Core/Sources/Core/BatchUpdate.swift new file mode 100644 index 000000000..1396eda22 --- /dev/null +++ b/Core/Sources/Core/BatchUpdate.swift @@ -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) + } + } + +} diff --git a/Core/Sources/Core/BinaryDiskCache.swift b/Core/Sources/Core/BinaryDiskCache.swift new file mode 100644 index 000000000..11373c37f --- /dev/null +++ b/Core/Sources/Core/BinaryDiskCache.swift @@ -0,0 +1,74 @@ +// +// BinaryDiskCache.swift +// RSCore +// +// Created by Brent Simmons on 11/24/17. +// Copyright © 2017 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +// Thread safety is up to the caller. + +public struct BinaryDiskCache { + + public let folder: String + + public init(folder: String) { + self.folder = folder + } + + public func data(forKey key: String) throws -> Data? { + let url = urlForKey(key) + return try Data(contentsOf: url) + } + + public func setData(_ data: Data, forKey key: String) throws { + let url = urlForKey(key) + try data.write(to: url) + } + + public func deleteData(forKey key: String) throws { + let url = urlForKey(key) + try FileManager.default.removeItem(at: url) + } + + // subscript doesn’t throw, for cases when you can ignore errors. + + public subscript(_ key: String) -> Data? { + get { + do { + return try data(forKey: key) + } + catch {} + return nil + } + + set { + if let data = newValue { + do { + try setData(data, forKey: key) + } + catch {} + } + else { + do { + try deleteData(forKey: key) + } + catch{} + } + } + } +} + +private extension BinaryDiskCache { + + func filePath(forKey key: String) -> String { + return (folder as NSString).appendingPathComponent(key) + } + + func urlForKey(_ key: String) -> URL { + let f = filePath(forKey: key) + return URL(fileURLWithPath: f) + } +} diff --git a/Core/Sources/Core/Blocks.swift b/Core/Sources/Core/Blocks.swift new file mode 100644 index 000000000..1a99f3a12 --- /dev/null +++ b/Core/Sources/Core/Blocks.swift @@ -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 +public typealias VoidResultCompletionBlock = (VoidResult) -> Void + +public typealias ImageResultBlock = (RSImage?) -> Void diff --git a/Core/Sources/Core/CoalescingQueue.swift b/Core/Sources/Core/CoalescingQueue.swift new file mode 100644 index 000000000..ec2271fe0 --- /dev/null +++ b/Core/Sources/Core/CoalescingQueue.swift @@ -0,0 +1,97 @@ +// +// CoalescingQueue.swift +// RSCore +// +// Created by Brent Simmons on 2/17/18. +// Copyright © 2018 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +// Use when you want to coalesce calls for something like updating visible table cells. +// Calls are uniqued. If you add a call with the same target and selector as a previous call, you’ll just get one call. +// Targets are weakly-held. If a target goes to nil, the call is not performed. +// The perform date is pushed off every time a call is added. +// Calls are FIFO. + +struct QueueCall: Equatable { + + weak var target: AnyObject? + let selector: Selector + + func perform() { + + let _ = target?.perform(selector) + } + + static func ==(lhs: QueueCall, rhs: QueueCall) -> Bool { + + return lhs.target === rhs.target && lhs.selector == rhs.selector + } +} + +@objc public final class CoalescingQueue: NSObject { + + public static let standard = CoalescingQueue(name: "Standard", interval: 0.05, maxInterval: 0.1) + public let name: String + public var isPaused = false + private let interval: TimeInterval + private let maxInterval: TimeInterval + private var lastCallTime = Date.distantFuture + private var timer: Timer? = nil + private var calls = [QueueCall]() + + public init(name: String, interval: TimeInterval = 0.05, maxInterval: TimeInterval = 2.0) { + self.name = name + self.interval = interval + self.maxInterval = maxInterval + } + + public func add(_ target: AnyObject, _ selector: Selector) { + let queueCall = QueueCall(target: target, selector: selector) + add(queueCall) + if Date().timeIntervalSince1970 - lastCallTime.timeIntervalSince1970 > maxInterval { + timerDidFire(nil) + } + } + + public func performCallsImmediately() { + guard !isPaused else { return } + let callsToMake = calls // Make a copy in case calls are added to the queue while performing calls. + resetCalls() + callsToMake.forEach { $0.perform() } + } + + @objc func timerDidFire(_ sender: Any?) { + lastCallTime = Date() + performCallsImmediately() + } + +} + +private extension CoalescingQueue { + + func add(_ call: QueueCall) { + restartTimer() + + if !calls.contains(call) { + calls.append(call) + } + } + + func resetCalls() { + calls = [QueueCall]() + } + + func restartTimer() { + invalidateTimer() + timer = Timer.scheduledTimer(timeInterval: interval, target: self, selector: #selector(timerDidFire(_:)), userInfo: nil, repeats: false) + } + + func invalidateTimer() { + if let timer = timer, timer.isValid { + timer.invalidate() + } + timer = nil + } +} diff --git a/Core/Sources/Core/DisplayNameProvider.swift b/Core/Sources/Core/DisplayNameProvider.swift new file mode 100644 index 000000000..05525a815 --- /dev/null +++ b/Core/Sources/Core/DisplayNameProvider.swift @@ -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) + } +} diff --git a/Core/Sources/Core/MacroProcessor.swift b/Core/Sources/Core/MacroProcessor.swift new file mode 100644 index 000000000..fad880b27 --- /dev/null +++ b/Core/Sources/Core/MacroProcessor.swift @@ -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.. Void + + /// Called when the operation completes. + /// + /// The completionBlock is called + /// even if the operation was canceled. The completionBlock + /// takes the operation as parameter, so you can inspect it as needed. + /// + /// Implementations of MainThreadOperation are *not* responsible + /// for calling the completionBlock — MainThreadOperationQueue + /// handles that. + /// + /// The completionBlock is always called on the main thread. + /// The queue will clear the completionBlock after calling it. + var completionBlock: MainThreadOperationCompletionBlock? { get set } + + /// Do the thing this operation does. + /// + /// This code runs on the main thread. If you want to run + /// code off of the main thread, you can use the standard mechanisms: + /// a DispatchQueue, most likely. + /// + /// When this is called, you don’t need to check isCanceled: + /// it’s guaranteed to not be canceled. However, if you run code + /// in another thread, you should check isCanceled in that code. + func run() + + /// Cancel this operation. + /// + /// Any operations dependent on this operation + /// will also be canceled automatically. + /// + /// This function has a default implementation. It’s super-rare + /// to need to provide your own. + func cancel() + + /// Make this operation dependent on an other operation. + /// + /// This means the other operation must complete before + /// this operation gets run. If the other operation is canceled, + /// this operation will automatically be canceled. + /// Note: an operation can have multiple dependencies. + /// + /// This function has a default implementation. It’s super-rare + /// to need to provide your own. + func addDependency(_ parentOperation: MainThreadOperation) +} + +public extension MainThreadOperation { + + func cancel() { + operationDelegate?.cancelOperation(self) + } + + func addDependency(_ parentOperation: MainThreadOperation) { + operationDelegate?.make(self, dependOn: parentOperation) + } + + func informOperationDelegateOfCompletion() { + guard !isCanceled else { + return + } + if Thread.isMainThread { + operationDelegate?.operationDidComplete(self) + } + else { + DispatchQueue.main.async { + self.informOperationDelegateOfCompletion() + } + } + } +} diff --git a/Core/Sources/Core/MainThreadOperationQueue.swift b/Core/Sources/Core/MainThreadOperationQueue.swift new file mode 100644 index 000000000..36455586e --- /dev/null +++ b/Core/Sources/Core/MainThreadOperationQueue.swift @@ -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: class { + func operationDidComplete(_ operation: MainThreadOperation) + func cancelOperation(_ operation: MainThreadOperation) + func make(_ childOperation: MainThreadOperation, dependOn parentOperation: MainThreadOperation) +} + +/// Manage a queue of MainThreadOperation tasks. +/// +/// Runs them one at a time; runs them on the main thread. +/// Any operation can use DispatchQueue or whatever to run code off of the main thread. +/// An operation calls back to the queue when it’s completed or canceled. +/// +/// Use this only on the main thread. +/// The operation can be suspended and resumed. +/// It is *not* suspended on creation. +public final class MainThreadOperationQueue { + + /// Use the shared queue when you don’t need to create a separate queue. + public static let shared: MainThreadOperationQueue = { + MainThreadOperationQueue() + }() + + private var operations = [Int: MainThreadOperation]() + private var pendingOperationIDs = [Int]() + private var currentOperationID: Int? + private static var incrementingID = 0 + private var isSuspended = false + private let dependencies = MainThreadOperationDependencies() + + /// Meant for testing; not intended to be useful. + public var pendingOperationsCount: Int { + return pendingOperationIDs.count + } + + public init() { + // Silence compiler complaint about init not being public. + } + + deinit { + cancelAllOperations() + } + + /// Add an operation to the queue. + public func add(_ operation: MainThreadOperation) { + precondition(Thread.isMainThread) + operation.operationDelegate = self + let operationID = ensureOperationID(operation) + operations[operationID] = operation + + assert(!pendingOperationIDs.contains(operationID)) + if !pendingOperationIDs.contains(operationID) { + pendingOperationIDs.append(operationID) + } + + runNextOperationIfNeeded() + } + + /// Add multiple operations to the queue. + /// This has the same effect as calling addOperation one-by-one. + public func addOperations(_ operations: [MainThreadOperation]) { + operations.forEach{ add($0) } + } + + /// Add a dependency. Do this *before* calling addOperation, since addOperation might run the operation right away. + public func make(_ childOperation: MainThreadOperation, dependOn parentOperation: MainThreadOperation) { + precondition(Thread.isMainThread) + let childOperationID = ensureOperationID(childOperation) + let parentOperationID = ensureOperationID(parentOperation) + dependencies.make(childOperationID, dependOn: parentOperationID) + } + + /// Cancel all the current and pending operations. + public func cancelAllOperations() { + precondition(Thread.isMainThread) + var operationIDsToCancel = pendingOperationIDs + if let currentOperationID = currentOperationID { + operationIDsToCancel.append(currentOperationID) + } + cancel(operationIDsToCancel) + } + + /// Cancel some operations. If any of them have dependent operations, + /// those operations will be canceled also. + public func cancelOperations(_ operations: [MainThreadOperation]) { + precondition(Thread.isMainThread) + let operationIDsToCancel = operations.map{ ensureOperationID($0) } + assert(allOperationIDsArePendingOrCurrent(operationIDsToCancel)) + assert(allOperationIDsAreInStorage(operationIDsToCancel)) + + cancel(operationIDsToCancel) + runNextOperationIfNeeded() + } + + /// Cancel operations with the given name. If any of them have dependent + /// operations, they will be canceled too. + /// + /// This will cancel the current operation, not just pending operations, + /// if it has the specified name. + public func cancelOperations(named name: String) { + precondition(Thread.isMainThread) + guard let operationsToCancel = pendingAndCurrentOperations(named: name) else { + return + } + cancelOperations(operationsToCancel) + } + + /// Stop running operations until resume() is called. + /// The current operation, if there is one, will run to completion — + /// it will not be canceled. + public func suspend() { + precondition(Thread.isMainThread) + isSuspended = true + } + + /// Resume running operations. + public func resume() { + precondition(Thread.isMainThread) + isSuspended = false + runNextOperationIfNeeded() + } +} + +extension MainThreadOperationQueue: MainThreadOperationDelegate { + + public func operationDidComplete(_ operation: MainThreadOperation) { + precondition(Thread.isMainThread) + operationDidFinish(operation) + } + + public func cancelOperation(_ operation: MainThreadOperation) { + cancelOperations([operation]) + } +} + +private extension MainThreadOperationQueue { + + var pendingOperations: [MainThreadOperation] { + return pendingOperationIDs.compactMap { (operationID) -> MainThreadOperation? in + guard let operation = operations[operationID] else { + assertionFailure("Expected operation, got nil.") + return nil + } + return operation + } + } + + var currentOperation: MainThreadOperation? { + guard let operationID = currentOperationID else { + return nil + } + return operations[operationID] + } + + func pendingAndCurrentOperations(named name: String) -> [MainThreadOperation]? { + var operations = pendingOperations.filter { $0.name == name } + if let current = currentOperation, current.name == name { + operations.append(current) + } + return operations.isEmpty ? nil : operations + } + + func operationDidFinish(_ operation: MainThreadOperation) { + guard let operationID = operation.id else { + assertionFailure("Expected operation.id, got nil") + return + } + if let currentOperationID = currentOperationID, currentOperationID == operationID { + self.currentOperationID = nil + } + + if operation.isCanceled { + dependencies.operationIDWasCanceled(operationID) + } + else { + dependencies.operationIDDidComplete(operationID) + } + + callCompletionBlock(for: operation) + removeFromStorage(operation) + operation.operationDelegate = nil + runNextOperationIfNeeded() + } + + func runNextOperationIfNeeded() { + DispatchQueue.main.async { + guard !self.isSuspended && !self.isRunningAnOperation() else { + return + } + guard let operation = self.popNextAvailableOperation() else { + return + } + self.currentOperationID = operation.id! + operation.run() + } + } + + func isRunningAnOperation() -> Bool { + return currentOperationID != nil + } + + func popNextAvailableOperation() -> MainThreadOperation? { + for operationID in pendingOperationIDs { + guard let operation = operations[operationID] else { + assertionFailure("Expected pending operation to be found in operations dictionary.") + continue + } + if operationIsAvailable(operation) { + removeOperationIDsFromPendingOperationIDs([operationID]) + dependencies.operationIDWillRun(operationID) + return operation + } + } + return nil + } + + func operationIsAvailable(_ operation: MainThreadOperation) -> Bool { + return !operation.isCanceled && !dependencies.operationIDIsBlockedByDependency(operation.id!) + } + + func createOperationID() -> Int { + precondition(Thread.isMainThread) + Self.incrementingID += 1 + return Self.incrementingID + } + + func ensureOperationID(_ operation: MainThreadOperation) -> Int { + if let operationID = operation.id { + return operationID + } + + let operationID = createOperationID() + operation.id = operationID + return operationID + } + + func cancel(_ operationIDs: [Int]) { + guard !operationIDs.isEmpty else { + return + } + + let operationIDsToCancel = operationIDsByAddingChildOperationIDs(operationIDs) + setCanceledAndRemoveDelegate(for: operationIDsToCancel) + callCompletionBlockForOperationIDs(operationIDsToCancel) + clearCurrentOperationIDIfContained(by: operationIDsToCancel) + removeOperationIDsFromPendingOperationIDs(operationIDsToCancel) + removeOperationIDsFromStorage(operationIDsToCancel) + dependencies.cancel(operationIDsToCancel) + } + + func operationIDsByAddingChildOperationIDs(_ operationIDs: [Int]) -> [Int] { + var operationIDsToCancel = operationIDs + for operationID in operationIDs { + if let childOperationIDs = dependencies.childOperationIDs(for: operationID) { + operationIDsToCancel += childOperationIDs + } + } + return operationIDsToCancel + } + + func setCanceledAndRemoveDelegate(for operationIDs: [Int]) { + for operationID in operationIDs { + if let operation = operations[operationID] { + operation.isCanceled = true + operation.operationDelegate = nil + } + } + } + + func clearCurrentOperationIDIfContained(by operationIDs: [Int]) { + if let currentOperationID = currentOperationID, operationIDs.contains(currentOperationID) { + self.currentOperationID = nil + } + } + + func removeOperationIDsFromPendingOperationIDs(_ operationIDs: [Int]) { + var updatedPendingOperationIDs = pendingOperationIDs + for operationID in operationIDs { + if let ix = updatedPendingOperationIDs.firstIndex(of: operationID) { + updatedPendingOperationIDs.remove(at: ix) + } + } + + pendingOperationIDs = updatedPendingOperationIDs + } + + func removeFromStorage(_ operation: MainThreadOperation) { + guard let operationID = operation.id else { + assertionFailure("Expected operation.id, got nil.") + return + } + removeOperationIDsFromStorage([operationID]) + } + + func removeOperationIDsFromStorage(_ operationIDs: [Int]) { + DispatchQueue.main.async { [weak self] in + for operationID in operationIDs { + self?.operations[operationID] = nil + } + } + } + + func callCompletionBlockForOperationIDs(_ operationIDs: [Int]) { + let completedOperations = operationIDs.compactMap { operations[$0] } + callCompletionBlockForOperations(completedOperations) + } + + func callCompletionBlockForOperations(_ operations: [MainThreadOperation]) { + for operation in operations { + callCompletionBlock(for: operation) + } + } + + func callCompletionBlock(for operation: MainThreadOperation) { + guard let completionBlock = operation.completionBlock else { + return + } + completionBlock(operation) + operation.completionBlock = nil + } + + func allOperationIDsArePendingOrCurrent(_ operationIDs: [Int]) -> Bool { + // Used by an assert. + for operationID in operationIDs { + if currentOperationID != operationID && !pendingOperationIDs.contains(operationID) { + return false + } + } + return true + } + + func allOperationIDsAreInStorage(_ operationIDs: [Int]) -> Bool { + // Used by an assert. + for operationID in operationIDs { + guard let _ = operations[operationID] else { + return false + } + } + return true + } +} + +private final class MainThreadOperationDependencies { + + private var dependencies = [Int: Dependency]() // Key is parentOperationID + + private final class Dependency { + + let operationID: Int + var parentOperationDidComplete = false + var isEmpty: Bool { + return childOperationIDs.isEmpty + } + var childOperationIDs = [Int]() + + init(operationID: Int) { + self.operationID = operationID + } + + func remove(_ childOperationID: Int) { + if let ix = childOperationIDs.firstIndex(of: childOperationID) { + childOperationIDs.remove(at: ix) + } + } + + func add(_ childOperationID: Int) { + guard !childOperationIDs.contains(childOperationID) else { + return + } + childOperationIDs.append(childOperationID) + } + + func operationIDIsBlocked(_ operationID: Int) -> Bool { + if parentOperationDidComplete { + return false + } + return childOperationIDs.contains(operationID) + } + } + + /// Add a dependency: make childOperationID dependent on parentOperationID. + func make(_ childOperationID: Int, dependOn parentOperationID: Int) { + let dependency = ensureDependency(parentOperationID) + dependency.add(childOperationID) + } + + /// Child operationIDs for a possible dependency. + func childOperationIDs(for parentOperationID: Int) -> [Int]? { + if let dependency = dependencies[parentOperationID] { + return dependency.childOperationIDs + } + return nil + } + + /// Update dependencies when an operation is completed. + func operationIDDidComplete(_ operationID: Int) { + if let dependency = dependencies[operationID] { + dependency.parentOperationDidComplete = true + } + removeChildOperationID(operationID) + removeEmptyDependencies() + } + + /// Update dependencies when an operation finished but was canceled. + func operationIDWasCanceled(_ operationID: Int) { + removeAllReferencesToOperationIDs([operationID]) + } + + /// Update dependencies when canceling operations. + func cancel(_ operationIDs: [Int]) { + removeAllReferencesToOperationIDs(operationIDs) + } + + /// Update dependencies when an operation is about to run. + func operationIDWillRun(_ operationID: Int) { + removeChildOperationIDs([operationID]) + } + + /// Find out if an operationID is blocked by a dependency. + func operationIDIsBlockedByDependency(_ operationID: Int) -> Bool { + for dependency in dependencies.values { + if dependency.operationIDIsBlocked(operationID) { + return true + } + } + return false + } + + private func ensureDependency(_ parentOperationID: Int) -> Dependency { + if let dependency = dependencies[parentOperationID] { + return dependency + } + let dependency = Dependency(operationID: parentOperationID) + dependencies[parentOperationID] = dependency + return dependency + } +} + +private extension MainThreadOperationDependencies { + + func removeAllReferencesToOperationIDs(_ operationIDs: [Int]) { + removeDependencies(operationIDs) + removeChildOperationIDs(operationIDs) + } + + func removeDependencies(_ parentOperationIDs: [Int]) { + parentOperationIDs.forEach { dependencies[$0] = nil } + } + + func removeChildOperationIDs(_ operationIDs: [Int]) { + operationIDs.forEach{ removeChildOperationID($0) } + removeEmptyDependencies() + } + + func removeChildOperationID(_ operationID: Int) { + dependencies.values.forEach{ $0.remove(operationID) } + } + + func removeEmptyDependencies() { + let parentOperationIDs = dependencies.keys + for parentOperationID in parentOperationIDs { + let dependency = dependencies[parentOperationID]! + if dependency.isEmpty { + dependencies[parentOperationID] = nil + } + } + } +} diff --git a/Core/Sources/Core/ManagedResourceFile.swift b/Core/Sources/Core/ManagedResourceFile.swift new file mode 100644 index 000000000..ea1652abe --- /dev/null +++ b/Core/Sources/Core/ManagedResourceFile.swift @@ -0,0 +1,127 @@ +// +// ManagedResourceFile.swift +// RSCore +// +// Created by Maurice Parker on 9/13/19. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +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() + } + } + +} diff --git a/Core/Sources/Core/OPMLRepresentable.swift b/Core/Sources/Core/OPMLRepresentable.swift new file mode 100644 index 000000000..046c78d3f --- /dev/null +++ b/Core/Sources/Core/OPMLRepresentable.swift @@ -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) + } +} diff --git a/Core/Sources/Core/Platform.swift b/Core/Sources/Core/Platform.swift new file mode 100644 index 000000000..0c74dba22 --- /dev/null +++ b/Core/Sources/Core/Platform.swift @@ -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) + } +} diff --git a/Core/Sources/Core/RSAppMovementMonitor.swift b/Core/Sources/Core/RSAppMovementMonitor.swift new file mode 100644 index 000000000..804ad0359 --- /dev/null +++ b/Core/Sources/Core/RSAppMovementMonitor.swift @@ -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 AppKit + +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 diff --git a/Core/Sources/Core/RSImage.swift b/Core/Sources/Core/RSImage.swift new file mode 100644 index 000000000..6549f02a1 --- /dev/null +++ b/Core/Sources/Core/RSImage.swift @@ -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.. maxPixelSize, but <= maxPixelSize * 2, then return it. + for i in 0.. 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.. 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) + } + +} diff --git a/Core/Sources/Core/RSScreen.swift b/Core/Sources/Core/RSScreen.swift new file mode 100644 index 000000000..4fee278db --- /dev/null +++ b/Core/Sources/Core/RSScreen.swift @@ -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 diff --git a/Core/Sources/Core/Renamable.swift b/Core/Sources/Core/Renamable.swift new file mode 100644 index 000000000..e65816b20 --- /dev/null +++ b/Core/Sources/Core/Renamable.swift @@ -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) + +} + diff --git a/Core/Sources/Core/SendToBlogEditorApp.swift b/Core/Sources/Core/SendToBlogEditorApp.swift new file mode 100644 index 000000000..633ce99b7 --- /dev/null +++ b/Core/Sources/Core/SendToBlogEditorApp.swift @@ -0,0 +1,143 @@ +// +// SendToBlogEditorApp.swift +// RSCore +// +// Created by Nate Weaver on 2020-01-04. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +#if os(macOS) +import Foundation +import AppKitExtras + +/// 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 diff --git a/Core/Sources/Core/SendToCommand.swift b/Core/Sources/Core/SendToCommand.swift new file mode 100644 index 000000000..ff83c5f4a --- /dev/null +++ b/Core/Sources/Core/SendToCommand.swift @@ -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?) +} + diff --git a/Core/Sources/Core/UndoableCommand.swift b/Core/Sources/Core/UndoableCommand.swift new file mode 100644 index 000000000..1a79051b5 --- /dev/null +++ b/Core/Sources/Core/UndoableCommand.swift @@ -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: class { + + 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: class { + + 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]() + } +} diff --git a/Core/Sources/CoreResources/AppKit/IndeterminateProgressWindowController.swift b/Core/Sources/CoreResources/AppKit/IndeterminateProgressWindowController.swift new file mode 100644 index 000000000..8c1dc27c9 --- /dev/null +++ b/Core/Sources/CoreResources/AppKit/IndeterminateProgressWindowController.swift @@ -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 + + diff --git a/Core/Sources/CoreResources/AppKit/WebViewWindowController.swift b/Core/Sources/CoreResources/AppKit/WebViewWindowController.swift new file mode 100644 index 000000000..3bac4f456 --- /dev/null +++ b/Core/Sources/CoreResources/AppKit/WebViewWindowController.swift @@ -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 diff --git a/Core/Sources/CoreResources/Resources/IndeterminateProgressWindow.xib b/Core/Sources/CoreResources/Resources/IndeterminateProgressWindow.xib new file mode 100644 index 000000000..ccb7d9456 --- /dev/null +++ b/Core/Sources/CoreResources/Resources/IndeterminateProgressWindow.xib @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Core/Sources/CoreResources/Resources/WebViewWindow.xib b/Core/Sources/CoreResources/Resources/WebViewWindow.xib new file mode 100644 index 000000000..b361093e1 --- /dev/null +++ b/Core/Sources/CoreResources/Resources/WebViewWindow.xib @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Core/Tests/CoreTests/CoreTests.swift b/Core/Tests/CoreTests/CoreTests.swift new file mode 100644 index 000000000..3cad2b63f --- /dev/null +++ b/Core/Tests/CoreTests/CoreTests.swift @@ -0,0 +1,12 @@ +import XCTest +@testable import Core + +final class CoreTests: XCTestCase { + func testExample() throws { + // XCTest Documentation + // https://developer.apple.com/documentation/xctest + + // Defining Test Cases and Test Methods + // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods + } +} diff --git a/FoundationExtras/.gitignore b/FoundationExtras/.gitignore new file mode 100644 index 000000000..0023a5340 --- /dev/null +++ b/FoundationExtras/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/FoundationExtras/Package.swift b/FoundationExtras/Package.swift new file mode 100644 index 000000000..bc05a8afd --- /dev/null +++ b/FoundationExtras/Package.swift @@ -0,0 +1,24 @@ +// swift-tools-version: 5.10 + +import PackageDescription + +let package = Package( + name: "FoundationExtras", + platforms: [.macOS(.v14), .iOS(.v17)], + products: [ + .library( + name: "FoundationExtras", + targets: ["FoundationExtras"]), + ], + targets: [ + .target( + name: "FoundationExtras", + swiftSettings: [ + .enableExperimentalFeature("StrictConcurrency") + ] + ), + .testTarget( + name: "FoundationExtrasTests", + dependencies: ["FoundationExtras"]), + ] +) diff --git a/FoundationExtras/Sources/FoundationExtras/Array+RSCore.swift b/FoundationExtras/Sources/FoundationExtras/Array+RSCore.swift new file mode 100644 index 000000000..e583824f3 --- /dev/null +++ b/FoundationExtras/Sources/FoundationExtras/Array+RSCore.swift @@ -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) + } + +} diff --git a/FoundationExtras/Sources/FoundationExtras/Calendar+RSCore.swift b/FoundationExtras/Sources/FoundationExtras/Calendar+RSCore.swift new file mode 100644 index 000000000..9de5e199d --- /dev/null +++ b/FoundationExtras/Sources/FoundationExtras/Calendar+RSCore.swift @@ -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()) + } + +} diff --git a/FoundationExtras/Sources/FoundationExtras/Character+RSCore.swift b/FoundationExtras/Sources/FoundationExtras/Character+RSCore.swift new file mode 100644 index 000000000..233de03ee --- /dev/null +++ b/FoundationExtras/Sources/FoundationExtras/Character+RSCore.swift @@ -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 } +} diff --git a/FoundationExtras/Sources/FoundationExtras/Data+RSCore.swift b/FoundationExtras/Sources/FoundationExtras/Data+RSCore.swift new file mode 100644 index 000000000..18211d012 --- /dev/null +++ b/FoundationExtras/Sources/FoundationExtras/Data+RSCore.swift @@ -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.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) } + + } + +} diff --git a/FoundationExtras/Sources/FoundationExtras/Date+Extensions.swift b/FoundationExtras/Sources/FoundationExtras/Date+Extensions.swift new file mode 100755 index 000000000..48421cbe5 --- /dev/null +++ b/FoundationExtras/Sources/FoundationExtras/Date+Extensions.swift @@ -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) + } +} diff --git a/FoundationExtras/Sources/FoundationExtras/FileManager+RSCore.swift b/FoundationExtras/Sources/FoundationExtras/FileManager+RSCore.swift new file mode 100644 index 000000000..21759ee56 --- /dev/null +++ b/FoundationExtras/Sources/FoundationExtras/FileManager+RSCore.swift @@ -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) + } + +} diff --git a/FoundationExtras/Sources/FoundationExtras/Geometry.swift b/FoundationExtras/Sources/FoundationExtras/Geometry.swift new file mode 100644 index 000000000..b0443416e --- /dev/null +++ b/FoundationExtras/Sources/FoundationExtras/Geometry.swift @@ -0,0 +1,50 @@ +// +// Geometry.swift +// RSCore +// +// Created by Nate Weaver on 2020-01-01. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +#if os(macOS) +import CoreGraphics + +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 diff --git a/FoundationExtras/Sources/FoundationExtras/PropertyList.swift b/FoundationExtras/Sources/FoundationExtras/PropertyList.swift new file mode 100644 index 000000000..2246b0e5b --- /dev/null +++ b/FoundationExtras/Sources/FoundationExtras/PropertyList.swift @@ -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 + } +} diff --git a/FoundationExtras/Sources/FoundationExtras/Set+Extensions.swift b/FoundationExtras/Sources/FoundationExtras/Set+Extensions.swift new file mode 100755 index 000000000..4e127e0bb --- /dev/null +++ b/FoundationExtras/Sources/FoundationExtras/Set+Extensions.swift @@ -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] + } +} diff --git a/FoundationExtras/Sources/FoundationExtras/String+RSCore.swift b/FoundationExtras/Sources/FoundationExtras/String+RSCore.swift new file mode 100644 index 000000000..12fd2d172 --- /dev/null +++ b/FoundationExtras/Sources/FoundationExtras/String+RSCore.swift @@ -0,0 +1,369 @@ +// +// String+RSCore.swift +// RSCore +// +// Created by Brent Simmons on 11/26/18. +// Copyright © 2018 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import CommonCrypto +import os + +public extension String { + + func htmlByAddingLink(_ link: String, className: String? = nil) -> String { + if let className = className { + return "\(self)" + } + return "\(self)" + } + + func htmlBySurroundingWithTag(_ tag: String) -> String { + return "<\(tag)>\(self)" + } + + static func htmlWithLink(_ link: String) -> String { + return link.htmlByAddingLink(link) + } + + func hmacUsingSHA1(key: String) -> String { + var digest = [UInt8](repeating: 0, count: Int(CC_SHA1_DIGEST_LENGTH)) + CCHmac(CCHmacAlgorithm(kCCHmacAlgSHA1), key, key.count, self, self.count, &digest) + let data = Data(digest) + return data.map { String(format: "%02hhx", $0) }.joined() + } + +} + +public extension String { + + /// An MD5 hash of the string's UTF-8 representation. + var md5Hash: Data { + self.data(using: .utf8)!.md5Hash + } + + /// A hexadecimal representaion of an MD5 hash of the string's UTF-8 representation. + var md5String: String { + self.md5Hash.hexadecimalString! + } + + /// Trims leading and trailing whitespace, and collapses other whitespace into a single space. + var collapsingWhitespace: String { + var dest = self + dest = dest.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + return dest.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) + } + + /// Trims whitespace from the beginning and end of the string. + var trimmingWhitespace: String { + self.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + } + + /// Returns `true` if the string contains any character from a set. + private func containsAnyCharacter(from charset: CharacterSet) -> Bool { + return self.rangeOfCharacter(from: charset) != nil + } + + /// Returns `true` if a string may be an IPv6 URL. + private var mayBeIPv6URL: Bool { + self.range(of: "\\[[0-9a-fA-F:]+\\]", options: .regularExpression) != nil + } + + private var hostMayBeLocalhost: Bool { + guard let components = URLComponents(string: self) else { return false } + + if let host = components.host { + return host == "localhost" + } + + // If self is schemeless: + if components.path.split(separator: "/", omittingEmptySubsequences: false).first == "localhost" { return true } + + return false + } + + /// Returns `true` if the string may be a URL. + var mayBeURL: Bool { + + let s = self.trimmingWhitespace + + if (s.isEmpty || (!s.contains(".") && !s.mayBeIPv6URL && !s.hostMayBeLocalhost)) { + return false + } + + let banned = CharacterSet.whitespacesAndNewlines.union(.controlCharacters).union(.illegalCharacters) + + if s.containsAnyCharacter(from: banned) { + return false + } + + return true + + } + + /// Normalizes a URL that could begin with "feed:" or "feeds:", converting + /// it to a URL beginning with "http:" or "https:" + /// + /// Strategy: + /// 1) Note whether or not this is a feed: or feeds: or other prefix + /// 2) Strip the feed: or feeds: prefix + /// 3) If the resulting string is not prefixed with http: or https:, then add http:// as a prefix + /// + /// - Note: Must handle edge case (like boingboing.net) where the feed URL is + /// feed:http://boingboing.net/feed + var normalizedURL: String { + + /// Prefix constants. + /// - Note: The lack of colon on `http(s)` is intentional. + enum Prefix { + static let feed = "feed:" + static let feeds = "feeds:" + static let http = "http" + static let https = "https" + } + + var s = self.trimmingWhitespace + var wasFeeds = false + + var lowercaseS = s.lowercased() + + if lowercaseS.hasPrefix(Prefix.feeds) { + wasFeeds = true + s = s.stripping(prefix: Prefix.feeds) + } else if lowercaseS.hasPrefix(Prefix.feed) { + s = s.stripping(prefix: Prefix.feed) + } + + if s.hasPrefix("//") { + s = s.stripping(prefix: "//") + } + + lowercaseS = s.lowercased() + if !lowercaseS.hasPrefix(Prefix.http) { + s = "\(wasFeeds ? Prefix.https : Prefix.http)://\(s)" + } + + // Handle top-level URLs missing a trailing slash, as in https://ranchero.com — make it http://ranchero.com/ + // We’re sticklers for this kind of thing. + // History: it used to be that on Windows they were always fine with no trailing slash, + // and on Macs the trailing slash would appear. In recent years you’ve seen no trailing slash + // on Macs too, but we’re bucking that trend. We’re Mac people, doggone it. Keepers of the flame. + // Add the slash. + let componentsCount = s.components(separatedBy: "/").count + if componentsCount == 3 { + s = s.appending("/") + } + + return s + } + + /// Removes a prefix from the beginning of a string. + /// - Parameters: + /// - prefix: The prefix to remove + /// - caseSensitive: `true` if the prefix should be matched case-sensitively. + /// - Returns: A new string with the prefix removed. + func stripping(prefix: String, caseSensitive: Bool = false) -> String { + let options: String.CompareOptions = caseSensitive ? .anchored : [.anchored, .caseInsensitive] + + if let range = self.range(of: prefix, options: options) { + return self.replacingCharacters(in: range, with: "") + } + + return self + } + + /// Removes a suffix from the end of a string. + /// - Parameters: + /// - suffix: The suffix to remove + /// - caseSensitive: `true` if the suffix should be matched case-sensitively. + /// - Returns: A new string with the suffix removed. + func stripping(suffix: String, caseSensitive: Bool = false) -> String { + let options: String.CompareOptions = caseSensitive ? [.backwards, .anchored] : [.backwards, .anchored, .caseInsensitive] + + if let range = self.range(of: suffix, options: options) { + return self.replacingCharacters(in: range, with: "") + } + + return self; + } + + /// Removes an HTML tag and everything between its start and end tags. + /// + /// - Parameter tag: The tag to remove. + /// + /// - Returns: A new copy of `self` with the tag removed. + /// + /// - Note: Doesn't work correctly with nested tags of the same name. + private func removingTagAndContents(_ tag: String) -> String { + return self.replacingOccurrences(of: "<\(tag).+?", 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[..", with: " ", options: options) + preflight = preflight.replacingOccurrences(of: "

|||", 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: "|

", with: "\n\n", options: options) + preflight = preflight.replacingOccurrences(of: "

|||", with: "\n", options: options) + + var s = String() + s.reserveCapacity(preflight.count) + var level = 0 + + for char in preflight { + if char == "<" { + level += 1 + } else if char == ">" { + level -= 1 + } else if level == 0 { + s.append(char) + } + } + + return s.replacingOccurrences(of: "\\n{3,}", with: "\n\n", options: .regularExpression) + } + + + /// Returns a Boolean value indicating whether the string contains another string, case-insensitively. + /// + /// - Parameter string: The string to search for. + /// + /// - Returns: `true` if the string contains `string`; `false` otherswise. + func caseInsensitiveContains(_ string: String) -> Bool { + return self.range(of: string, options: .caseInsensitive) != nil + } + + /// Returns the string with the special XML characters (other than single-quote) ampersand-escaped. + /// + /// The four escaped characters are `<`, `>`, `&`, and `"`. + var escapingSpecialXMLCharacters: String { + var escaped = String() + + for char in self { + switch char { + case "&": + escaped.append("&") + case "<": + escaped.append("<") + case ">": + escaped.append(">") + case "\"": + escaped.append(""") + default: + escaped.append(char) + } + } + + return escaped + } + + /// Initializes a string with a run of tabs. + /// + /// - Parameter tabCount: The number of tabs in the returned string. Must be greater than or equal to zero. + init(tabCount: Int) { + self = String(repeating: "\t", count: tabCount) + } + + /// 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 } } +} diff --git a/FoundationExtras/Tests/FoundationExtrasTests/FoundationExtrasTests.swift b/FoundationExtras/Tests/FoundationExtrasTests/FoundationExtrasTests.swift new file mode 100644 index 000000000..c3cb158ab --- /dev/null +++ b/FoundationExtras/Tests/FoundationExtrasTests/FoundationExtrasTests.swift @@ -0,0 +1,12 @@ +import XCTest +@testable import FoundationExtras + +final class FoundationExtrasTests: XCTestCase { + func testExample() throws { + // XCTest Documentation + // https://developer.apple.com/documentation/xctest + + // Defining Test Cases and Test Methods + // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods + } +} diff --git a/Mac/AppAssets.swift b/Mac/AppAssets.swift index 55db01efb..a71a44364 100644 --- a/Mac/AppAssets.swift +++ b/Mac/AppAssets.swift @@ -7,46 +7,45 @@ // import AppKit -import RSCore import Account struct AppAssets { - static let accountBazQux = RSImage(named: "accountBazQux") + static let accountBazQux = NSImage(named: "accountBazQux") - static let accountCloudKit = RSImage(named: "accountCloudKit") + static let accountCloudKit = NSImage(named: "accountCloudKit") - static let accountFeedbin = RSImage(named: "accountFeedbin") + static let accountFeedbin = NSImage(named: "accountFeedbin") - static let accountFeedly = RSImage(named: "accountFeedly") + static let accountFeedly = NSImage(named: "accountFeedly") - static let accountFreshRSS = RSImage(named: "accountFreshRSS") + static let accountFreshRSS = NSImage(named: "accountFreshRSS") - static let accountInoreader = RSImage(named: "accountInoreader") + static let accountInoreader = NSImage(named: "accountInoreader") - static let accountLocal = RSImage(named: "accountLocal") + static let accountLocal = NSImage(named: "accountLocal") - static let accountNewsBlur = RSImage(named: "accountNewsBlur") + static let accountNewsBlur = NSImage(named: "accountNewsBlur") - static let accountTheOldReader = RSImage(named: "accountTheOldReader") + static let accountTheOldReader = NSImage(named: "accountTheOldReader") static let addNewSidebarItemImage = NSImage(systemSymbolName: "plus", accessibilityDescription: nil)! - static let articleExtractorError = RSImage(named: "articleExtractorError")! + static let articleExtractorError = NSImage(named: "articleExtractorError")! - static let articleExtractorOff = RSImage(named: "articleExtractorOff")! + static let articleExtractorOff = NSImage(named: "articleExtractorOff")! - static let articleExtractorOn = RSImage(named: "articleExtractorOn")! + static let articleExtractorOn = NSImage(named: "articleExtractorOn")! static let articleTheme = NSImage(systemSymbolName: "doc.richtext", accessibilityDescription: nil)! static let cleanUpImage = NSImage(systemSymbolName: "wind", accessibilityDescription: nil)! - static let marsEditIcon = RSImage(named: "MarsEditIcon")! + static let marsEditIcon = NSImage(named: "MarsEditIcon")! - static let microblogIcon = RSImage(named: "MicroblogIcon")! + static let microblogIcon = NSImage(named: "MicroblogIcon")! - static let faviconTemplateImage = RSImage(named: "faviconTemplateImage")! + static let faviconTemplateImage = NSImage(named: "faviconTemplateImage")! static let filterActive = NSImage(systemSymbolName: "line.horizontal.3.decrease.circle.fill", accessibilityDescription: nil)! @@ -56,21 +55,21 @@ struct AppAssets { static let iconDarkBackgroundColor = NSColor(named: NSColor.Name("iconDarkBackgroundColor"))! - static let legacyArticleExtractor = RSImage(named: "legacyArticleExtractor")! + static let legacyArticleExtractor = NSImage(named: "legacyArticleExtractor")! - static let legacyArticleExtractorError = RSImage(named: "legacyArticleExtractorError")! + static let legacyArticleExtractorError = NSImage(named: "legacyArticleExtractorError")! - static let legacyArticleExtractorInactiveDark = RSImage(named: "legacyArticleExtractorInactiveDark")! + static let legacyArticleExtractorInactiveDark = NSImage(named: "legacyArticleExtractorInactiveDark")! - static let legacyArticleExtractorInactiveLight = RSImage(named: "legacyArticleExtractorInactiveLight")! + static let legacyArticleExtractorInactiveLight = NSImage(named: "legacyArticleExtractorInactiveLight")! - static let legacyArticleExtractorProgress1 = RSImage(named: "legacyArticleExtractorProgress1") + static let legacyArticleExtractorProgress1 = NSImage(named: "legacyArticleExtractorProgress1") - static let legacyArticleExtractorProgress2 = RSImage(named: "legacyArticleExtractorProgress2") + static let legacyArticleExtractorProgress2 = NSImage(named: "legacyArticleExtractorProgress2") - static let legacyArticleExtractorProgress3 = RSImage(named: "legacyArticleExtractorProgress3") + static let legacyArticleExtractorProgress3 = NSImage(named: "legacyArticleExtractorProgress3") - static let legacyArticleExtractorProgress4 = RSImage(named: "legacyArticleExtractorProgress4") + static let legacyArticleExtractorProgress4 = NSImage(named: "legacyArticleExtractorProgress4") static let folderImage: IconImage = { let image = NSImage(systemSymbolName: "folder", accessibilityDescription: nil)! @@ -79,7 +78,7 @@ struct AppAssets { return IconImage(coloredImage, isSymbol: true, isBackgroundSupressed: true, preferredColor: preferredColor.cgColor) }() - static let markAllAsReadImage = RSImage(named: "markAllAsRead")! + static let markAllAsReadImage = NSImage(named: "markAllAsRead")! static let nextUnreadImage = NSImage(systemSymbolName: "chevron.down.circle", accessibilityDescription: nil)! @@ -98,7 +97,7 @@ struct AppAssets { static let refreshImage = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: nil)! static let searchFeedImage: IconImage = { - return IconImage(RSImage(named: NSImage.smartBadgeTemplateName)!, isSymbol: true, isBackgroundSupressed: true) + return IconImage(NSImage(named: NSImage.smartBadgeTemplateName)!, isSymbol: true, isBackgroundSupressed: true) }() static let shareImage = NSImage(systemSymbolName: "square.and.arrow.up", accessibilityDescription: nil)! @@ -118,9 +117,9 @@ struct AppAssets { static let timelineSeparatorColor = NSColor(named: "timelineSeparatorColor")! - static let timelineStarSelected = RSImage(named: "timelineStar")?.tinted(with: .white) + static let timelineStarSelected = NSImage(named: "timelineStar")?.tinted(with: .white) - static let timelineStarUnselected = RSImage(named: "timelineStar")?.tinted(with: starColor) + static let timelineStarUnselected = NSImage(named: "timelineStar")?.tinted(with: starColor) static let todayFeedImage: IconImage = { let image = NSImage(systemSymbolName: "sun.max.fill", accessibilityDescription: nil)! @@ -136,16 +135,16 @@ struct AppAssets { return IconImage(coloredImage, isSymbol: true, isBackgroundSupressed: true, preferredColor: preferredColor.cgColor) }() - static let swipeMarkReadImage = RSImage(systemSymbolName: "circle", accessibilityDescription: "Mark Read")! + static let swipeMarkReadImage = NSImage(systemSymbolName: "circle", accessibilityDescription: "Mark Read")! .withSymbolConfiguration(.init(scale: .large)) - static let swipeMarkUnreadImage = RSImage(systemSymbolName: "largecircle.fill.circle", accessibilityDescription: "Mark Unread")! + static let swipeMarkUnreadImage = NSImage(systemSymbolName: "largecircle.fill.circle", accessibilityDescription: "Mark Unread")! .withSymbolConfiguration(.init(scale: .large)) - static let swipeMarkStarredImage = RSImage(systemSymbolName: "star.fill", accessibilityDescription: "Star")! + static let swipeMarkStarredImage = NSImage(systemSymbolName: "star.fill", accessibilityDescription: "Star")! .withSymbolConfiguration(.init(scale: .large)) - static let swipeMarkUnstarredImage = RSImage(systemSymbolName: "star", accessibilityDescription: "Unstar")! + static let swipeMarkUnstarredImage = NSImage(systemSymbolName: "star", accessibilityDescription: "Unstar")! .withSymbolConfiguration(.init(scale: .large))! static let starColor = NSColor(named: NSColor.Name("StarColor"))! diff --git a/Mac/AppDelegate.swift b/Mac/AppDelegate.swift index 3662afc2d..3d832baba 100644 --- a/Mac/AppDelegate.swift +++ b/Mac/AppDelegate.swift @@ -12,10 +12,10 @@ import Articles import RSTree import RSWeb import Account -import RSCore -import RSCoreResources +import CoreResources import Secrets import OSLog +import Core import CrashReporter // If we're not going to import Sparkle, provide dummy protocols to make it easy diff --git a/Mac/Inspector/FolderInspectorViewController.swift b/Mac/Inspector/FolderInspectorViewController.swift index 6e12f248d..32f463c24 100644 --- a/Mac/Inspector/FolderInspectorViewController.swift +++ b/Mac/Inspector/FolderInspectorViewController.swift @@ -8,7 +8,6 @@ import AppKit import Account -import RSCore @MainActor final class FolderInspectorViewController: NSViewController, Inspector { diff --git a/Mac/MainWindow/AddFeed/AddFeedController.swift b/Mac/MainWindow/AddFeed/AddFeedController.swift index 1824ac925..45d5eb42b 100644 --- a/Mac/MainWindow/AddFeed/AddFeedController.swift +++ b/Mac/MainWindow/AddFeed/AddFeedController.swift @@ -7,8 +7,7 @@ // import AppKit -import RSCore -import RSCoreResources +import CoreResources import RSTree import Articles import Account diff --git a/Mac/MainWindow/AddFeed/AddFeedWindowController.swift b/Mac/MainWindow/AddFeed/AddFeedWindowController.swift index 3722c9644..49d5726ef 100644 --- a/Mac/MainWindow/AddFeed/AddFeedWindowController.swift +++ b/Mac/MainWindow/AddFeed/AddFeedWindowController.swift @@ -7,7 +7,6 @@ // import AppKit -import RSCore import RSTree import Articles import Account diff --git a/Mac/MainWindow/AddFeed/FolderTreeMenu.swift b/Mac/MainWindow/AddFeed/FolderTreeMenu.swift index 1c696f0dc..340418716 100644 --- a/Mac/MainWindow/AddFeed/FolderTreeMenu.swift +++ b/Mac/MainWindow/AddFeed/FolderTreeMenu.swift @@ -7,9 +7,9 @@ // import AppKit -import RSCore import RSTree import Account +import Core @MainActor final class FolderTreeMenu { diff --git a/Mac/MainWindow/Detail/DetailViewController.swift b/Mac/MainWindow/Detail/DetailViewController.swift index 4a84a3f48..7feea9850 100644 --- a/Mac/MainWindow/Detail/DetailViewController.swift +++ b/Mac/MainWindow/Detail/DetailViewController.swift @@ -8,7 +8,6 @@ import Foundation import WebKit -import RSCore import Articles import RSWeb diff --git a/Mac/MainWindow/Detail/DetailWebView.swift b/Mac/MainWindow/Detail/DetailWebView.swift index dfe79ba94..b3c3b9963 100644 --- a/Mac/MainWindow/Detail/DetailWebView.swift +++ b/Mac/MainWindow/Detail/DetailWebView.swift @@ -8,7 +8,7 @@ import AppKit import WebKit -import RSCore +import AppKitExtras final class DetailWebView: WKWebView { diff --git a/Mac/MainWindow/Detail/DetailWebViewController.swift b/Mac/MainWindow/Detail/DetailWebViewController.swift index 3f3abf0ad..9c1bea221 100644 --- a/Mac/MainWindow/Detail/DetailWebViewController.swift +++ b/Mac/MainWindow/Detail/DetailWebViewController.swift @@ -8,9 +8,9 @@ import AppKit import WebKit -import RSCore import RSWeb import Articles +import Core protocol DetailWebViewControllerDelegate: AnyObject { func mouseDidEnter(_: DetailWebViewController, link: String) diff --git a/Mac/MainWindow/Detail/Keyboard/DetailKeyboardDelegate.swift b/Mac/MainWindow/Detail/Keyboard/DetailKeyboardDelegate.swift index d2d3b49ea..4a766f92d 100644 --- a/Mac/MainWindow/Detail/Keyboard/DetailKeyboardDelegate.swift +++ b/Mac/MainWindow/Detail/Keyboard/DetailKeyboardDelegate.swift @@ -7,9 +7,9 @@ // import AppKit -import RSCore +import AppKitExtras -@objc final class DetailKeyboardDelegate: NSObject, KeyboardDelegate { +@objc @MainActor final class DetailKeyboardDelegate: NSObject, KeyboardDelegate { let shortcuts: Set diff --git a/Mac/MainWindow/Keyboard/MainWIndowKeyboardHandler.swift b/Mac/MainWindow/Keyboard/MainWIndowKeyboardHandler.swift index 04483edca..bf21b9a97 100644 --- a/Mac/MainWindow/Keyboard/MainWIndowKeyboardHandler.swift +++ b/Mac/MainWindow/Keyboard/MainWIndowKeyboardHandler.swift @@ -1,5 +1,5 @@ // -// MainWIndowKeyboardHandler.swift +// MainWindowKeyboardHandler.swift // NetNewsWire // // Created by Brent Simmons on 12/19/17. @@ -7,9 +7,9 @@ // import AppKit -import RSCore +import AppKitExtras -final class MainWindowKeyboardHandler: KeyboardDelegate { +@MainActor final class MainWindowKeyboardHandler: KeyboardDelegate { static let shared = MainWindowKeyboardHandler() let globalShortcuts: Set diff --git a/Mac/MainWindow/MainWindowController.swift b/Mac/MainWindow/MainWindowController.swift index 88452fede..4e51e3e40 100644 --- a/Mac/MainWindow/MainWindowController.swift +++ b/Mac/MainWindow/MainWindowController.swift @@ -10,7 +10,8 @@ import AppKit import UserNotifications import Articles import Account -import RSCore +import Core +import AppKitExtras enum TimelineSourceMode { case regular, search diff --git a/Mac/MainWindow/NNW3/NNW3Document.swift b/Mac/MainWindow/NNW3/NNW3Document.swift index 9d7fbed7f..4472c3a45 100644 --- a/Mac/MainWindow/NNW3/NNW3Document.swift +++ b/Mac/MainWindow/NNW3/NNW3Document.swift @@ -7,7 +7,7 @@ // import Foundation -import RSCore +import Core struct NNW3Document { diff --git a/Mac/MainWindow/NNW3/NNW3ImportController.swift b/Mac/MainWindow/NNW3/NNW3ImportController.swift index 152babdca..1d32b5ce1 100644 --- a/Mac/MainWindow/NNW3/NNW3ImportController.swift +++ b/Mac/MainWindow/NNW3/NNW3ImportController.swift @@ -94,7 +94,7 @@ private extension NNW3ImportController { guard let document = NNW3Document(subscriptionsPlistURL: url) else { return nil } - let opml = document.OPMLString(indentLevel: 0) + let opml = document.OPMLString(indentLevel: 0, allowCustomAttributes: false) let opmlURL = FileManager.default.temporaryDirectory.appendingPathComponent("NNW3.opml") do { diff --git a/Mac/MainWindow/SharingServicePickerDelegate.swift b/Mac/MainWindow/SharingServicePickerDelegate.swift index b95793e2e..bbe3b0d4f 100644 --- a/Mac/MainWindow/SharingServicePickerDelegate.swift +++ b/Mac/MainWindow/SharingServicePickerDelegate.swift @@ -7,7 +7,7 @@ // import AppKit -import RSCore +import Core @objc final class SharingServicePickerDelegate: NSObject, NSSharingServicePickerDelegate { diff --git a/Mac/MainWindow/Sidebar/Cell/SidebarCell.swift b/Mac/MainWindow/Sidebar/Cell/SidebarCell.swift index ae0e2a12d..4453a247d 100644 --- a/Mac/MainWindow/Sidebar/Cell/SidebarCell.swift +++ b/Mac/MainWindow/Sidebar/Cell/SidebarCell.swift @@ -7,7 +7,6 @@ // import Foundation -import RSCore import Account import RSTree diff --git a/Mac/MainWindow/Sidebar/Cell/SidebarCellLayout.swift b/Mac/MainWindow/Sidebar/Cell/SidebarCellLayout.swift index ed9e91a94..17dd13d6a 100644 --- a/Mac/MainWindow/Sidebar/Cell/SidebarCellLayout.swift +++ b/Mac/MainWindow/Sidebar/Cell/SidebarCellLayout.swift @@ -7,7 +7,6 @@ // import AppKit -import RSCore // image - title - unreadCount diff --git a/Mac/MainWindow/Sidebar/Keyboard/SidebarKeyboardDelegate.swift b/Mac/MainWindow/Sidebar/Keyboard/SidebarKeyboardDelegate.swift index 26d5a299d..3ee447f49 100644 --- a/Mac/MainWindow/Sidebar/Keyboard/SidebarKeyboardDelegate.swift +++ b/Mac/MainWindow/Sidebar/Keyboard/SidebarKeyboardDelegate.swift @@ -7,9 +7,9 @@ // import AppKit -import RSCore +import AppKitExtras -@objc final class SidebarKeyboardDelegate: NSObject, KeyboardDelegate { +@objc @MainActor final class SidebarKeyboardDelegate: NSObject, KeyboardDelegate { @IBOutlet weak var sidebarViewController: SidebarViewController? let shortcuts: Set diff --git a/Mac/MainWindow/Sidebar/PasteboardFeed.swift b/Mac/MainWindow/Sidebar/PasteboardFeed.swift index f92ad2c31..696528753 100644 --- a/Mac/MainWindow/Sidebar/PasteboardFeed.swift +++ b/Mac/MainWindow/Sidebar/PasteboardFeed.swift @@ -9,7 +9,7 @@ import AppKit import Articles import Account -import RSCore +import AppKitExtras typealias PasteboardFeedDictionary = [String: String] diff --git a/Mac/MainWindow/Sidebar/PasteboardFolder.swift b/Mac/MainWindow/Sidebar/PasteboardFolder.swift index 2a7e14eb8..85cdea238 100644 --- a/Mac/MainWindow/Sidebar/PasteboardFolder.swift +++ b/Mac/MainWindow/Sidebar/PasteboardFolder.swift @@ -8,7 +8,7 @@ import AppKit import Account -import RSCore +import AppKitExtras typealias PasteboardFolderDictionary = [String: String] diff --git a/Mac/MainWindow/Sidebar/Renaming/RenameWindowController.swift b/Mac/MainWindow/Sidebar/Renaming/RenameWindowController.swift index 5cb8b6309..91f12f0e4 100644 --- a/Mac/MainWindow/Sidebar/Renaming/RenameWindowController.swift +++ b/Mac/MainWindow/Sidebar/Renaming/RenameWindowController.swift @@ -10,7 +10,7 @@ import AppKit protocol RenameWindowControllerDelegate { - func renameWindowController(_ windowController: RenameWindowController, didRenameObject: Any, withNewName: String) + @MainActor func renameWindowController(_ windowController: RenameWindowController, didRenameObject: Any, withNewName: String) } final class RenameWindowController: NSWindowController { diff --git a/Mac/MainWindow/Sidebar/SidebarOutlineDataSource.swift b/Mac/MainWindow/Sidebar/SidebarOutlineDataSource.swift index 76eac34eb..da819a1f5 100644 --- a/Mac/MainWindow/Sidebar/SidebarOutlineDataSource.swift +++ b/Mac/MainWindow/Sidebar/SidebarOutlineDataSource.swift @@ -9,8 +9,9 @@ import AppKit import RSTree import Articles -import RSCore import Account +import Core +import AppKitExtras @objc @MainActor final class SidebarOutlineDataSource: NSObject, NSOutlineViewDataSource { diff --git a/Mac/MainWindow/Sidebar/SidebarOutlineView.swift b/Mac/MainWindow/Sidebar/SidebarOutlineView.swift index 2be34e87c..45a1bb404 100644 --- a/Mac/MainWindow/Sidebar/SidebarOutlineView.swift +++ b/Mac/MainWindow/Sidebar/SidebarOutlineView.swift @@ -7,8 +7,8 @@ // import AppKit -import RSCore import RSTree +import AppKitExtras class SidebarOutlineView : NSOutlineView { diff --git a/Mac/MainWindow/Sidebar/SidebarStatusBarView.swift b/Mac/MainWindow/Sidebar/SidebarStatusBarView.swift index d65733d51..24f955fb2 100644 --- a/Mac/MainWindow/Sidebar/SidebarStatusBarView.swift +++ b/Mac/MainWindow/Sidebar/SidebarStatusBarView.swift @@ -7,10 +7,10 @@ // import AppKit -import RSCore import Articles import RSWeb import Account +import Core final class SidebarStatusBarView: NSView { diff --git a/Mac/MainWindow/Sidebar/SidebarViewController+ContextualMenus.swift b/Mac/MainWindow/Sidebar/SidebarViewController+ContextualMenus.swift index 6bc267609..ee4bf4b81 100644 --- a/Mac/MainWindow/Sidebar/SidebarViewController+ContextualMenus.swift +++ b/Mac/MainWindow/Sidebar/SidebarViewController+ContextualMenus.swift @@ -9,8 +9,9 @@ import AppKit import Articles import Account -import RSCore import UserNotifications +import AppKitExtras +import Core extension Notification.Name { public static let DidUpdateFeedPreferencesFromContextMenu = Notification.Name(rawValue: "DidUpdateFeedPreferencesFromContextMenu") diff --git a/Mac/MainWindow/Sidebar/SidebarViewController.swift b/Mac/MainWindow/Sidebar/SidebarViewController.swift index 934f21307..c879ef851 100644 --- a/Mac/MainWindow/Sidebar/SidebarViewController.swift +++ b/Mac/MainWindow/Sidebar/SidebarViewController.swift @@ -10,7 +10,7 @@ import AppKit import RSTree import Articles import Account -import RSCore +import Core extension Notification.Name { static let appleSideBarDefaultIconSizeChanged = Notification.Name("AppleSideBarDefaultIconSizeChanged") diff --git a/Mac/MainWindow/Timeline/ArticlePasteboardWriter.swift b/Mac/MainWindow/Timeline/ArticlePasteboardWriter.swift index 612f05a45..3645ea179 100644 --- a/Mac/MainWindow/Timeline/ArticlePasteboardWriter.swift +++ b/Mac/MainWindow/Timeline/ArticlePasteboardWriter.swift @@ -8,7 +8,7 @@ import AppKit import Articles -import RSCore +import AppKitExtras extension Article: PasteboardWriterOwner { diff --git a/Mac/MainWindow/Timeline/Cell/TimelineCellLayout.swift b/Mac/MainWindow/Timeline/Cell/TimelineCellLayout.swift index 21f8dcdef..08fb41441 100644 --- a/Mac/MainWindow/Timeline/Cell/TimelineCellLayout.swift +++ b/Mac/MainWindow/Timeline/Cell/TimelineCellLayout.swift @@ -7,7 +7,6 @@ // import AppKit -import RSCore @MainActor struct TimelineCellLayout { diff --git a/Mac/MainWindow/Timeline/Cell/TimelineTableCellView.swift b/Mac/MainWindow/Timeline/Cell/TimelineTableCellView.swift index 24a88abc0..4b8652d48 100644 --- a/Mac/MainWindow/Timeline/Cell/TimelineTableCellView.swift +++ b/Mac/MainWindow/Timeline/Cell/TimelineTableCellView.swift @@ -7,7 +7,6 @@ // import AppKit -import RSCore class TimelineTableCellView: NSTableCellView { diff --git a/Mac/MainWindow/Timeline/Keyboard/TimelineKeyboardDelegate.swift b/Mac/MainWindow/Timeline/Keyboard/TimelineKeyboardDelegate.swift index fb228538b..32e771f0d 100644 --- a/Mac/MainWindow/Timeline/Keyboard/TimelineKeyboardDelegate.swift +++ b/Mac/MainWindow/Timeline/Keyboard/TimelineKeyboardDelegate.swift @@ -7,11 +7,11 @@ // import AppKit -import RSCore +import AppKitExtras // Doesn’t have any shortcuts of its own — they’re all in MainWindowKeyboardHandler. -@objc final class TimelineKeyboardDelegate: NSObject, KeyboardDelegate { +@objc @MainActor final class TimelineKeyboardDelegate: NSObject, KeyboardDelegate { @IBOutlet weak var timelineViewController: TimelineViewController? let shortcuts: Set diff --git a/Mac/MainWindow/Timeline/TimelineTableView.swift b/Mac/MainWindow/Timeline/TimelineTableView.swift index e70bbed17..d818995c0 100644 --- a/Mac/MainWindow/Timeline/TimelineTableView.swift +++ b/Mac/MainWindow/Timeline/TimelineTableView.swift @@ -7,7 +7,7 @@ // import AppKit -import RSCore +import AppKitExtras class TimelineTableView: NSTableView { diff --git a/Mac/MainWindow/Timeline/TimelineViewController+ContextualMenus.swift b/Mac/MainWindow/Timeline/TimelineViewController+ContextualMenus.swift index fe88a3bce..26ce001d5 100644 --- a/Mac/MainWindow/Timeline/TimelineViewController+ContextualMenus.swift +++ b/Mac/MainWindow/Timeline/TimelineViewController+ContextualMenus.swift @@ -7,9 +7,10 @@ // import AppKit -import RSCore import Articles import Account +import Core +import AppKitExtras extension TimelineViewController { diff --git a/Mac/MainWindow/Timeline/TimelineViewController.swift b/Mac/MainWindow/Timeline/TimelineViewController.swift index 68bbe9470..f2ec43eaf 100644 --- a/Mac/MainWindow/Timeline/TimelineViewController.swift +++ b/Mac/MainWindow/Timeline/TimelineViewController.swift @@ -7,10 +7,10 @@ // import Foundation -import RSCore import Articles import Account import os.log +import Core protocol TimelineDelegate: AnyObject { diff --git a/Mac/Preferences/Accounts/AccountsPreferencesViewController.swift b/Mac/Preferences/Accounts/AccountsPreferencesViewController.swift index b1aa87766..078d02992 100644 --- a/Mac/Preferences/Accounts/AccountsPreferencesViewController.swift +++ b/Mac/Preferences/Accounts/AccountsPreferencesViewController.swift @@ -9,7 +9,7 @@ import AppKit import Account import SwiftUI -import RSCore +import Core // MARK: - AccountsPreferencesAddAccountDelegate protocol AccountsPreferencesAddAccountDelegate { diff --git a/Mac/Preferences/Accounts/AddAccountsView.swift b/Mac/Preferences/Accounts/AddAccountsView.swift index b615327b2..18db13ec5 100644 --- a/Mac/Preferences/Accounts/AddAccountsView.swift +++ b/Mac/Preferences/Accounts/AddAccountsView.swift @@ -8,7 +8,6 @@ import SwiftUI import Account -import RSCore enum AddAccountSections: Int, CaseIterable { case local = 0 diff --git a/Mac/Preferences/General/GeneralPrefencesViewController.swift b/Mac/Preferences/General/GeneralPrefencesViewController.swift index 7fb93a71a..2ec312517 100644 --- a/Mac/Preferences/General/GeneralPrefencesViewController.swift +++ b/Mac/Preferences/General/GeneralPrefencesViewController.swift @@ -7,7 +7,6 @@ // import AppKit -import RSCore import RSWeb import UserNotifications import UniformTypeIdentifiers diff --git a/Mac/Preferences/PreferencesControlsBackgroundView.swift b/Mac/Preferences/PreferencesControlsBackgroundView.swift index 36353dd6b..9ea424465 100644 --- a/Mac/Preferences/PreferencesControlsBackgroundView.swift +++ b/Mac/Preferences/PreferencesControlsBackgroundView.swift @@ -7,7 +7,6 @@ // import AppKit -import RSCore final class PreferencesControlsBackgroundView: NSView { diff --git a/Mac/Scriptability/Account+Scriptability.swift b/Mac/Scriptability/Account+Scriptability.swift index 0c7c84380..af6b36f47 100644 --- a/Mac/Scriptability/Account+Scriptability.swift +++ b/Mac/Scriptability/Account+Scriptability.swift @@ -9,7 +9,7 @@ import AppKit import Account import Articles -import RSCore +import Core @objc(ScriptableAccount) class ScriptableAccount: NSObject, UniqueIdScriptingObject, ScriptingObjectContainer { diff --git a/Mac/Scriptability/Folder+Scriptability.swift b/Mac/Scriptability/Folder+Scriptability.swift index 301dce64d..d0d739b31 100644 --- a/Mac/Scriptability/Folder+Scriptability.swift +++ b/Mac/Scriptability/Folder+Scriptability.swift @@ -9,7 +9,7 @@ import Foundation import Account import Articles -import RSCore +import Core @objc(ScriptableFolder) class ScriptableFolder: NSObject, UniqueIdScriptingObject, ScriptingObjectContainer { diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index 18db61477..616bb4aaa 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -59,14 +59,9 @@ 27B86EEB25A53AAB00264340 /* Account in Frameworks */ = {isa = PBXBuildFile; productRef = 51BC2F4A24D343A500E90810 /* Account */; }; 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 */; }; - 5102AE6924D17F7C0050839C /* RSCore in Frameworks */ = {isa = PBXBuildFile; productRef = 5102AE6824D17F7C0050839C /* RSCore */; }; - 5102AE6A24D17F7C0050839C /* RSCore in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 5102AE6824D17F7C0050839C /* RSCore */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; - 5102AE6C24D17F7C0050839C /* RSCoreResources in Frameworks */ = {isa = PBXBuildFile; productRef = 5102AE6B24D17F7C0050839C /* RSCoreResources */; }; 5103A9982421643300410853 /* blank.html in Resources */ = {isa = PBXBuildFile; fileRef = 5103A9972421643300410853 /* blank.html */; }; 5103A9992421643300410853 /* blank.html in Resources */ = {isa = PBXBuildFile; fileRef = 5103A9972421643300410853 /* blank.html */; }; 5103A9B424216A4200410853 /* blank.html in Resources */ = {isa = PBXBuildFile; fileRef = 5103A9B324216A4200410853 /* blank.html */; }; @@ -141,7 +136,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 */; }; 5137C2E526F3F52D009EFEDB /* Sepia.nnwtheme in Resources */ = {isa = PBXBuildFile; fileRef = 5137C2E326F3F52D009EFEDB /* Sepia.nnwtheme */; }; @@ -151,8 +145,6 @@ 51386A8F25673277005F3762 /* 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, ); }; }; 5138E95224D3418100AFF0FE /* RSParser in Frameworks */ = {isa = PBXBuildFile; productRef = 5138E95124D3418100AFF0FE /* RSParser */; }; 5138E95324D3418100AFF0FE /* RSParser in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 5138E95124D3418100AFF0FE /* RSParser */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 5138E95824D3419000AFF0FE /* RSWeb in Frameworks */ = {isa = PBXBuildFile; productRef = 5138E95724D3419000AFF0FE /* RSWeb */; }; @@ -160,8 +152,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 App 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 */; }; @@ -171,8 +161,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 */; }; @@ -191,7 +179,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 */; }; @@ -243,8 +230,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, ); }; }; 51A737C524DB19B50015FA66 /* RSWeb in Frameworks */ = {isa = PBXBuildFile; productRef = 51A737C424DB19B50015FA66 /* RSWeb */; }; 51A737C624DB19B50015FA66 /* RSWeb in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 51A737C424DB19B50015FA66 /* RSWeb */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 51A737C824DB19CC0015FA66 /* RSParser in Frameworks */ = {isa = PBXBuildFile; productRef = 51A737C724DB19CC0015FA66 /* RSParser */; }; @@ -432,12 +417,10 @@ 653813492680E2DA007A082C /* ExtensionContainers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B5C87623F22B8200032075 /* ExtensionContainers.swift */; }; 6538134A2680E2DA007A082C /* ArticleTextSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51DC07972552083500A3F79F /* ArticleTextSize.swift */; }; 6538134B2680E2DA007A082C /* ShareDefaultContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B5C8BC23F3780900032075 /* ShareDefaultContainer.swift */; }; - 6538134D2680E2DA007A082C /* RSCore in Frameworks */ = {isa = PBXBuildFile; productRef = 653813402680E2DA007A082C /* RSCore */; }; 6538134E2680E2DA007A082C /* Account in Frameworks */ = {isa = PBXBuildFile; productRef = 6538133F2680E2DA007A082C /* Account */; }; 653813502680E2DA007A082C /* ShareViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 510C416224E5CDE3008226FD /* ShareViewController.xib */; }; 653813512680E2DA007A082C /* icon.icns in Resources */ = {isa = PBXBuildFile; fileRef = 5132779E2591034D0064F1E7 /* icon.icns */; }; 653813522680E2DA007A082C /* SafariExt.js in Resources */ = {isa = PBXBuildFile; fileRef = 515D4FCB2325815A00EE1167 /* SafariExt.js */; }; - 653813542680E2DA007A082C /* RSCore in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 653813402680E2DA007A082C /* RSCore */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 6538135C2680E47A007A082C /* NetNewsWire Share Extension MAS.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 653813592680E2DA007A082C /* NetNewsWire Share Extension MAS.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 6581C73820CED60100F4AD34 /* SafariExtensionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6581C73720CED60100F4AD34 /* SafariExtensionHandler.swift */; }; 6581C73A20CED60100F4AD34 /* SafariExtensionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6581C73920CED60100F4AD34 /* SafariExtensionViewController.swift */; }; @@ -630,6 +613,7 @@ 842611A21FCB769D0086A189 /* RSHTMLMetadata+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842611A11FCB769D0086A189 /* RSHTMLMetadata+Extension.swift */; }; 842E45CE1ED8C308000A8B52 /* AppNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842E45CD1ED8C308000A8B52 /* AppNotifications.swift */; }; 842E45DD1ED8C54B000A8B52 /* Browser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842E45DC1ED8C54B000A8B52 /* Browser.swift */; }; + 8438C2DB2BABE0B00040C9EE /* CoreResources in Frameworks */ = {isa = PBXBuildFile; productRef = 8438C2DA2BABE0B00040C9EE /* CoreResources */; }; 84411E711FE5FBFA004B527F /* SmallIconProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84411E701FE5FBFA004B527F /* SmallIconProvider.swift */; }; 8444C8F21FED81840051386C /* OPMLExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8444C8F11FED81840051386C /* OPMLExporter.swift */; }; 844933D22BA953590068AC51 /* ArticlePathInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 844933D12BA953590068AC51 /* ArticlePathInfo.swift */; }; @@ -745,6 +729,19 @@ 84CAFCB022BC8C35007694F0 /* FetchRequestOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84CAFCAE22BC8C35007694F0 /* FetchRequestOperation.swift */; }; 84CC88181FE59CBF00644329 /* SmartFeedsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84CC88171FE59CBF00644329 /* SmartFeedsController.swift */; }; 84D52E951FE588BB00D14F5B /* DetailStatusBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D52E941FE588BB00D14F5B /* DetailStatusBarView.swift */; }; + 84D9582C2BABE53B0053E7B2 /* FoundationExtras in Frameworks */ = {isa = PBXBuildFile; productRef = 84D9582B2BABE53B0053E7B2 /* FoundationExtras */; }; + 84DCA5122BABB75600792720 /* FoundationExtras in Frameworks */ = {isa = PBXBuildFile; productRef = 84DCA5112BABB75600792720 /* FoundationExtras */; }; + 84DCA5142BABB76100792720 /* AppKitExtras in Frameworks */ = {isa = PBXBuildFile; productRef = 84DCA5132BABB76100792720 /* AppKitExtras */; }; + 84DCA5162BABB76B00792720 /* CloudKitExtras in Frameworks */ = {isa = PBXBuildFile; productRef = 84DCA5152BABB76B00792720 /* CloudKitExtras */; }; + 84DCA5182BABB77E00792720 /* FoundationExtras in Frameworks */ = {isa = PBXBuildFile; productRef = 84DCA5172BABB77E00792720 /* FoundationExtras */; }; + 84DCA51A2BABB78700792720 /* AppKitExtras in Frameworks */ = {isa = PBXBuildFile; productRef = 84DCA5192BABB78700792720 /* AppKitExtras */; }; + 84DCA51C2BABB78E00792720 /* CloudKitExtras in Frameworks */ = {isa = PBXBuildFile; productRef = 84DCA51B2BABB78E00792720 /* CloudKitExtras */; }; + 84DCA51E2BABB79900792720 /* FoundationExtras in Frameworks */ = {isa = PBXBuildFile; productRef = 84DCA51D2BABB79900792720 /* FoundationExtras */; }; + 84DCA5202BABB7A200792720 /* UIKitExtras in Frameworks */ = {isa = PBXBuildFile; productRef = 84DCA51F2BABB7A200792720 /* UIKitExtras */; }; + 84DCA5222BABB7A800792720 /* CloudKitExtras in Frameworks */ = {isa = PBXBuildFile; productRef = 84DCA5212BABB7A800792720 /* CloudKitExtras */; }; + 84DCA5252BABBB5A00792720 /* Core in Frameworks */ = {isa = PBXBuildFile; productRef = 84DCA5242BABBB5A00792720 /* Core */; }; + 84DCA5272BABBB6200792720 /* Core in Frameworks */ = {isa = PBXBuildFile; productRef = 84DCA5262BABBB6200792720 /* Core */; }; + 84DCA5292BABBB6A00792720 /* Core in Frameworks */ = {isa = PBXBuildFile; productRef = 84DCA5282BABBB6A00792720 /* Core */; }; 84DEE56522C32CA4005FC42C /* SmartFeedDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DEE56422C32CA4005FC42C /* SmartFeedDelegate.swift */; }; 84DEE56622C32CA4005FC42C /* SmartFeedDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DEE56422C32CA4005FC42C /* SmartFeedDelegate.swift */; }; 84E185B3203B74E500F69BFA /* SingleLineTextFieldSizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E185B2203B74E500F69BFA /* SingleLineTextFieldSizer.swift */; }; @@ -895,7 +892,6 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( - 4679674A25E599C100844E8D /* RSCore in Embed Frameworks */, 4679674725E599C100844E8D /* Articles in Embed Frameworks */, ); name = "Embed Frameworks"; @@ -907,7 +903,6 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( - 513F325D2593ECF40003048F /* RSCore in Embed Frameworks */, 51BC2F4924D3439E00E90810 /* RSTree in Embed Frameworks */, ); name = "Embed Frameworks"; @@ -919,7 +914,6 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( - 513F32892593EF8F0003048F /* RSCore in Embed Frameworks */, 51BC2F4E24D343AB00E90810 /* RSTree in Embed Frameworks */, ); name = "Embed Frameworks"; @@ -956,7 +950,6 @@ files = ( 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 */, @@ -973,7 +966,6 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( - 653813542680E2DA007A082C /* RSCore in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -1003,7 +995,6 @@ 653813342680E220007A082C /* RSTree in Embed Frameworks */, 653813222680E1D0007A082C /* Articles in Embed Frameworks */, 6538131F2680E1CA007A082C /* Account in Embed Frameworks */, - 5102AE6A24D17F7C0050839C /* RSCore in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -1046,7 +1037,6 @@ 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 */, 514C16DF24D2EF15009A3AFA /* RSTree in Embed Frameworks */, ); @@ -1450,6 +1440,11 @@ 84CC88171FE59CBF00644329 /* SmartFeedsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmartFeedsController.swift; sourceTree = ""; }; 84D2200922B0BC4B0019E085 /* CONTRIBUTING.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CONTRIBUTING.md; sourceTree = ""; }; 84D52E941FE588BB00D14F5B /* DetailStatusBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailStatusBarView.swift; sourceTree = ""; }; + 84DCA50D2BAB643700792720 /* FoundationExtras */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = FoundationExtras; sourceTree = ""; }; + 84DCA50E2BABB5D800792720 /* AppKitExtras */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = AppKitExtras; sourceTree = ""; }; + 84DCA50F2BABB65600792720 /* CloudKitExtras */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = CloudKitExtras; sourceTree = ""; }; + 84DCA5102BABB6A100792720 /* UIKitExtras */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = UIKitExtras; sourceTree = ""; }; + 84DCA5232BABBA8100792720 /* Core */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Core; sourceTree = ""; }; 84DEE56422C32CA4005FC42C /* SmartFeedDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmartFeedDelegate.swift; sourceTree = ""; }; 84E185B2203B74E500F69BFA /* SingleLineTextFieldSizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleLineTextFieldSizer.swift; sourceTree = ""; }; 84E185C2203BB12600F69BFA /* MultilineTextFieldSizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultilineTextFieldSizer.swift; sourceTree = ""; }; @@ -1531,7 +1526,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5132778C2590FF1E0064F1E7 /* RSCore in Frameworks */, 511B148924E5DBDD00C919BD /* Account in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1541,7 +1535,6 @@ buildActionMask = 2147483647; files = ( 27B86EEB25A53AAB00264340 /* Account in Frameworks */, - 513F32882593EF8F0003048F /* RSCore in Frameworks */, 51BC2F4D24D343AB00E90810 /* RSTree in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1551,8 +1544,8 @@ buildActionMask = 2147483647; files = ( 51BC2F3824D3439A00E90810 /* Account in Frameworks */, - 513F325C2593ECF40003048F /* RSCore in Frameworks */, 51BC2F4824D3439E00E90810 /* RSTree in Frameworks */, + 84D9582C2BABE53B0053E7B2 /* FoundationExtras in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1567,7 +1560,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 6538134D2680E2DA007A082C /* RSCore in Frameworks */, 6538134E2680E2DA007A082C /* Account in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1585,12 +1577,14 @@ files = ( 653813392680E22B007A082C /* Secrets in Frameworks */, 653813362680E224007A082C /* RSWeb in Frameworks */, + 84DCA51C2BABB78E00792720 /* CloudKitExtras in Frameworks */, + 84DCA5272BABBB6200792720 /* Core in Frameworks */, + 84DCA5182BABB77E00792720 /* FoundationExtras in Frameworks */, 653813302680E20C007A082C /* RSParser in Frameworks */, 6538131E2680E1CA007A082C /* Account in Frameworks */, 653813282680E1EC007A082C /* CrashReporter in Frameworks */, - 5102AE6C24D17F7C0050839C /* RSCoreResources in Frameworks */, - 5102AE6924D17F7C0050839C /* RSCore in Frameworks */, 653813332680E220007A082C /* RSTree in Frameworks */, + 84DCA51A2BABB78700792720 /* AppKitExtras in Frameworks */, 653813262680E1E4007A082C /* CloudKit.framework in Frameworks */, 653813242680E1D6007A082C /* ArticlesDatabase in Frameworks */, 653813212680E1D0007A082C /* Articles in Frameworks */, @@ -1608,13 +1602,16 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5138E94924D3416D00AFF0FE /* RSCore in Frameworks */, 5138E95824D3419000AFF0FE /* RSWeb in Frameworks */, + 84DCA5202BABB7A200792720 /* UIKitExtras in Frameworks */, 179D280B26F6F93D003B2E0A /* Zip in Frameworks */, + 84DCA51E2BABB79900792720 /* FoundationExtras in Frameworks */, 516B695F24D2F33B00B5702F /* Account in Frameworks */, 5138E95224D3418100AFF0FE /* RSParser in Frameworks */, 513F32712593EE6F0003048F /* Articles in Frameworks */, 513F32772593EE6F0003048F /* Secrets in Frameworks */, + 84DCA5292BABBB6A00792720 /* Core in Frameworks */, + 84DCA5222BABB7A800792720 /* CloudKitExtras in Frameworks */, 513F32742593EE6F0003048F /* ArticlesDatabase in Frameworks */, 513F327A2593EE6F0003048F /* SyncDatabase in Frameworks */, 848565512B9E910200F4BAE0 /* FMDB in Frameworks */, @@ -1630,17 +1627,20 @@ 513277642590FC640064F1E7 /* SyncDatabase in Frameworks */, 17192ADA2567B3D500AAEACA /* RSSparkle in Frameworks */, 51A737C524DB19B50015FA66 /* RSWeb in Frameworks */, + 8438C2DB2BABE0B00040C9EE /* CoreResources in Frameworks */, 514C16DE24D2EF15009A3AFA /* RSTree in Frameworks */, 5132775E2590FC640064F1E7 /* Articles in Frameworks */, + 84DCA5252BABBB5A00792720 /* Core in Frameworks */, 8479ABE32B9E906E00F84C4D /* Database in Frameworks */, + 84DCA5122BABB75600792720 /* FoundationExtras in Frameworks */, 513277612590FC640064F1E7 /* ArticlesDatabase in Frameworks */, 51C4CFF624D37DD500AF9874 /* Secrets in Frameworks */, - 51A737AE24DB19730015FA66 /* RSCore in Frameworks */, 51A737C824DB19CC0015FA66 /* RSParser in Frameworks */, 179C39EA26F76B0500D4E741 /* Zip in Frameworks */, 51E4DAED2425F6940091EB5B /* CloudKit.framework in Frameworks */, - 514C16E124D2EF38009A3AFA /* RSCoreResources in Frameworks */, + 84DCA5142BABB76100792720 /* AppKitExtras in Frameworks */, 8479ABE52B9E907400F84C4D /* FMDB in Frameworks */, + 84DCA5162BABB76B00792720 /* CloudKitExtras in Frameworks */, 514C16CE24D2E63F009A3AFA /* Account in Frameworks */, 519CA8E525841DB700EB079A /* CrashReporter in Frameworks */, ); @@ -1650,7 +1650,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 4679674925E599C100844E8D /* RSCore in Frameworks */, 4679674625E599C100844E8D /* Articles in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -2374,6 +2373,11 @@ 51CD32A824D2CB25009ABAEF /* SyncDatabase */, 841550F42B9E3F8000D4B345 /* Database */, 841550F52B9E4D6800D4B345 /* FMDB */, + 84DCA50F2BABB65600792720 /* CloudKitExtras */, + 84DCA5232BABBA8100792720 /* Core */, + 84DCA5102BABB6A100792720 /* UIKitExtras */, + 84DCA50E2BABB5D800792720 /* AppKitExtras */, + 84DCA50D2BAB643700792720 /* FoundationExtras */, ); sourceTree = ""; usesTabs = 1; @@ -2781,7 +2785,6 @@ name = "NetNewsWire Share Extension"; packageProductDependencies = ( 511B148824E5DBDD00C919BD /* Account */, - 5132778B2590FF1E0064F1E7 /* RSCore */, ); productName = ShareExtension; productReference = 510C415C24E5CDE3008226FD /* NetNewsWire Share Extension.appex */; @@ -2805,7 +2808,6 @@ packageProductDependencies = ( 51BC2F4A24D343A500E90810 /* Account */, 51BC2F4C24D343AB00E90810 /* RSTree */, - 513F32872593EF8F0003048F /* RSCore */, ); productName = "NetNewsWire iOS Intents Extension"; productReference = 51314637235A7BBE00387FDC /* NetNewsWire iOS Intents Extension.appex */; @@ -2829,7 +2831,7 @@ packageProductDependencies = ( 51BC2F3724D3439A00E90810 /* Account */, 51BC2F4724D3439E00E90810 /* RSTree */, - 513F325B2593ECF40003048F /* RSCore */, + 84D9582B2BABE53B0053E7B2 /* FoundationExtras */, ); productName = "NetNewsWire iOS Share Extension"; productReference = 513C5CE6232571C2003D4054 /* NetNewsWire iOS Share Extension.appex */; @@ -2870,7 +2872,6 @@ name = "NetNewsWire Share Extension MAS"; packageProductDependencies = ( 6538133F2680E2DA007A082C /* Account */, - 653813402680E2DA007A082C /* RSCore */, ); productName = ShareExtension; productReference = 653813592680E2DA007A082C /* NetNewsWire Share Extension MAS.appex */; @@ -2914,8 +2915,6 @@ ); name = "NetNewsWire MAS"; packageProductDependencies = ( - 5102AE6824D17F7C0050839C /* RSCore */, - 5102AE6B24D17F7C0050839C /* RSCoreResources */, 6538131D2680E1CA007A082C /* Account */, 653813202680E1D0007A082C /* Articles */, 653813232680E1D6007A082C /* ArticlesDatabase */, @@ -2924,6 +2923,10 @@ 653813322680E220007A082C /* RSTree */, 653813352680E224007A082C /* RSWeb */, 653813382680E22B007A082C /* Secrets */, + 84DCA5172BABB77E00792720 /* FoundationExtras */, + 84DCA5192BABB78700792720 /* AppKitExtras */, + 84DCA51B2BABB78E00792720 /* CloudKitExtras */, + 84DCA5262BABBB6200792720 /* Core */, ); productName = NetNewsWire; productReference = 65ED4083235DEF6C0081F399 /* NetNewsWire.app */; @@ -2968,7 +2971,6 @@ packageProductDependencies = ( 516B695E24D2F33B00B5702F /* Account */, 5138E93924D33E5600AFF0FE /* RSTree */, - 5138E94824D3416D00AFF0FE /* RSCore */, 5138E95124D3418100AFF0FE /* RSParser */, 5138E95724D3419000AFF0FE /* RSWeb */, 513F32702593EE6F0003048F /* Articles */, @@ -2978,6 +2980,10 @@ 179D280A26F6F93D003B2E0A /* Zip */, 8485654E2B9E90FD00F4BAE0 /* Database */, 848565502B9E910200F4BAE0 /* FMDB */, + 84DCA51D2BABB79900792720 /* FoundationExtras */, + 84DCA51F2BABB7A200792720 /* UIKitExtras */, + 84DCA5212BABB7A800792720 /* CloudKitExtras */, + 84DCA5282BABBB6A00792720 /* Core */, ); productName = "NetNewsWire-iOS"; productReference = 840D617C2029031C009BC708 /* NetNewsWire.app */; @@ -3008,9 +3014,7 @@ packageProductDependencies = ( 514C16CD24D2E63F009A3AFA /* Account */, 514C16DD24D2EF15009A3AFA /* RSTree */, - 514C16E024D2EF38009A3AFA /* RSCoreResources */, 51C4CFF524D37DD500AF9874 /* Secrets */, - 51A737AD24DB19730015FA66 /* RSCore */, 51A737C424DB19B50015FA66 /* RSWeb */, 51A737C724DB19CC0015FA66 /* RSParser */, 17192AD92567B3D500AAEACA /* RSSparkle */, @@ -3021,6 +3025,11 @@ 179C39E926F76B0500D4E741 /* Zip */, 8479ABE22B9E906E00F84C4D /* Database */, 8479ABE42B9E907400F84C4D /* FMDB */, + 84DCA5112BABB75600792720 /* FoundationExtras */, + 84DCA5132BABB76100792720 /* AppKitExtras */, + 84DCA5152BABB76B00792720 /* CloudKitExtras */, + 84DCA5242BABBB5A00792720 /* Core */, + 8438C2DA2BABE0B00040C9EE /* CoreResources */, ); productName = NetNewsWire; productReference = 849C64601ED37A5D003D8FC0 /* NetNewsWire.app */; @@ -3044,7 +3053,6 @@ name = NetNewsWireTests; packageProductDependencies = ( 4679674525E599C100844E8D /* Articles */, - 4679674825E599C100844E8D /* RSCore */, ); productName = NetNewsWireTests; productReference = 849C64711ED37A5D003D8FC0 /* NetNewsWireTests.xctest */; @@ -3135,7 +3143,6 @@ ); mainGroup = 849C64571ED37A5D003D8FC0; packageReferences = ( - 5102AE4324D17E820050839C /* XCRemoteSwiftPackageReference "RSCore" */, 510ECA4024D1DCD0001C31A6 /* XCRemoteSwiftPackageReference "RSTree" */, 51383A3024D1F90E0027E272 /* XCRemoteSwiftPackageReference "RSWeb" */, 51B0DF2324D2C7FA000AD99E /* XCRemoteSwiftPackageReference "RSParser" */, @@ -4763,14 +4770,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"; @@ -4803,14 +4802,6 @@ minimumVersion = 2.0.3; }; }; - 653813412680E2DA007A082C /* XCRemoteSwiftPackageReference "RSCore" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/Ranchero-Software/RSCore.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.0.0; - }; - }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -4838,21 +4829,6 @@ isa = XCSwiftPackageProductDependency; productName = Articles; }; - 4679674825E599C100844E8D /* RSCore */ = { - isa = XCSwiftPackageProductDependency; - package = 5102AE4324D17E820050839C /* XCRemoteSwiftPackageReference "RSCore" */; - productName = RSCore; - }; - 5102AE6824D17F7C0050839C /* RSCore */ = { - isa = XCSwiftPackageProductDependency; - package = 5102AE4324D17E820050839C /* XCRemoteSwiftPackageReference "RSCore" */; - productName = RSCore; - }; - 5102AE6B24D17F7C0050839C /* RSCoreResources */ = { - isa = XCSwiftPackageProductDependency; - package = 5102AE4324D17E820050839C /* XCRemoteSwiftPackageReference "RSCore" */; - productName = RSCoreResources; - }; 511B148824E5DBDD00C919BD /* Account */ = { isa = XCSwiftPackageProductDependency; productName = Account; @@ -4869,21 +4845,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; - }; 5138E95124D3418100AFF0FE /* RSParser */ = { isa = XCSwiftPackageProductDependency; package = 51B0DF2324D2C7FA000AD99E /* XCRemoteSwiftPackageReference "RSParser" */; @@ -4894,11 +4860,6 @@ package = 51383A3024D1F90E0027E272 /* XCRemoteSwiftPackageReference "RSWeb" */; productName = RSWeb; }; - 513F325B2593ECF40003048F /* RSCore */ = { - isa = XCSwiftPackageProductDependency; - package = 5102AE4324D17E820050839C /* XCRemoteSwiftPackageReference "RSCore" */; - productName = RSCore; - }; 513F32702593EE6F0003048F /* Articles */ = { isa = XCSwiftPackageProductDependency; productName = Articles; @@ -4915,11 +4876,6 @@ isa = XCSwiftPackageProductDependency; productName = SyncDatabase; }; - 513F32872593EF8F0003048F /* RSCore */ = { - isa = XCSwiftPackageProductDependency; - package = 5102AE4324D17E820050839C /* XCRemoteSwiftPackageReference "RSCore" */; - productName = RSCore; - }; 514C16CD24D2E63F009A3AFA /* Account */ = { isa = XCSwiftPackageProductDependency; productName = Account; @@ -4929,11 +4885,6 @@ package = 510ECA4024D1DCD0001C31A6 /* XCRemoteSwiftPackageReference "RSTree" */; productName = RSTree; }; - 514C16E024D2EF38009A3AFA /* RSCoreResources */ = { - isa = XCSwiftPackageProductDependency; - package = 5102AE4324D17E820050839C /* XCRemoteSwiftPackageReference "RSCore" */; - productName = RSCoreResources; - }; 516B695E24D2F33B00B5702F /* Account */ = { isa = XCSwiftPackageProductDependency; productName = Account; @@ -4943,11 +4894,6 @@ package = 519CA8E325841DB700EB079A /* XCRemoteSwiftPackageReference "plcrashreporter" */; productName = CrashReporter; }; - 51A737AD24DB19730015FA66 /* RSCore */ = { - isa = XCSwiftPackageProductDependency; - package = 5102AE4324D17E820050839C /* XCRemoteSwiftPackageReference "RSCore" */; - productName = RSCore; - }; 51A737C424DB19B50015FA66 /* RSWeb */ = { isa = XCSwiftPackageProductDependency; package = 51383A3024D1F90E0027E272 /* XCRemoteSwiftPackageReference "RSWeb" */; @@ -5020,10 +4966,9 @@ isa = XCSwiftPackageProductDependency; productName = Account; }; - 653813402680E2DA007A082C /* RSCore */ = { + 8438C2DA2BABE0B00040C9EE /* CoreResources */ = { isa = XCSwiftPackageProductDependency; - package = 653813412680E2DA007A082C /* XCRemoteSwiftPackageReference "RSCore" */; - productName = RSCore; + productName = CoreResources; }; 8479ABE22B9E906E00F84C4D /* Database */ = { isa = XCSwiftPackageProductDependency; @@ -5041,6 +4986,58 @@ isa = XCSwiftPackageProductDependency; productName = FMDB; }; + 84D9582B2BABE53B0053E7B2 /* FoundationExtras */ = { + isa = XCSwiftPackageProductDependency; + productName = FoundationExtras; + }; + 84DCA5112BABB75600792720 /* FoundationExtras */ = { + isa = XCSwiftPackageProductDependency; + productName = FoundationExtras; + }; + 84DCA5132BABB76100792720 /* AppKitExtras */ = { + isa = XCSwiftPackageProductDependency; + productName = AppKitExtras; + }; + 84DCA5152BABB76B00792720 /* CloudKitExtras */ = { + isa = XCSwiftPackageProductDependency; + productName = CloudKitExtras; + }; + 84DCA5172BABB77E00792720 /* FoundationExtras */ = { + isa = XCSwiftPackageProductDependency; + productName = FoundationExtras; + }; + 84DCA5192BABB78700792720 /* AppKitExtras */ = { + isa = XCSwiftPackageProductDependency; + productName = AppKitExtras; + }; + 84DCA51B2BABB78E00792720 /* CloudKitExtras */ = { + isa = XCSwiftPackageProductDependency; + productName = CloudKitExtras; + }; + 84DCA51D2BABB79900792720 /* FoundationExtras */ = { + isa = XCSwiftPackageProductDependency; + productName = FoundationExtras; + }; + 84DCA51F2BABB7A200792720 /* UIKitExtras */ = { + isa = XCSwiftPackageProductDependency; + productName = UIKitExtras; + }; + 84DCA5212BABB7A800792720 /* CloudKitExtras */ = { + isa = XCSwiftPackageProductDependency; + productName = CloudKitExtras; + }; + 84DCA5242BABBB5A00792720 /* Core */ = { + isa = XCSwiftPackageProductDependency; + productName = Core; + }; + 84DCA5262BABBB6200792720 /* Core */ = { + isa = XCSwiftPackageProductDependency; + productName = Core; + }; + 84DCA5282BABBB6A00792720 /* Core */ = { + isa = XCSwiftPackageProductDependency; + productName = Core; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 849C64581ED37A5D003D8FC0 /* Project object */; diff --git a/NetNewsWire.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/NetNewsWire.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 8fdd73a70..800ed313e 100644 --- a/NetNewsWire.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/NetNewsWire.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,70 +1,59 @@ { - "object": { - "pins": [ - { - "package": "PLCrashReporter", - "repositoryURL": "https://github.com/microsoft/plcrashreporter.git", - "state": { - "branch": null, - "revision": "b1a342da19ed9b3af61ea2efa7656c2af30aeb7c", - "version": "1.11.0" - } - }, - { - "package": "RSCore", - "repositoryURL": "https://github.com/Ranchero-Software/RSCore.git", - "state": { - "branch": null, - "revision": "060b12a3d3b6d27d57b2fae84160bfec91ec7118", - "version": "1.0.7" - } - }, - { - "package": "RSParser", - "repositoryURL": "https://github.com/Ranchero-Software/RSParser.git", - "state": { - "branch": null, - "revision": "d5b50ff78905ebfaf26dd698e0e5d3ed8269dd9b", - "version": "2.0.3" - } - }, - { - "package": "RSTree", - "repositoryURL": "https://github.com/Ranchero-Software/RSTree.git", - "state": { - "branch": null, - "revision": "9d051f42cfc4faa991fd79cdb32e4cc8c545e334", - "version": "1.0.0" - } - }, - { - "package": "RSWeb", - "repositoryURL": "https://github.com/Ranchero-Software/RSWeb.git", - "state": { - "branch": null, - "revision": "2f7849a9ad2cb461b3d6c9c920e163596e6b5d7b", - "version": "1.0.3" - } - }, - { - "package": "RSSparkle", - "repositoryURL": "https://github.com/Ranchero-Software/Sparkle-Binary.git", - "state": { - "branch": null, - "revision": "d1a8b3c98d96c601453f2e4230f1dd65b60d0581", - "version": "2.0.1" - } - }, - { - "package": "Zip", - "repositoryURL": "https://github.com/marmelroy/Zip.git", - "state": { - "branch": null, - "revision": "059e7346082d02de16220cd79df7db18ddeba8c3", - "version": null - } + "originHash" : "4d91e68a1cd512b0fa806978f5c3b759c8c6defc0a648d1e0fb0db9e944c07c1", + "pins" : [ + { + "identity" : "plcrashreporter", + "kind" : "remoteSourceControl", + "location" : "https://github.com/microsoft/plcrashreporter.git", + "state" : { + "revision" : "b1a342da19ed9b3af61ea2efa7656c2af30aeb7c", + "version" : "1.11.0" } - ] - }, - "version": 1 + }, + { + "identity" : "rsparser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Ranchero-Software/RSParser.git", + "state" : { + "revision" : "d5b50ff78905ebfaf26dd698e0e5d3ed8269dd9b", + "version" : "2.0.3" + } + }, + { + "identity" : "rstree", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Ranchero-Software/RSTree.git", + "state" : { + "revision" : "9d051f42cfc4faa991fd79cdb32e4cc8c545e334", + "version" : "1.0.0" + } + }, + { + "identity" : "rsweb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Ranchero-Software/RSWeb.git", + "state" : { + "revision" : "2f7849a9ad2cb461b3d6c9c920e163596e6b5d7b", + "version" : "1.0.3" + } + }, + { + "identity" : "sparkle-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Ranchero-Software/Sparkle-Binary.git", + "state" : { + "revision" : "d1a8b3c98d96c601453f2e4230f1dd65b60d0581", + "version" : "2.0.1" + } + }, + { + "identity" : "zip", + "kind" : "remoteSourceControl", + "location" : "https://github.com/marmelroy/Zip.git", + "state" : { + "revision" : "059e7346082d02de16220cd79df7db18ddeba8c3" + } + } + ], + "version" : 3 } diff --git a/Shared/Activity/ActivityManager.swift b/Shared/Activity/ActivityManager.swift index 0ba584c49..3c1046e5c 100644 --- a/Shared/Activity/ActivityManager.swift +++ b/Shared/Activity/ActivityManager.swift @@ -8,7 +8,6 @@ import Foundation import CoreServices -import RSCore import Account import Articles import Intents diff --git a/Shared/Article Rendering/ArticleRenderer.swift b/Shared/Article Rendering/ArticleRenderer.swift index b6b7d3a40..3c1fd0c00 100644 --- a/Shared/Article Rendering/ArticleRenderer.swift +++ b/Shared/Article Rendering/ArticleRenderer.swift @@ -10,9 +10,9 @@ import Foundation #if os(iOS) import UIKit #endif -import RSCore import Articles import Account +import Core @MainActor struct ArticleRenderer { diff --git a/Shared/ArticleStyles/ArticleThemesManager.swift b/Shared/ArticleStyles/ArticleThemesManager.swift index ff0365e9f..8c31e855c 100644 --- a/Shared/ArticleStyles/ArticleThemesManager.swift +++ b/Shared/ArticleStyles/ArticleThemesManager.swift @@ -7,7 +7,6 @@ // import Foundation -import RSCore public extension Notification.Name { static let ArticleThemeNamesDidChangeNotification = Notification.Name("ArticleThemeNamesDidChangeNotification") diff --git a/Shared/Commands/DeleteCommand.swift b/Shared/Commands/DeleteCommand.swift index 1c3fea02e..9b0dbede0 100644 --- a/Shared/Commands/DeleteCommand.swift +++ b/Shared/Commands/DeleteCommand.swift @@ -7,10 +7,10 @@ // import Foundation -import RSCore import RSTree import Account import Articles +import Core final class DeleteCommand: UndoableCommand { diff --git a/Shared/Commands/MarkStatusCommand.swift b/Shared/Commands/MarkStatusCommand.swift index 999fe5a3f..404a38ec5 100644 --- a/Shared/Commands/MarkStatusCommand.swift +++ b/Shared/Commands/MarkStatusCommand.swift @@ -7,8 +7,8 @@ // import Foundation -import RSCore import Articles +import Core // Mark articles read/unread, starred/unstarred, deleted/undeleted. diff --git a/Shared/Exporters/OPMLExporter.swift b/Shared/Exporters/OPMLExporter.swift index cba135a17..d1158958e 100644 --- a/Shared/Exporters/OPMLExporter.swift +++ b/Shared/Exporters/OPMLExporter.swift @@ -8,7 +8,6 @@ import Foundation import Account -import RSCore struct OPMLExporter { diff --git a/Shared/ExtensionPoints/SendToMarsEditCommand.swift b/Shared/ExtensionPoints/SendToMarsEditCommand.swift index 926dda93f..560d32959 100644 --- a/Shared/ExtensionPoints/SendToMarsEditCommand.swift +++ b/Shared/ExtensionPoints/SendToMarsEditCommand.swift @@ -7,8 +7,9 @@ // import AppKit -import RSCore import Articles +import Core +import AppKitExtras final class SendToMarsEditCommand: SendToCommand { diff --git a/Shared/ExtensionPoints/SendToMicroBlogCommand.swift b/Shared/ExtensionPoints/SendToMicroBlogCommand.swift index 62139e980..897e00959 100644 --- a/Shared/ExtensionPoints/SendToMicroBlogCommand.swift +++ b/Shared/ExtensionPoints/SendToMicroBlogCommand.swift @@ -8,7 +8,8 @@ import AppKit import Articles -import RSCore +import Core +import AppKitExtras // Not undoable. diff --git a/Shared/Extensions/ArticleUtilities.swift b/Shared/Extensions/ArticleUtilities.swift index cf1975718..4e6bddc14 100644 --- a/Shared/Extensions/ArticleUtilities.swift +++ b/Shared/Extensions/ArticleUtilities.swift @@ -7,7 +7,6 @@ // import Foundation -import RSCore import Articles import Account diff --git a/Shared/Extensions/IconImage.swift b/Shared/Extensions/IconImage.swift index e460e4b6b..654985fb0 100644 --- a/Shared/Extensions/IconImage.swift +++ b/Shared/Extensions/IconImage.swift @@ -11,8 +11,7 @@ import AppKit #else import UIKit #endif - -import RSCore +import Core final class IconImage { diff --git a/Shared/Extensions/Node-Extensions.swift b/Shared/Extensions/Node-Extensions.swift index 14839f8e9..fa44efabf 100644 --- a/Shared/Extensions/Node-Extensions.swift +++ b/Shared/Extensions/Node-Extensions.swift @@ -9,7 +9,7 @@ import Foundation import RSTree import Articles -import RSCore +import Core extension Array where Element == Node { diff --git a/Shared/Extensions/RSImage-AppIcons.swift b/Shared/Extensions/RSImage-AppIcons.swift index df5670732..04964d85d 100644 --- a/Shared/Extensions/RSImage-AppIcons.swift +++ b/Shared/Extensions/RSImage-AppIcons.swift @@ -7,7 +7,7 @@ // import Foundation -import RSCore +import Core extension RSImage { static var appIconImage: RSImage? { diff --git a/Shared/Extensions/RSImage-Extensions.swift b/Shared/Extensions/RSImage-Extensions.swift index 94ae44892..c45067c89 100644 --- a/Shared/Extensions/RSImage-Extensions.swift +++ b/Shared/Extensions/RSImage-Extensions.swift @@ -6,14 +6,12 @@ // Copyright © 2019 Ranchero Software. All rights reserved. // -import RSCore #if os(macOS) import AppKit #else import UIKit #endif - -import RSCore +import Core extension RSImage { diff --git a/Shared/Extensions/SmallIconProvider.swift b/Shared/Extensions/SmallIconProvider.swift index ca71ba44d..bd935cd72 100644 --- a/Shared/Extensions/SmallIconProvider.swift +++ b/Shared/Extensions/SmallIconProvider.swift @@ -9,7 +9,6 @@ import Foundation import Articles import Account -import RSCore protocol SmallIconProvider { diff --git a/Shared/Extensions/URL-Extensions.swift b/Shared/Extensions/URL-Extensions.swift index dd96df60e..c38ecb973 100644 --- a/Shared/Extensions/URL-Extensions.swift +++ b/Shared/Extensions/URL-Extensions.swift @@ -27,7 +27,7 @@ extension URL { } /// Reverse chronological list of release notes. - static var releaseNotes = URL(string: "https://github.com/Ranchero-Software/NetNewsWire/releases/")! + static let releaseNotes = URL(string: "https://github.com/Ranchero-Software/NetNewsWire/releases/")! func valueFor(_ parameter: String) -> String? { guard let components = URLComponents(url: self, resolvingAgainstBaseURL: false), diff --git a/Shared/Favicons/FaviconDownloader.swift b/Shared/Favicons/FaviconDownloader.swift index 1bc77d2ea..9f538cebf 100644 --- a/Shared/Favicons/FaviconDownloader.swift +++ b/Shared/Favicons/FaviconDownloader.swift @@ -10,8 +10,8 @@ import Foundation import CoreServices import Articles import Account -import RSCore import UniformTypeIdentifiers +import Core extension Notification.Name { diff --git a/Shared/Favicons/FaviconGenerator.swift b/Shared/Favicons/FaviconGenerator.swift index b68277eef..94e866750 100644 --- a/Shared/Favicons/FaviconGenerator.swift +++ b/Shared/Favicons/FaviconGenerator.swift @@ -7,7 +7,6 @@ // import Foundation -import RSCore import Account @MainActor final class FaviconGenerator { diff --git a/Shared/Favicons/SingleFaviconDownloader.swift b/Shared/Favicons/SingleFaviconDownloader.swift index 1b82802f6..aacde51f2 100644 --- a/Shared/Favicons/SingleFaviconDownloader.swift +++ b/Shared/Favicons/SingleFaviconDownloader.swift @@ -8,8 +8,9 @@ import Foundation import os.log -import RSCore import RSWeb +import FoundationExtras +import Core // The image may be on disk already. If not, download it. // Post .DidLoadFavicon notification once it’s in memory. diff --git a/Shared/Images/AuthorAvatarDownloader.swift b/Shared/Images/AuthorAvatarDownloader.swift index c69077c0d..82efc8471 100644 --- a/Shared/Images/AuthorAvatarDownloader.swift +++ b/Shared/Images/AuthorAvatarDownloader.swift @@ -8,7 +8,7 @@ import Foundation import Articles -import RSCore +import Core extension Notification.Name { diff --git a/Shared/Images/FeaturedImageDownloader.swift b/Shared/Images/FeaturedImageDownloader.swift index 2fdf13e7f..f9711d9cc 100644 --- a/Shared/Images/FeaturedImageDownloader.swift +++ b/Shared/Images/FeaturedImageDownloader.swift @@ -8,7 +8,6 @@ import Foundation import Articles -import RSCore import RSParser final class FeaturedImageDownloader { diff --git a/Shared/Images/FeedIconDownloader.swift b/Shared/Images/FeedIconDownloader.swift index c18b179ff..ae9a3444a 100644 --- a/Shared/Images/FeedIconDownloader.swift +++ b/Shared/Images/FeedIconDownloader.swift @@ -9,9 +9,9 @@ import Foundation import Articles import Account -import RSCore import RSWeb import RSParser +import Core extension Notification.Name { diff --git a/Shared/Images/ImageDownloader.swift b/Shared/Images/ImageDownloader.swift index 9a549d3ef..f2d3d6898 100644 --- a/Shared/Images/ImageDownloader.swift +++ b/Shared/Images/ImageDownloader.swift @@ -8,8 +8,9 @@ import Foundation import os.log -import RSCore import RSWeb +import FoundationExtras +import Core extension Notification.Name { diff --git a/Shared/Importers/DefaultFeedsImporter.swift b/Shared/Importers/DefaultFeedsImporter.swift index d02b2cec3..1df737255 100644 --- a/Shared/Importers/DefaultFeedsImporter.swift +++ b/Shared/Importers/DefaultFeedsImporter.swift @@ -8,7 +8,6 @@ import Foundation import Account -import RSCore @MainActor struct DefaultFeedsImporter { diff --git a/Shared/ShareExtension/ExtensionContainersFile.swift b/Shared/ShareExtension/ExtensionContainersFile.swift index 0e97a45d9..7906a77ed 100644 --- a/Shared/ShareExtension/ExtensionContainersFile.swift +++ b/Shared/ShareExtension/ExtensionContainersFile.swift @@ -8,9 +8,9 @@ import Foundation import os.log -import RSCore import RSParser import Account +import Core final class ExtensionContainersFile { diff --git a/Shared/SidebarItem/SidebarItem.swift b/Shared/SidebarItem/SidebarItem.swift index b58ea77bf..a4bf8771b 100644 --- a/Shared/SidebarItem/SidebarItem.swift +++ b/Shared/SidebarItem/SidebarItem.swift @@ -7,8 +7,8 @@ // import Foundation -import RSCore import Account +import Core enum ReadFilterType { case read diff --git a/Shared/SmartFeeds/PseudoFeed.swift b/Shared/SmartFeeds/PseudoFeed.swift index 94436c66b..c3a3be092 100644 --- a/Shared/SmartFeeds/PseudoFeed.swift +++ b/Shared/SmartFeeds/PseudoFeed.swift @@ -11,7 +11,7 @@ import AppKit import Articles import Account -import RSCore +import AppKitExtras protocol PseudoFeed: AnyObject, SidebarItem, SmallIconProvider, PasteboardWriterOwner { @@ -22,7 +22,6 @@ protocol PseudoFeed: AnyObject, SidebarItem, SmallIconProvider, PasteboardWriter import UIKit import Articles import Account -import RSCore protocol PseudoFeed: AnyObject, SidebarItem, SmallIconProvider { diff --git a/Shared/SmartFeeds/SearchFeedDelegate.swift b/Shared/SmartFeeds/SearchFeedDelegate.swift index cc8060725..5c6aa685a 100644 --- a/Shared/SmartFeeds/SearchFeedDelegate.swift +++ b/Shared/SmartFeeds/SearchFeedDelegate.swift @@ -7,7 +7,6 @@ // import Foundation -import RSCore import Account import Articles import ArticlesDatabase diff --git a/Shared/SmartFeeds/SearchTimelineFeedDelegate.swift b/Shared/SmartFeeds/SearchTimelineFeedDelegate.swift index 53acc7a50..2c4a5965f 100644 --- a/Shared/SmartFeeds/SearchTimelineFeedDelegate.swift +++ b/Shared/SmartFeeds/SearchTimelineFeedDelegate.swift @@ -7,7 +7,6 @@ // import Foundation -import RSCore import Account import Articles import ArticlesDatabase diff --git a/Shared/SmartFeeds/SmartFeed.swift b/Shared/SmartFeeds/SmartFeed.swift index 54817ca66..c37889c33 100644 --- a/Shared/SmartFeeds/SmartFeed.swift +++ b/Shared/SmartFeeds/SmartFeed.swift @@ -7,11 +7,11 @@ // import Foundation -import RSCore import Articles import ArticlesDatabase import Account import Database +import Core final class SmartFeed: PseudoFeed { diff --git a/Shared/SmartFeeds/SmartFeedDelegate.swift b/Shared/SmartFeeds/SmartFeedDelegate.swift index 062786998..87736efd6 100644 --- a/Shared/SmartFeeds/SmartFeedDelegate.swift +++ b/Shared/SmartFeeds/SmartFeedDelegate.swift @@ -10,8 +10,8 @@ import Foundation import Account import Articles import ArticlesDatabase -import RSCore import Database +import Core protocol SmartFeedDelegate: SidebarItemIdentifiable, DisplayNameProvider, ArticleFetcher, SmallIconProvider { diff --git a/Shared/SmartFeeds/SmartFeedPasteboardWriter.swift b/Shared/SmartFeeds/SmartFeedPasteboardWriter.swift index 139f120d9..825e7f66e 100644 --- a/Shared/SmartFeeds/SmartFeedPasteboardWriter.swift +++ b/Shared/SmartFeeds/SmartFeedPasteboardWriter.swift @@ -8,7 +8,6 @@ import AppKit import Account -import RSCore @objc final class SmartFeedPasteboardWriter: NSObject, NSPasteboardWriting { diff --git a/Shared/SmartFeeds/SmartFeedsController.swift b/Shared/SmartFeeds/SmartFeedsController.swift index 8ff75f510..56e1dce82 100644 --- a/Shared/SmartFeeds/SmartFeedsController.swift +++ b/Shared/SmartFeeds/SmartFeedsController.swift @@ -7,8 +7,8 @@ // import Foundation -import RSCore import Account +import Core final class SmartFeedsController: DisplayNameProvider, ContainerIdentifiable { diff --git a/Shared/SmartFeeds/StarredFeedDelegate.swift b/Shared/SmartFeeds/StarredFeedDelegate.swift index 1eae46c08..bd0db9544 100644 --- a/Shared/SmartFeeds/StarredFeedDelegate.swift +++ b/Shared/SmartFeeds/StarredFeedDelegate.swift @@ -7,7 +7,6 @@ // import Foundation -import RSCore import Articles import ArticlesDatabase import Account diff --git a/Shared/SmartFeeds/TodayFeedDelegate.swift b/Shared/SmartFeeds/TodayFeedDelegate.swift index 06f215ef5..ad510b0e0 100644 --- a/Shared/SmartFeeds/TodayFeedDelegate.swift +++ b/Shared/SmartFeeds/TodayFeedDelegate.swift @@ -7,7 +7,6 @@ // import Foundation -import RSCore import Articles import ArticlesDatabase import Account diff --git a/Shared/SmartFeeds/UnreadFeed.swift b/Shared/SmartFeeds/UnreadFeed.swift index f90843b01..0ece229c3 100644 --- a/Shared/SmartFeeds/UnreadFeed.swift +++ b/Shared/SmartFeeds/UnreadFeed.swift @@ -11,7 +11,6 @@ import AppKit #else import Foundation #endif -import RSCore import Account import Articles import ArticlesDatabase diff --git a/Shared/Timeline/FetchRequestOperation.swift b/Shared/Timeline/FetchRequestOperation.swift index 86f636e0b..69b118050 100644 --- a/Shared/Timeline/FetchRequestOperation.swift +++ b/Shared/Timeline/FetchRequestOperation.swift @@ -7,7 +7,6 @@ // import Foundation -import RSCore import Account import Articles import Database diff --git a/Shared/Tree/FolderTreeControllerDelegate.swift b/Shared/Tree/FolderTreeControllerDelegate.swift index 4051a42e4..be06ca83d 100644 --- a/Shared/Tree/FolderTreeControllerDelegate.swift +++ b/Shared/Tree/FolderTreeControllerDelegate.swift @@ -7,7 +7,6 @@ // import Foundation -import RSCore import RSTree import Articles import Account diff --git a/Shared/Widget/WidgetDataEncoder.swift b/Shared/Widget/WidgetDataEncoder.swift index fe88389b7..179fa46f0 100644 --- a/Shared/Widget/WidgetDataEncoder.swift +++ b/Shared/Widget/WidgetDataEncoder.swift @@ -10,7 +10,6 @@ import Foundation import WidgetKit import os.log import UIKit -import RSCore import Articles import Account diff --git a/SyncDatabase/Package.swift b/SyncDatabase/Package.swift index 1380b8804..66f5e35dd 100644 --- a/SyncDatabase/Package.swift +++ b/SyncDatabase/Package.swift @@ -2,7 +2,6 @@ import PackageDescription var dependencies: [Package.Dependency] = [ - .package(url: "https://github.com/Ranchero-Software/RSCore.git", .upToNextMinor(from: "1.0.0")), ] #if swift(>=5.6) @@ -31,7 +30,6 @@ let package = Package( .target( name: "SyncDatabase", dependencies: [ - "RSCore", "Database", "Articles", "FMDB" diff --git a/SyncDatabase/Sources/SyncDatabase/SyncDatabase.swift b/SyncDatabase/Sources/SyncDatabase/SyncDatabase.swift index 9a9937265..644d32ed3 100644 --- a/SyncDatabase/Sources/SyncDatabase/SyncDatabase.swift +++ b/SyncDatabase/Sources/SyncDatabase/SyncDatabase.swift @@ -7,7 +7,6 @@ // import Foundation -import RSCore import Database import FMDB diff --git a/SyncDatabase/Sources/SyncDatabase/SyncStatusTable.swift b/SyncDatabase/Sources/SyncDatabase/SyncStatusTable.swift index fb8067d45..62349a0c5 100644 --- a/SyncDatabase/Sources/SyncDatabase/SyncStatusTable.swift +++ b/SyncDatabase/Sources/SyncDatabase/SyncStatusTable.swift @@ -7,7 +7,6 @@ // import Foundation -import RSCore import Articles import Database import FMDB diff --git a/UIKitExtras/.gitignore b/UIKitExtras/.gitignore new file mode 100644 index 000000000..0023a5340 --- /dev/null +++ b/UIKitExtras/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/UIKitExtras/Package.swift b/UIKitExtras/Package.swift new file mode 100644 index 000000000..0e8096664 --- /dev/null +++ b/UIKitExtras/Package.swift @@ -0,0 +1,24 @@ +// swift-tools-version: 5.10 + +import PackageDescription + +let package = Package( + name: "UIKitExtras", + platforms: [.macOS(.v14), .iOS(.v17)], + products: [ + .library( + name: "UIKitExtras", + targets: ["UIKitExtras"]), + ], + targets: [ + .target( + name: "UIKitExtras", + swiftSettings: [ + .enableExperimentalFeature("StrictConcurrency") + ] + ), + .testTarget( + name: "UIKitExtrasTests", + dependencies: ["UIKitExtras"]), + ] +) diff --git a/UIKitExtras/Sources/UIKitExtras/UIResponder+RSCore.swift b/UIKitExtras/Sources/UIKitExtras/UIResponder+RSCore.swift new file mode 100644 index 000000000..a4f334321 --- /dev/null +++ b/UIKitExtras/Sources/UIKitExtras/UIResponder+RSCore.swift @@ -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 diff --git a/UIKitExtras/Sources/UIKitExtras/UIView+RSCore.swift b/UIKitExtras/Sources/UIKitExtras/UIView+RSCore.swift new file mode 100644 index 000000000..f3b2892b4 --- /dev/null +++ b/UIKitExtras/Sources/UIKitExtras/UIView+RSCore.swift @@ -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 diff --git a/UIKitExtras/Sources/UIKitExtras/UIViewController+RSCore.swift b/UIKitExtras/Sources/UIKitExtras/UIViewController+RSCore.swift new file mode 100644 index 000000000..a4945c594 --- /dev/null +++ b/UIKitExtras/Sources/UIKitExtras/UIViewController+RSCore.swift @@ -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(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 diff --git a/UIKitExtras/Sources/UIKitExtras/UIWindow+RSCore.swift b/UIKitExtras/Sources/UIKitExtras/UIWindow+RSCore.swift new file mode 100644 index 000000000..5c45f8fc1 --- /dev/null +++ b/UIKitExtras/Sources/UIKitExtras/UIWindow+RSCore.swift @@ -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 diff --git a/UIKitExtras/Tests/UIKitExtrasTests/UIKitExtrasTests.swift b/UIKitExtras/Tests/UIKitExtrasTests/UIKitExtrasTests.swift new file mode 100644 index 000000000..3bf64b147 --- /dev/null +++ b/UIKitExtras/Tests/UIKitExtrasTests/UIKitExtrasTests.swift @@ -0,0 +1,12 @@ +import XCTest +@testable import UIKitExtras + +final class UIKitExtrasTests: XCTestCase { + func testExample() throws { + // XCTest Documentation + // https://developer.apple.com/documentation/xctest + + // Defining Test Cases and Test Methods + // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods + } +} diff --git a/iOS/Add/AddFeedFolderViewController.swift b/iOS/Add/AddFeedFolderViewController.swift index 42b560fe3..56ee04c64 100644 --- a/iOS/Add/AddFeedFolderViewController.swift +++ b/iOS/Add/AddFeedFolderViewController.swift @@ -7,8 +7,8 @@ // import UIKit -import RSCore import Account +import Core protocol AddFeedFolderViewControllerDelegate { func didSelect(container: Container) diff --git a/iOS/Add/AddFeedViewController.swift b/iOS/Add/AddFeedViewController.swift index ef862a4d4..8c7afd699 100644 --- a/iOS/Add/AddFeedViewController.swift +++ b/iOS/Add/AddFeedViewController.swift @@ -8,7 +8,6 @@ import UIKit import Account -import RSCore import RSTree import RSParser diff --git a/iOS/Add/AddFolderViewController.swift b/iOS/Add/AddFolderViewController.swift index 528e443c9..07d0b1450 100644 --- a/iOS/Add/AddFolderViewController.swift +++ b/iOS/Add/AddFolderViewController.swift @@ -8,7 +8,7 @@ import UIKit import Account -import RSCore +import Core class AddFolderViewController: UITableViewController { diff --git a/iOS/AppAssets.swift b/iOS/AppAssets.swift index 3eb012dd1..60d26cc61 100644 --- a/iOS/AppAssets.swift +++ b/iOS/AppAssets.swift @@ -5,9 +5,10 @@ // Created by Maurice Parker on 4/8/19. // Copyright © 2019 Ranchero Software. All rights reserved. // + import UIKit -import RSCore import Account +import Core struct AppAssets { diff --git a/iOS/AppDelegate.swift b/iOS/AppDelegate.swift index 27f97f2d0..3a887d9d3 100644 --- a/iOS/AppDelegate.swift +++ b/iOS/AppDelegate.swift @@ -7,13 +7,13 @@ // import UIKit -import RSCore import RSWeb import Account import BackgroundTasks import os.log import Secrets import WidgetKit +import Core var appDelegate: AppDelegate! diff --git a/iOS/Article/WebViewController.swift b/iOS/Article/WebViewController.swift index 595d6ba9e..668b6548d 100644 --- a/iOS/Article/WebViewController.swift +++ b/iOS/Article/WebViewController.swift @@ -8,11 +8,11 @@ import UIKit import WebKit -import RSCore import Account import Articles import SafariServices import MessageUI +import Core protocol WebViewControllerDelegate: AnyObject { func webViewController(_: WebViewController, articleExtractorButtonStateDidUpdate: ArticleExtractorButtonState) diff --git a/iOS/ErrorHandler.swift b/iOS/ErrorHandler.swift index f726cd0ce..94d4fae36 100644 --- a/iOS/ErrorHandler.swift +++ b/iOS/ErrorHandler.swift @@ -7,7 +7,6 @@ // import UIKit -import RSCore import os.log struct ErrorHandler { diff --git a/iOS/Feeds/Cell/FeedTableViewCell.swift b/iOS/Feeds/Cell/FeedTableViewCell.swift index ba9c7f8c4..9c647faf4 100644 --- a/iOS/Feeds/Cell/FeedTableViewCell.swift +++ b/iOS/Feeds/Cell/FeedTableViewCell.swift @@ -7,7 +7,6 @@ // import UIKit -import RSCore import Account import RSTree diff --git a/iOS/Feeds/Cell/FeedTableViewCellLayout.swift b/iOS/Feeds/Cell/FeedTableViewCellLayout.swift index 6ecb4d8a6..fb28690bd 100644 --- a/iOS/Feeds/Cell/FeedTableViewCellLayout.swift +++ b/iOS/Feeds/Cell/FeedTableViewCellLayout.swift @@ -7,7 +7,6 @@ // import UIKit -import RSCore struct FeedTableViewCellLayout { diff --git a/iOS/Feeds/Cell/FeedTableViewSectionHeader.swift b/iOS/Feeds/Cell/FeedTableViewSectionHeader.swift index ff48a475e..d94adc4ef 100644 --- a/iOS/Feeds/Cell/FeedTableViewSectionHeader.swift +++ b/iOS/Feeds/Cell/FeedTableViewSectionHeader.swift @@ -7,6 +7,7 @@ // import UIKit +import UIKitExtras protocol FeedTableViewSectionHeaderDelegate { func FeedTableViewSectionHeaderDisclosureDidToggle(_ sender: FeedTableViewSectionHeader) diff --git a/iOS/Feeds/Cell/FeedTableViewSectionHeaderLayout.swift b/iOS/Feeds/Cell/FeedTableViewSectionHeaderLayout.swift index 1c0dd8f55..5497a0fc3 100644 --- a/iOS/Feeds/Cell/FeedTableViewSectionHeaderLayout.swift +++ b/iOS/Feeds/Cell/FeedTableViewSectionHeaderLayout.swift @@ -7,7 +7,6 @@ // import UIKit -import RSCore @MainActor struct FeedTableViewSectionHeaderLayout { diff --git a/iOS/Feeds/FeedsViewController+Drop.swift b/iOS/Feeds/FeedsViewController+Drop.swift index c8ef1986d..1bf52e5a4 100644 --- a/iOS/Feeds/FeedsViewController+Drop.swift +++ b/iOS/Feeds/FeedsViewController+Drop.swift @@ -7,7 +7,6 @@ // import UIKit -import RSCore import Account import RSTree diff --git a/iOS/Feeds/SidebarViewController.swift b/iOS/Feeds/SidebarViewController.swift index 70d6ed31e..2a05ea521 100644 --- a/iOS/Feeds/SidebarViewController.swift +++ b/iOS/Feeds/SidebarViewController.swift @@ -9,9 +9,9 @@ import UIKit import Account import Articles -import RSCore import RSTree import SafariServices +import Core class SidebarViewController: UITableViewController, UndoableCommandRunner { diff --git a/iOS/SceneCoordinator.swift b/iOS/SceneCoordinator.swift index f6394a824..7b03a1c58 100644 --- a/iOS/SceneCoordinator.swift +++ b/iOS/SceneCoordinator.swift @@ -10,7 +10,6 @@ import UIKit import UserNotifications import Account import Articles -import RSCore import RSTree import SafariServices import SwiftUI diff --git a/iOS/Settings/AddAccountViewController.swift b/iOS/Settings/AddAccountViewController.swift index 2998b0684..be930ca05 100644 --- a/iOS/Settings/AddAccountViewController.swift +++ b/iOS/Settings/AddAccountViewController.swift @@ -8,7 +8,6 @@ import Account import UIKit -import RSCore protocol AddAccountDismissDelegate: UIViewController { func dismiss() diff --git a/iOS/ShareExtension/ShareFolderPickerController.swift b/iOS/ShareExtension/ShareFolderPickerController.swift index 7c1319060..b9a36d786 100644 --- a/iOS/ShareExtension/ShareFolderPickerController.swift +++ b/iOS/ShareExtension/ShareFolderPickerController.swift @@ -8,7 +8,6 @@ import UIKit import Account -import RSCore protocol ShareFolderPickerControllerDelegate: AnyObject { func shareFolderPickerDidSelect(_ container: ExtensionContainer) diff --git a/iOS/ShareExtension/ShareViewController.swift b/iOS/ShareExtension/ShareViewController.swift index e81d2772e..cabd6d5ee 100644 --- a/iOS/ShareExtension/ShareViewController.swift +++ b/iOS/ShareExtension/ShareViewController.swift @@ -10,7 +10,6 @@ import UIKit import MobileCoreServices import Account import Social -import RSCore import RSTree import UniformTypeIdentifiers diff --git a/iOS/Timeline/Cell/TimelineAccessibilityCellLayout.swift b/iOS/Timeline/Cell/TimelineAccessibilityCellLayout.swift index d7048db5d..e63d54756 100644 --- a/iOS/Timeline/Cell/TimelineAccessibilityCellLayout.swift +++ b/iOS/Timeline/Cell/TimelineAccessibilityCellLayout.swift @@ -7,7 +7,6 @@ // import UIKit -import RSCore struct TimelineAccessibilityCellLayout: TimelineCellLayout { diff --git a/iOS/Timeline/Cell/TimelineDefaultCellLayout.swift b/iOS/Timeline/Cell/TimelineDefaultCellLayout.swift index 1752bed81..f322d60c8 100644 --- a/iOS/Timeline/Cell/TimelineDefaultCellLayout.swift +++ b/iOS/Timeline/Cell/TimelineDefaultCellLayout.swift @@ -7,7 +7,6 @@ // import UIKit -import RSCore struct TimelineDefaultCellLayout: TimelineCellLayout { diff --git a/iOS/Timeline/Cell/TimelineTableViewCell.swift b/iOS/Timeline/Cell/TimelineTableViewCell.swift index 51ac893b6..1b5127a61 100644 --- a/iOS/Timeline/Cell/TimelineTableViewCell.swift +++ b/iOS/Timeline/Cell/TimelineTableViewCell.swift @@ -7,7 +7,6 @@ // import UIKit -import RSCore class TimelineTableViewCell: VibrantTableViewCell { diff --git a/iOS/Timeline/TimelineViewController.swift b/iOS/Timeline/TimelineViewController.swift index 0933198e8..7154db2c3 100644 --- a/iOS/Timeline/TimelineViewController.swift +++ b/iOS/Timeline/TimelineViewController.swift @@ -7,9 +7,9 @@ // import UIKit -import RSCore import Account import Articles +import Core class TimelineViewController: UITableViewController, UndoableCommandRunner { diff --git a/iOS/UIKit Extensions/UIViewController-Extensions.swift b/iOS/UIKit Extensions/UIViewController-Extensions.swift index 1ed607502..cdbb03d6c 100644 --- a/iOS/UIKit Extensions/UIViewController-Extensions.swift +++ b/iOS/UIKit Extensions/UIViewController-Extensions.swift @@ -7,7 +7,6 @@ // import UIKit -import RSCore import Account extension UIViewController {