From 941bc4713e346db9ea0a790ee0925536dab3ed4f Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Mon, 20 Jun 2022 11:32:58 -0400 Subject: [PATCH] Add Recents widget --- .../Sources/Persistence/LocalData.swift | 6 + .../Persistence/PersistentContainer.swift | 4 +- .../Reader.xcdatamodel/contents | 1 + Reader.xcodeproj/project.pbxproj | 195 ++++++++++++++++- Reader/FervorController.swift | 2 + Reader/Info.plist | 13 +- Reader/SceneDelegate.swift | 33 +++ Reader/Screens/AppSplitViewController.swift | 1 + Reader/Widgets/WidgetData.swift | 53 +++++ Reader/Widgets/WidgetHelper.swift | 54 +++++ .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 13 ++ Widgets/Assets.xcassets/Contents.json | 6 + .../WidgetBackground.colorset/Contents.json | 11 + Widgets/Info.plist | 11 + Widgets/Recents.swift | 196 ++++++++++++++++++ Widgets/Widgets.intentdefinition | 61 ++++++ Widgets/Widgets.swift | 23 ++ WidgetsExtension.entitlements | 14 ++ 19 files changed, 698 insertions(+), 10 deletions(-) create mode 100644 Reader/Widgets/WidgetData.swift create mode 100644 Reader/Widgets/WidgetHelper.swift create mode 100644 Widgets/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 Widgets/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Widgets/Assets.xcassets/Contents.json create mode 100644 Widgets/Assets.xcassets/WidgetBackground.colorset/Contents.json create mode 100644 Widgets/Info.plist create mode 100644 Widgets/Recents.swift create mode 100644 Widgets/Widgets.intentdefinition create mode 100644 Widgets/Widgets.swift create mode 100644 WidgetsExtension.entitlements diff --git a/Persistence/Sources/Persistence/LocalData.swift b/Persistence/Sources/Persistence/LocalData.swift index 7899214..c746652 100644 --- a/Persistence/Sources/Persistence/LocalData.swift +++ b/Persistence/Sources/Persistence/LocalData.swift @@ -59,6 +59,12 @@ public struct LocalData { public let clientSecret: String public let token: Token + /// A filename-safe string for this account + public var persistenceKey: String { + // slashes the base64 string turn into subdirectories which we don't want + id.base64EncodedString().replacingOccurrences(of: "/", with: "_") + } + public init(instanceURL: URL, clientID: String, clientSecret: String, token: Token) { // we use a hash of instance host and account id rather than random ids so that // user activites can uniquely identify accounts across devices diff --git a/Persistence/Sources/Persistence/PersistentContainer.swift b/Persistence/Sources/Persistence/PersistentContainer.swift index fc0bf3c..06db8d8 100644 --- a/Persistence/Sources/Persistence/PersistentContainer.swift +++ b/Persistence/Sources/Persistence/PersistentContainer.swift @@ -27,9 +27,7 @@ public class PersistentContainer: NSPersistentContainer, @unchecked Sendable { private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "PersistentContainer") public init(account: LocalData.Account) { - // slashes the base64 string turn into subdirectories which we don't want - let name = account.id.base64EncodedString().replacingOccurrences(of: "/", with: "_") - super.init(name: name, managedObjectModel: PersistentContainer.managedObjectModel) + super.init(name: account.persistenceKey, managedObjectModel: PersistentContainer.managedObjectModel) let groupContainerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.net.shadowfacts.Reader")! let containerAppSupportURL = groupContainerURL diff --git a/Persistence/Sources/Persistence/Reader.xcdatamodeld/Reader.xcdatamodel/contents b/Persistence/Sources/Persistence/Reader.xcdatamodeld/Reader.xcdatamodel/contents index eecde3a..19f6ec5 100644 --- a/Persistence/Sources/Persistence/Reader.xcdatamodeld/Reader.xcdatamodel/contents +++ b/Persistence/Sources/Persistence/Reader.xcdatamodeld/Reader.xcdatamodel/contents @@ -29,6 +29,7 @@ + diff --git a/Reader.xcodeproj/project.pbxproj b/Reader.xcodeproj/project.pbxproj index 265e25d..4ce96fb 100644 --- a/Reader.xcodeproj/project.pbxproj +++ b/Reader.xcodeproj/project.pbxproj @@ -27,6 +27,9 @@ D6C68806272CD27700874C10 /* ReaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C68805272CD27700874C10 /* ReaderTests.swift */; }; D6C68810272CD27700874C10 /* ReaderUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C6880F272CD27700874C10 /* ReaderUITests.swift */; }; D6C68812272CD27700874C10 /* ReaderUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C68811272CD27700874C10 /* ReaderUITestsLaunchTests.swift */; }; + D6D5FA24285FE9DB00BBF188 /* WidgetHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D5FA23285FE9DB00BBF188 /* WidgetHelper.swift */; }; + D6D5FA27285FEA6900BBF188 /* WidgetData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D5FA26285FEA6900BBF188 /* WidgetData.swift */; }; + D6D5FA28285FEA8A00BBF188 /* WidgetData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D5FA26285FEA6900BBF188 /* WidgetData.swift */; }; D6E2434C278B456A0005E546 /* ItemsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E2434B278B456A0005E546 /* ItemsViewController.swift */; }; D6E24363278BA1410005E546 /* ItemCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E24361278BA1410005E546 /* ItemCollectionViewCell.swift */; }; D6E24369278BABB40005E546 /* UIColor+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E24368278BABB40005E546 /* UIColor+App.swift */; }; @@ -39,6 +42,15 @@ D6EEDE87285FA75D009F854E /* Fervor in Frameworks */ = {isa = PBXBuildFile; productRef = D6EEDE86285FA75D009F854E /* Fervor */; }; D6EEDE89285FA75F009F854E /* Persistence in Frameworks */ = {isa = PBXBuildFile; productRef = D6EEDE88285FA75F009F854E /* Persistence */; }; D6EEDE8B285FA7FD009F854E /* LocalData+Migration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EEDE8A285FA7FD009F854E /* LocalData+Migration.swift */; }; + D6EEDE92285FA915009F854E /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D6EEDE91285FA915009F854E /* WidgetKit.framework */; }; + D6EEDE94285FA915009F854E /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D6EEDE93285FA915009F854E /* SwiftUI.framework */; }; + D6EEDE97285FA915009F854E /* Widgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EEDE96285FA915009F854E /* Widgets.swift */; }; + D6EEDE9A285FA915009F854E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D6EEDE99285FA915009F854E /* Assets.xcassets */; }; + D6EEDE9C285FA915009F854E /* Widgets.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = D6EEDE98285FA915009F854E /* Widgets.intentdefinition */; }; + D6EEDE9D285FA915009F854E /* Widgets.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = D6EEDE98285FA915009F854E /* Widgets.intentdefinition */; }; + D6EEDEA0285FA915009F854E /* WidgetsExtension.appex in Embed PlugIns */ = {isa = PBXBuildFile; fileRef = D6EEDE90285FA915009F854E /* WidgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + D6EEDEA7285FAE4D009F854E /* Recents.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EEDEA5285FAD24009F854E /* Recents.swift */; }; + D6EEDEA9285FAE60009F854E /* Persistence in Frameworks */ = {isa = PBXBuildFile; productRef = D6EEDEA8285FAE60009F854E /* Persistence */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -63,6 +75,13 @@ remoteGlobalIDString = D6C687E7272CD27600874C10; remoteInfo = Reader; }; + D6EEDE9E285FA915009F854E /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = D6C687E0272CD27600874C10 /* Project object */; + proxyType = 1; + remoteGlobalIDString = D6EEDE8F285FA915009F854E; + remoteInfo = WidgetsExtension; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -72,6 +91,7 @@ dstPath = ""; dstSubfolderSpec = 13; files = ( + D6EEDEA0285FA915009F854E /* WidgetsExtension.appex in Embed PlugIns */, D6840914279487DC00E327D2 /* ReaderMac.bundle in Embed PlugIns */, ); name = "Embed PlugIns"; @@ -119,6 +139,8 @@ D6C6880B272CD27700874C10 /* ReaderUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ReaderUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D6C6880F272CD27700874C10 /* ReaderUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderUITests.swift; sourceTree = ""; }; D6C68811272CD27700874C10 /* ReaderUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderUITestsLaunchTests.swift; sourceTree = ""; }; + D6D5FA23285FE9DB00BBF188 /* WidgetHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetHelper.swift; sourceTree = ""; }; + D6D5FA26285FEA6900BBF188 /* WidgetData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetData.swift; sourceTree = ""; }; D6E2434B278B456A0005E546 /* ItemsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemsViewController.swift; sourceTree = ""; }; D6E24361278BA1410005E546 /* ItemCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemCollectionViewCell.swift; sourceTree = ""; }; D6E24368278BABB40005E546 /* UIColor+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+App.swift"; sourceTree = ""; }; @@ -128,6 +150,15 @@ D6EB531C278C89C300AD2E61 /* AppNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNavigationController.swift; sourceTree = ""; }; D6EB531E278E4A7500AD2E61 /* StretchyMenuInteraction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StretchyMenuInteraction.swift; sourceTree = ""; }; D6EEDE8A285FA7FD009F854E /* LocalData+Migration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LocalData+Migration.swift"; sourceTree = ""; }; + D6EEDE90285FA915009F854E /* WidgetsExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WidgetsExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + D6EEDE91285FA915009F854E /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; + D6EEDE93285FA915009F854E /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; + D6EEDE96285FA915009F854E /* Widgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Widgets.swift; sourceTree = ""; }; + D6EEDE98285FA915009F854E /* Widgets.intentdefinition */ = {isa = PBXFileReference; lastKnownFileType = file.intentdefinition; path = Widgets.intentdefinition; sourceTree = ""; }; + D6EEDE99285FA915009F854E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + D6EEDE9B285FA915009F854E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + D6EEDEA1285FA915009F854E /* WidgetsExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WidgetsExtension.entitlements; sourceTree = ""; }; + D6EEDEA5285FAD24009F854E /* Recents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Recents.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -162,6 +193,16 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D6EEDE8D285FA915009F854E /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + D6EEDEA9285FAE60009F854E /* Persistence in Frameworks */, + D6EEDE94285FA915009F854E /* SwiftUI.framework in Frameworks */, + D6EEDE92285FA915009F854E /* WidgetKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -216,6 +257,8 @@ isa = PBXGroup; children = ( D68B3037279099FD00E8B3FA /* liblolhtml.a */, + D6EEDE91285FA915009F854E /* WidgetKit.framework */, + D6EEDE93285FA915009F854E /* SwiftUI.framework */, ); name = Frameworks; sourceTree = ""; @@ -223,12 +266,14 @@ D6C687DF272CD27600874C10 = { isa = PBXGroup; children = ( + D6EEDEA1285FA915009F854E /* WidgetsExtension.entitlements */, D6AB5E9B285F706F00157F2F /* Persistence */, D6AB5E9A285F6FE100157F2F /* Fervor */, D6C687EA272CD27600874C10 /* Reader */, D6C68804272CD27700874C10 /* ReaderTests */, D6C6880E272CD27700874C10 /* ReaderUITests */, D6840911279486C400E327D2 /* ReaderMac */, + D6EEDE95285FA915009F854E /* Widgets */, D6C687E9272CD27600874C10 /* Products */, D68B302E278FDCE200E8B3FA /* Frameworks */, ); @@ -241,6 +286,7 @@ D6C68801272CD27700874C10 /* ReaderTests.xctest */, D6C6880B272CD27700874C10 /* ReaderUITests.xctest */, D684090D279486BF00E327D2 /* ReaderMac.bundle */, + D6EEDE90285FA915009F854E /* WidgetsExtension.appex */, ); name = Products; sourceTree = ""; @@ -261,6 +307,7 @@ D68408EE2794808E00E327D2 /* Preferences.swift */, D608238C27DE729E00D7D5F9 /* ItemListType.swift */, D6EEDE8A285FA7FD009F854E /* LocalData+Migration.swift */, + D6D5FA25285FEA5B00BBF188 /* Widgets */, D65B18AF2750468B004A9448 /* Screens */, D6C687F7272CD27700874C10 /* Assets.xcassets */, D6C687F9272CD27700874C10 /* LaunchScreen.storyboard */, @@ -288,6 +335,15 @@ path = ReaderUITests; sourceTree = ""; }; + D6D5FA25285FEA5B00BBF188 /* Widgets */ = { + isa = PBXGroup; + children = ( + D6D5FA23285FE9DB00BBF188 /* WidgetHelper.swift */, + D6D5FA26285FEA6900BBF188 /* WidgetData.swift */, + ); + path = Widgets; + sourceTree = ""; + }; D6E2434A278B455C0005E546 /* Items */ = { isa = PBXGroup; children = ( @@ -305,6 +361,18 @@ path = Read; sourceTree = ""; }; + D6EEDE95285FA915009F854E /* Widgets */ = { + isa = PBXGroup; + children = ( + D6EEDE96285FA915009F854E /* Widgets.swift */, + D6EEDEA5285FAD24009F854E /* Recents.swift */, + D6EEDE98285FA915009F854E /* Widgets.intentdefinition */, + D6EEDE99285FA915009F854E /* Assets.xcassets */, + D6EEDE9B285FA915009F854E /* Info.plist */, + ); + path = Widgets; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -340,6 +408,7 @@ ); dependencies = ( D6840916279487DC00E327D2 /* PBXTargetDependency */, + D6EEDE9F285FA915009F854E /* PBXTargetDependency */, ); name = Reader; packageProductDependencies = ( @@ -387,6 +456,26 @@ productReference = D6C6880B272CD27700874C10 /* ReaderUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; + D6EEDE8F285FA915009F854E /* WidgetsExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = D6EEDEA2285FA915009F854E /* Build configuration list for PBXNativeTarget "WidgetsExtension" */; + buildPhases = ( + D6EEDE8C285FA915009F854E /* Sources */, + D6EEDE8D285FA915009F854E /* Frameworks */, + D6EEDE8E285FA915009F854E /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = WidgetsExtension; + packageProductDependencies = ( + D6EEDEA8285FAE60009F854E /* Persistence */, + ); + productName = WidgetsExtension; + productReference = D6EEDE90285FA915009F854E /* WidgetsExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -394,7 +483,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1320; + LastSwiftUpdateCheck = 1400; LastUpgradeCheck = 1400; TargetAttributes = { D684090C279486BF00E327D2 = { @@ -412,6 +501,9 @@ CreatedOnToolsVersion = 13.2; TestTargetID = D6C687E7272CD27600874C10; }; + D6EEDE8F285FA915009F854E = { + CreatedOnToolsVersion = 14.0; + }; }; }; buildConfigurationList = D6C687E3272CD27600874C10 /* Build configuration list for PBXProject "Reader" */; @@ -434,6 +526,7 @@ D6C68800272CD27700874C10 /* ReaderTests */, D6C6880A272CD27700874C10 /* ReaderUITests */, D684090C279486BF00E327D2 /* ReaderMac */, + D6EEDE8F285FA915009F854E /* WidgetsExtension */, ); }; /* End PBXProject section */ @@ -471,6 +564,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D6EEDE8E285FA915009F854E /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D6EEDE9A285FA915009F854E /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -510,6 +611,7 @@ files = ( D608238D27DE729E00D7D5F9 /* ItemListType.swift in Sources */, D65B18B627504920004A9448 /* FervorController.swift in Sources */, + D6D5FA27285FEA6900BBF188 /* WidgetData.swift in Sources */, D68B304227932ED500E8B3FA /* UserActivities.swift in Sources */, D6C687EC272CD27600874C10 /* AppDelegate.swift in Sources */, D6E2436B278BB1880005E546 /* HomeCollectionViewCell.swift in Sources */, @@ -521,9 +623,11 @@ D6EB531F278E4A7500AD2E61 /* StretchyMenuInteraction.swift in Sources */, D68B30402792729A00E8B3FA /* AppSplitViewController.swift in Sources */, D65B18B22750469D004A9448 /* LoginViewController.swift in Sources */, + D6EEDE9D285FA915009F854E /* Widgets.intentdefinition in Sources */, D68408ED2794803D00E327D2 /* PrefsView.swift in Sources */, D68408EF2794808E00E327D2 /* Preferences.swift in Sources */, D68B303627907D9200E8B3FA /* ExcerptGenerator.swift in Sources */, + D6D5FA24285FE9DB00BBF188 /* WidgetHelper.swift in Sources */, D6E24363278BA1410005E546 /* ItemCollectionViewCell.swift in Sources */, D6E2436E278BD8160005E546 /* ReadViewController.swift in Sources */, D65B18C127505348004A9448 /* HomeViewController.swift in Sources */, @@ -548,6 +652,17 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D6EEDE8C285FA915009F854E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D6EEDE9C285FA915009F854E /* Widgets.intentdefinition in Sources */, + D6D5FA28285FEA8A00BBF188 /* WidgetData.swift in Sources */, + D6EEDEA7285FAE4D009F854E /* Recents.swift in Sources */, + D6EEDE97285FA915009F854E /* Widgets.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -567,6 +682,11 @@ target = D6C687E7272CD27600874C10 /* Reader */; targetProxy = D6C6880C272CD27700874C10 /* PBXContainerItemProxy */; }; + D6EEDE9F285FA915009F854E /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D6EEDE8F285FA915009F854E /* WidgetsExtension */; + targetProxy = D6EEDE9E285FA915009F854E /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -922,6 +1042,66 @@ }; name = Release; }; + D6EEDEA3285FA915009F854E /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_ENTITLEMENTS = WidgetsExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = V4WK9KR9U2; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Widgets/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Widgets; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = net.shadowfacts.Reader.Widgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SUPPORTS_MACCATALYST = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + D6EEDEA4285FA915009F854E /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_ENTITLEMENTS = WidgetsExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = V4WK9KR9U2; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Widgets/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Widgets; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = net.shadowfacts.Reader.Widgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SUPPORTS_MACCATALYST = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -970,6 +1150,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + D6EEDEA2285FA915009F854E /* Build configuration list for PBXNativeTarget "WidgetsExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D6EEDEA3285FA915009F854E /* Debug */, + D6EEDEA4285FA915009F854E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ @@ -997,6 +1186,10 @@ isa = XCSwiftPackageProductDependency; productName = Persistence; }; + D6EEDEA8285FAE60009F854E /* Persistence */ = { + isa = XCSwiftPackageProductDependency; + productName = Persistence; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = D6C687E0272CD27600874C10 /* Project object */; diff --git a/Reader/FervorController.swift b/Reader/FervorController.swift index a1e6d16..dda93c3 100644 --- a/Reader/FervorController.swift +++ b/Reader/FervorController.swift @@ -89,6 +89,8 @@ actor FervorController { setSyncState(.excerpts) await ExcerptGenerator.generateAll(self) + + await WidgetHelper.updateWidgetData(fervorController: self) } @MainActor diff --git a/Reader/Info.plist b/Reader/Info.plist index 1542fc1..a497217 100644 --- a/Reader/Info.plist +++ b/Reader/Info.plist @@ -4,14 +4,15 @@ NSUserActivityTypes - $(PRODUCT_BUNDLE_IDENTIFIER).activity.read-unread + $(PRODUCT_BUNDLE_IDENTIFIER).activity.activate-account + $(PRODUCT_BUNDLE_IDENTIFIER).activity.add-account + $(PRODUCT_BUNDLE_IDENTIFIER).activity.preferences $(PRODUCT_BUNDLE_IDENTIFIER).activity.read-all $(PRODUCT_BUNDLE_IDENTIFIER).activity.read-feed $(PRODUCT_BUNDLE_IDENTIFIER).activity.read-group $(PRODUCT_BUNDLE_IDENTIFIER).activity.read-item - $(PRODUCT_BUNDLE_IDENTIFIER).activity.preferences - $(PRODUCT_BUNDLE_IDENTIFIER).activity.add-account - $(PRODUCT_BUNDLE_IDENTIFIER).activity.activate-account + $(PRODUCT_BUNDLE_IDENTIFIER).activity.read-unread + ConfigurationIntent UIApplicationSceneManifest @@ -28,10 +29,10 @@ $(PRODUCT_MODULE_NAME).SceneDelegate - UISceneDelegateClassName - $(PRODUCT_MODULE_NAME).PrefsSceneDelegate UISceneConfigurationName prefs + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).PrefsSceneDelegate diff --git a/Reader/SceneDelegate.swift b/Reader/SceneDelegate.swift index 8701db9..c53134d 100644 --- a/Reader/SceneDelegate.swift +++ b/Reader/SceneDelegate.swift @@ -95,6 +95,9 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { Task(priority: .userInitiated) { await fervorController.syncReadToServer() } + Task(priority: .userInitiated) { + await WidgetHelper.updateWidgetData(fervorController: fervorController) + } } } @@ -119,6 +122,36 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } } + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + let url = URLContexts.first!.url + guard url.scheme == "reader", + url.host == "item", + let itemID = url.pathComponents.last else { + logger.error("Launched with unknown URL: \(url.absoluteString, privacy: .public)") + return + } + guard let split = window?.rootViewController as? AppSplitViewController else { + logger.error("Could not handle URL: missing split VC") + return + } + let req = Item.fetchRequest() + req.predicate = NSPredicate(format: "id = %@", itemID) + if let items = try? fervorController.persistentContainer.viewContext.fetch(req), + let item = items.first { + split.showItem(item) + Task { + await fervorController.markItem(item, read: true) + } + } else { + Task { + if let item = try? await fervorController.fetchItem(id: itemID) { + split.showItem(item) + await fervorController.markItem(item, read: true) + } + } + } + } + private func setupUI(from activity: NSUserActivity) async { guard let split = window?.rootViewController as? AppSplitViewController else { logger.error("Failed to setup UI for user activity: missing split VC") diff --git a/Reader/Screens/AppSplitViewController.swift b/Reader/Screens/AppSplitViewController.swift index eb3bb9d..1df944c 100644 --- a/Reader/Screens/AppSplitViewController.swift +++ b/Reader/Screens/AppSplitViewController.swift @@ -87,6 +87,7 @@ class AppSplitViewController: UISplitViewController { home.selectItem(type) } + @discardableResult func showItem(_ item: Item) -> ReadViewController { if traitCollection.horizontalSizeClass == .compact { let nav = viewController(for: .compact) as! UINavigationController diff --git a/Reader/Widgets/WidgetData.swift b/Reader/Widgets/WidgetData.swift new file mode 100644 index 0000000..80cdb32 --- /dev/null +++ b/Reader/Widgets/WidgetData.swift @@ -0,0 +1,53 @@ +// +// WidgetData.swift +// Reader +// +// Created by Shadowfacts on 6/19/22. +// + +import Foundation +import Persistence + +struct WidgetData: Codable { + let recentItems: [Item] + + private static func url(for account: LocalData.Account) -> URL { + let groupContainerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.net.shadowfacts.Reader")! + let appSupport = groupContainerURL + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Application Support", isDirectory: true) + let widgetData = appSupport.appendingPathComponent("Widget Data", isDirectory: true) + return widgetData + .appendingPathComponent(account.persistenceKey) + .appendingPathExtension("plist") + } + + static func load(account: LocalData.Account) -> WidgetData { + let url = url(for: account) + let decoder = PropertyListDecoder() + guard let data = try? Data(contentsOf: url), + let decoded = try? decoder.decode(WidgetData.self, from: data) else { + return WidgetData(recentItems: []) + } + return decoded + } + + func save(account: LocalData.Account) { + let encoder = PropertyListEncoder() + let data = try! encoder.encode(self) + let url = WidgetData.url(for: account) + try! FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try! data.write(to: url, options: .noFileProtection) + } + + struct Item: Codable, Identifiable { + let id: String + let feedTitle: String? + let title: String? + let published: Date? + + var launchURL: URL { + return URL(string: "reader://item/\(id)")! + } + } +} diff --git a/Reader/Widgets/WidgetHelper.swift b/Reader/Widgets/WidgetHelper.swift new file mode 100644 index 0000000..b4d6901 --- /dev/null +++ b/Reader/Widgets/WidgetHelper.swift @@ -0,0 +1,54 @@ +// +// WidgetHelper.swift +// Reader +// +// Created by Shadowfacts on 6/19/22. +// + +import Foundation +import WidgetKit +import Persistence + +struct WidgetHelper { + private init() {} + + private static let maxDisplayableItems = 8 + + static func updateWidgetData(fervorController: FervorController) async { + // Accessing CoreData from the widget extension puts us over the memory limit, so we pre-generate all the data it needs and save it to disk + + let prioritizedItems: [WidgetData.Item] = await fervorController.persistentContainer.performBackgroundTask { ctx in + let req = Item.fetchRequest() + req.sortDescriptors = [NSSortDescriptor(key: "published", ascending: false)] + req.fetchLimit = 32 + req.predicate = NSPredicate(format: "read = NO") + var items = (try? ctx.fetch(req)) ?? [] + + var prioritizedItems: [Item] = [] + + while prioritizedItems.count < maxDisplayableItems { + let firstFromUnseenFeedIdx = items.firstIndex { item in + prioritizedItems.allSatisfy { existing in + existing.feed != item.feed + } + } + if let firstFromUnseenFeedIdx { + prioritizedItems.append(items.remove(at: firstFromUnseenFeedIdx)) + } else if let item = items.first { + prioritizedItems.append(item) + items.removeFirst() + } else { + break + } + } + + return prioritizedItems.map { + WidgetData.Item(id: $0.id!, feedTitle: $0.feed?.title, title: $0.title, published: $0.published) + } + } + + WidgetData(recentItems: prioritizedItems).save(account: fervorController.account!) + + WidgetCenter.shared.reloadAllTimelines() + } +} diff --git a/Widgets/Assets.xcassets/AccentColor.colorset/Contents.json b/Widgets/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Widgets/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Widgets/Assets.xcassets/AppIcon.appiconset/Contents.json b/Widgets/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..13613e3 --- /dev/null +++ b/Widgets/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Widgets/Assets.xcassets/Contents.json b/Widgets/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Widgets/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Widgets/Assets.xcassets/WidgetBackground.colorset/Contents.json b/Widgets/Assets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Widgets/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Widgets/Info.plist b/Widgets/Info.plist new file mode 100644 index 0000000..0f118fb --- /dev/null +++ b/Widgets/Info.plist @@ -0,0 +1,11 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/Widgets/Recents.swift b/Widgets/Recents.swift new file mode 100644 index 0000000..970e598 --- /dev/null +++ b/Widgets/Recents.swift @@ -0,0 +1,196 @@ +// +// Recents.swift +// Reader +// +// Created by Shadowfacts on 6/19/22. +// + +import WidgetKit +import SwiftUI +import Persistence + +struct RecentsProvider: IntentTimelineProvider { + func placeholder(in context: Context) -> RecentsEntry { + RecentsEntry(items: [], configuration: ConfigurationIntent()) + } + + func getSnapshot(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (RecentsEntry) -> ()) { + let entry = RecentsEntry(items: getItems(), configuration: configuration) + completion(entry) + } + + func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline) -> ()) { + // TODO: get account from configuration intent + + let entry = RecentsEntry(items: getItems(), configuration: configuration) + let refreshDate = Calendar.current.date(byAdding: .hour, value: 1, to: Date())! + let timeline = Timeline(entries: [entry], policy: .after(refreshDate)) + completion(timeline) + } + + private func getItems() -> [WidgetData.Item] { + guard let account = LocalData.mostRecentAccount() else { + return [] + } + return WidgetData.load(account: account).recentItems + } + +} + +struct RecentsEntry: TimelineEntry { + let date = Date() + + let items: [WidgetData.Item] + let configuration: ConfigurationIntent +} + +struct RecentsEntryView: View { + let entry: RecentsEntry + + @Environment(\.widgetFamily) var family + + var body: some View { + if entry.items.isEmpty { + } else { + switch family { + case .systemSmall: + SquareItemView(item: entry.items[0]) + .padding() + + case .systemMedium, .systemLarge: + VStack { + ForEach(Array(entry.items.prefix(family.maxItemCount).enumerated()), id: \.element.id) { (index, item) in + if index != 0 { + Divider() + } + ItemListEntryView(item: item) + Spacer(minLength: 4) + } + } + .padding() + + case .systemExtraLarge: + if #available(iOS 16.0, *) { + VStack { + ForEach(Array(stride(from: 0, to: min(entry.items.count, family.maxItemCount), by: 2)), id: \.self) { idx in + HStack { + ItemListEntryView(item: entry.items[idx]) + if idx + 1 < entry.items.count { + Divider() + ItemListEntryView(item: entry.items[idx + 1]) + } + } + if idx + 2 < entry.items.count { + Spacer() + Divider() + } + } + } + .padding() + } else { + Text("Requires iOS 16") + } + + default: + fatalError("unreachable") + } + } + } +} + +private extension WidgetFamily { + var maxItemCount: Int { + switch self { + case .systemSmall: + return 1 + case .systemMedium: + return 2 + case .systemLarge: + return 4 + case .systemExtraLarge: + return 8 + default: + return 0 + } + } +} + +private var feedFont = Font.subheadline.weight(.medium).italic() +private var titleUIFont: UIFont { + // TODO: this should use the compressed SF Pro variant, but there's no API to get at it + let descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .headline).withSymbolicTraits(.traitCondensed)! + return UIFont(descriptor: descriptor, size: 0) +} +private var titleFont = Font(titleUIFont).leading(.tight) + +struct SquareItemView: View { + let item: WidgetData.Item + + var body: some View { + VStack(alignment: .leading) { + HStack { + Text(verbatim: item.feedTitle ?? "") + .font(feedFont) + .foregroundColor(.red) + // force the vstack to be as wide as possible + Spacer(minLength: 0) + } + Text(verbatim: item.title ?? "") + .font(titleFont) + if let published = item.published { + Text(published, format: .relative(presentation: .numeric, unitsStyle: .narrow)) + .font(.caption) + } + Spacer(minLength: 0) + } + .widgetURL(item.launchURL) + } +} + +struct ItemListEntryView: View { + let item: WidgetData.Item + + var body: some View { + Link(destination: item.launchURL) { + VStack(alignment: .leading) { + HStack { + Text(verbatim: item.feedTitle ?? "") + .font(feedFont) + .foregroundColor(.red) + Spacer() + if let published = item.published { + Text(published, format: .relative(presentation: .numeric, unitsStyle: .narrow)) + .font(.caption) + } + } + Text(verbatim: item.title ?? "") + .font(titleFont) + .lineLimit(3) + Spacer(minLength: 0) + } + } + } +} + +struct Recents_Previews: PreviewProvider { + static let item1 = WidgetData.Item( + id: "1", + feedTitle: "Daring Fireball", + title: "There's a Privacy Angle on Apple's Decision to Finance Apple Pay Layer On Its Own", + published: Calendar.current.date(byAdding: .hour, value: -1, to: Date())! + ) + static let item2 = WidgetData.Item( + id: "2", + feedTitle: "Ars Technica", + title: "Senate bill would ban data brokers from selling location and health data", + published: Calendar.current.date(byAdding: .hour, value: -2, to: Date())! + ) + + static var previews: some View { +// RecentsEntryView(entry: RecentsEntry(items: [item1], configuration: ConfigurationIntent())) +// .previewContext(WidgetPreviewContext(family: .systemSmall)) + + RecentsEntryView(entry: RecentsEntry(items: [item1, item2], configuration: ConfigurationIntent())) + .previewContext(WidgetPreviewContext(family: .systemMedium)) + } +} diff --git a/Widgets/Widgets.intentdefinition b/Widgets/Widgets.intentdefinition new file mode 100644 index 0000000..95a5592 --- /dev/null +++ b/Widgets/Widgets.intentdefinition @@ -0,0 +1,61 @@ + + + + + INEnums + + INIntentDefinitionModelVersion + 1.2 + INIntentDefinitionNamespace + 88xZPY + INIntentDefinitionSystemVersion + 22A5266r + INIntentDefinitionToolsBuildVersion + 14A5228q + INIntentDefinitionToolsVersion + 14.0 + INIntents + + + INIntentCategory + information + INIntentDescriptionID + tVvJ9c + INIntentEligibleForWidgets + + INIntentIneligibleForSuggestions + + INIntentLastParameterTag + 2 + INIntentName + Configuration + INIntentResponse + + INIntentResponseCodes + + + INIntentResponseCodeName + success + INIntentResponseCodeSuccess + + + + INIntentResponseCodeName + failure + + + + INIntentTitle + Configuration + INIntentTitleID + gpCwrM + INIntentType + Custom + INIntentVerb + View + + + INTypes + + + diff --git a/Widgets/Widgets.swift b/Widgets/Widgets.swift new file mode 100644 index 0000000..7588db7 --- /dev/null +++ b/Widgets/Widgets.swift @@ -0,0 +1,23 @@ +// +// Widgets.swift +// Widgets +// +// Created by Shadowfacts on 6/19/22. +// + +import WidgetKit +import SwiftUI +import Intents + +@main +struct Widgets: Widget { + var body: some WidgetConfiguration { + IntentConfiguration(kind: "recents", intent: ConfigurationIntent.self, provider: RecentsProvider()) { entry in + RecentsEntryView(entry: entry) + } + .configurationDisplayName("Recent Articles") + .description("Recently posted articles from your feeds.") + .supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .systemExtraLarge]) + } +} + diff --git a/WidgetsExtension.entitlements b/WidgetsExtension.entitlements new file mode 100644 index 0000000..be06bd4 --- /dev/null +++ b/WidgetsExtension.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + com.apple.security.application-groups + + group.net.shadowfacts.Reader + + +