diff --git a/Development Assets/DevelopmentModels.swift b/Development Assets/DevelopmentModels.swift index 2d46497..09330b4 100644 --- a/Development Assets/DevelopmentModels.swift +++ b/Development Assets/DevelopmentModels.swift @@ -69,15 +69,15 @@ extension IdentityService { static let development = try! IdentitiesService.development.identityService(id: devIdentityID) } -extension NotificationService { - static let development = NotificationService(userNotificationCenter: .current()) +extension UserNotificationService { + static let development = UserNotificationService(userNotificationCenter: .current()) } extension RootViewModel { static let development = RootViewModel( appDelegate: AppDelegate(), identitiesService: .development, - notificationService: .development) + userNotificationService: .development) } extension AddIdentityViewModel { diff --git a/Development Assets/MockKeychainService.swift b/Development Assets/MockKeychainService.swift index 11049f5..341a7ff 100644 --- a/Development Assets/MockKeychainService.swift +++ b/Development Assets/MockKeychainService.swift @@ -23,11 +23,11 @@ extension MockKeychainService: KeychainService { items[account] } - static func generateKeyAndReturnPublicKey(applicationTag: String) throws -> Data { + static func generateKeyAndReturnPublicKey(applicationTag: String, attributes: [String: Any]) throws -> Data { fatalError("not implemented") } - static func getPrivateKey(applicationTag: String) throws -> Data? { + static func getPrivateKey(applicationTag: String, attributes: [String: Any]) throws -> Data? { fatalError("not implemented") } } diff --git a/Metatext.entitlements b/Metatext.entitlements index 903def2..ac209bb 100644 --- a/Metatext.entitlements +++ b/Metatext.entitlements @@ -4,5 +4,9 @@ aps-environment development + keychain-access-groups + + $(AppIdentifierPrefix)com.metabolist.metatext + diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index 73ce389..49465d1 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -132,6 +132,17 @@ D0DC176124D0171800A75C65 /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = D0DC176024D0171800A75C65 /* Alamofire */; }; D0DC177724D0CF2600A75C65 /* MockKeychainService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DC177624D0CF2600A75C65 /* MockKeychainService.swift */; }; D0DC177824D0CF2600A75C65 /* MockKeychainService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DC177624D0CF2600A75C65 /* MockKeychainService.swift */; }; + D0E5361C24E3EB4D00FB1CE1 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E5361B24E3EB4D00FB1CE1 /* NotificationService.swift */; }; + D0E5362024E3EB4D00FB1CE1 /* Notification Service Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = D0E5361924E3EB4D00FB1CE1 /* Notification Service Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + D0E5362524E3FE2300FB1CE1 /* SecretsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DC724DF8B3C00A08489 /* SecretsService.swift */; }; + D0E5362624E3FE2C00FB1CE1 /* KeychainService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DC424DF842700A08489 /* KeychainService.swift */; }; + D0E5362724E4047C00FB1CE1 /* NSError+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B23F0C24D210E90066F411 /* NSError+Extensions.swift */; }; + D0E5362C24E534BD00FB1CE1 /* Unknowable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0CD847B24DBEA9F00CF380C /* Unknowable.swift */; }; + D0E5362D24E5430F00FB1CE1 /* MastodonDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = D019E6D624DF728400697C7D /* MastodonDecoder.swift */; }; + D0E5362E24E5432000FB1CE1 /* MastodonAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DC175A24D0154F00A75C65 /* MastodonAPI.swift */; }; + D0E5363024E5436C00FB1CE1 /* PushNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E5362F24E5436C00FB1CE1 /* PushNotification.swift */; }; + D0E5363124E5453E00FB1CE1 /* PushNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E5362F24E5436C00FB1CE1 /* PushNotification.swift */; }; + D0E5363224E5453F00FB1CE1 /* PushNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E5362F24E5436C00FB1CE1 /* PushNotification.swift */; }; D0EC8DC224DF7D9C00A08489 /* IdentityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DC124DF7D9C00A08489 /* IdentityService.swift */; }; D0EC8DC324DF7D9C00A08489 /* IdentityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DC124DF7D9C00A08489 /* IdentityService.swift */; }; D0EC8DC524DF842700A08489 /* KeychainService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DC424DF842700A08489 /* KeychainService.swift */; }; @@ -145,8 +156,8 @@ D0EC8DD424DFE38900A08489 /* AuthenticationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DD324DFE38900A08489 /* AuthenticationServiceTests.swift */; }; D0EC8DDF24E09D7000A08489 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DDE24E09D7000A08489 /* AppDelegate.swift */; }; D0EC8DE024E09D7000A08489 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DDE24E09D7000A08489 /* AppDelegate.swift */; }; - D0EC8DE424E0B44400A08489 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DD724E096C900A08489 /* NotificationService.swift */; }; - D0EC8DE524E0B44500A08489 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DD724E096C900A08489 /* NotificationService.swift */; }; + D0EC8DE424E0B44400A08489 /* UserNotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DD724E096C900A08489 /* UserNotificationService.swift */; }; + D0EC8DE524E0B44500A08489 /* UserNotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DD724E096C900A08489 /* UserNotificationService.swift */; }; D0EC8DE824E21FEC00A08489 /* Data+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DE724E21FEC00A08489 /* Data+Extensions.swift */; }; D0EC8DE924E21FEC00A08489 /* Data+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DE724E21FEC00A08489 /* Data+Extensions.swift */; }; D0EC8DEB24E26F1100A08489 /* PushSubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DEA24E26F1100A08489 /* PushSubscription.swift */; }; @@ -180,8 +191,29 @@ remoteGlobalIDString = D047FA8B24C3E21200AF17C5; remoteInfo = "Metatext (iOS)"; }; + D0E5361E24E3EB4D00FB1CE1 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = D047FA8024C3E21000AF17C5 /* Project object */; + proxyType = 1; + remoteGlobalIDString = D0E5361824E3EB4D00FB1CE1; + remoteInfo = "Notification Service Extension"; + }; /* End PBXContainerItemProxy section */ +/* Begin PBXCopyFilesBuildPhase section */ + D0E5362424E3EB4D00FB1CE1 /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + D0E5362024E3EB4D00FB1CE1 /* Notification Service Extension.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ D0091B6724DC10B30040E8D2 /* PostingReadingPreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostingReadingPreferencesView.swift; sourceTree = ""; }; D0091B6A24DC10CE0040E8D2 /* PostingReadingPreferencesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostingReadingPreferencesViewModel.swift; sourceTree = ""; }; @@ -254,13 +286,18 @@ D0DC175724D0130800A75C65 /* HTTPStubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPStubs.swift; sourceTree = ""; }; D0DC175A24D0154F00A75C65 /* MastodonAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAPI.swift; sourceTree = ""; }; D0DC177624D0CF2600A75C65 /* MockKeychainService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockKeychainService.swift; sourceTree = ""; }; + D0E5361924E3EB4D00FB1CE1 /* Notification Service Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Notification Service Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; + D0E5361B24E3EB4D00FB1CE1 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; + D0E5361D24E3EB4D00FB1CE1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + D0E5362824E4A06B00FB1CE1 /* Notification Service Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Notification Service Extension.entitlements"; sourceTree = ""; }; + D0E5362F24E5436C00FB1CE1 /* PushNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotification.swift; sourceTree = ""; }; D0EC8DC124DF7D9C00A08489 /* IdentityService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IdentityService.swift; sourceTree = ""; }; D0EC8DC424DF842700A08489 /* KeychainService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeychainService.swift; sourceTree = ""; }; D0EC8DC724DF8B3C00A08489 /* SecretsService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SecretsService.swift; sourceTree = ""; }; D0EC8DCA24DFA06700A08489 /* IdentitiesService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentitiesService.swift; sourceTree = ""; }; D0EC8DCD24DFB64200A08489 /* AuthenticationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationService.swift; sourceTree = ""; }; D0EC8DD324DFE38900A08489 /* AuthenticationServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationServiceTests.swift; sourceTree = ""; }; - D0EC8DD724E096C900A08489 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; + D0EC8DD724E096C900A08489 /* UserNotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserNotificationService.swift; sourceTree = ""; }; D0EC8DDE24E09D7000A08489 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; D0EC8DE624E0BA6500A08489 /* Metatext.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Metatext.entitlements; sourceTree = ""; }; D0EC8DE724E21FEC00A08489 /* Data+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Extensions.swift"; sourceTree = ""; }; @@ -306,6 +343,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D0E5361624E3EB4D00FB1CE1 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -375,7 +419,7 @@ D0EC8DCA24DFA06700A08489 /* IdentitiesService.swift */, D0EC8DC124DF7D9C00A08489 /* IdentityService.swift */, D0EC8DC424DF842700A08489 /* KeychainService.swift */, - D0EC8DD724E096C900A08489 /* NotificationService.swift */, + D0EC8DD724E096C900A08489 /* UserNotificationService.swift */, D0EC8DC724DF8B3C00A08489 /* SecretsService.swift */, ); path = Services; @@ -384,11 +428,12 @@ D047FA7F24C3E21000AF17C5 = { isa = PBXGroup; children = ( - D0EC8DE624E0BA6500A08489 /* Metatext.entitlements */, D0ED1BB224CE3A1600B4899C /* Development Assets */, D0666A7924C7745A00F3F04B /* Frameworks */, D047FA8E24C3E21200AF17C5 /* iOS */, D047FA9524C3E21200AF17C5 /* macOS */, + D0EC8DE624E0BA6500A08489 /* Metatext.entitlements */, + D0E5361A24E3EB4D00FB1CE1 /* Notification Service Extension */, D047FA8D24C3E21200AF17C5 /* Products */, D047FA8424C3E21000AF17C5 /* Shared */, D0666A2224C677B400F3F04B /* Tests */, @@ -419,6 +464,7 @@ D047FA8C24C3E21200AF17C5 /* Metatext.app */, D047FA9424C3E21200AF17C5 /* Metatext.app */, D0666A2124C677B400F3F04B /* Tests.xctest */, + D0E5361924E3EB4D00FB1CE1 /* Notification Service Extension.appex */, ); name = Products; sourceTree = ""; @@ -468,6 +514,7 @@ D0666A4D24C6C39600F3F04B /* Instance.swift */, D0ED1BE224CFA84400B4899C /* MastodonError.swift */, D0CD847224DBDEC700CF380C /* MastodonPreferences.swift */, + D0E5362F24E5436C00FB1CE1 /* PushNotification.swift */, D0EC8DEA24E26F1100A08489 /* PushSubscription.swift */, D0CD847524DBDF3C00CF380C /* Status.swift */, D0CD847B24DBEA9F00CF380C /* Unknowable.swift */, @@ -552,6 +599,16 @@ path = "Mastodon API Stubs"; sourceTree = ""; }; + D0E5361A24E3EB4D00FB1CE1 /* Notification Service Extension */ = { + isa = PBXGroup; + children = ( + D0E5362824E4A06B00FB1CE1 /* Notification Service Extension.entitlements */, + D0E5361B24E3EB4D00FB1CE1 /* NotificationService.swift */, + D0E5361D24E3EB4D00FB1CE1 /* Info.plist */, + ); + path = "Notification Service Extension"; + sourceTree = ""; + }; D0EC8DD024DFE34F00A08489 /* Services */ = { isa = PBXGroup; children = ( @@ -610,10 +667,12 @@ D047FA8924C3E21200AF17C5 /* Frameworks */, D047FA8A24C3E21200AF17C5 /* Resources */, D0666A2E24C67E6700F3F04B /* ShellScript */, + D0E5362424E3EB4D00FB1CE1 /* Embed App Extensions */, ); buildRules = ( ); dependencies = ( + D0E5361F24E3EB4D00FB1CE1 /* PBXTargetDependency */, ); name = "Metatext (iOS)"; packageProductDependencies = ( @@ -669,6 +728,25 @@ productReference = D0666A2124C677B400F3F04B /* Tests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; + D0E5361824E3EB4D00FB1CE1 /* Notification Service Extension */ = { + isa = PBXNativeTarget; + buildConfigurationList = D0E5362124E3EB4D00FB1CE1 /* Build configuration list for PBXNativeTarget "Notification Service Extension" */; + buildPhases = ( + D0E5361524E3EB4D00FB1CE1 /* Sources */, + D0E5361624E3EB4D00FB1CE1 /* Frameworks */, + D0E5361724E3EB4D00FB1CE1 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = "Notification Service Extension"; + packageProductDependencies = ( + ); + productName = "Notification Service Extension"; + productReference = D0E5361924E3EB4D00FB1CE1 /* Notification Service Extension.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -690,6 +768,9 @@ LastSwiftMigration = 1200; TestTargetID = D047FA8B24C3E21200AF17C5; }; + D0E5361824E3EB4D00FB1CE1 = { + CreatedOnToolsVersion = 12.0; + }; }; }; buildConfigurationList = D047FA8324C3E21000AF17C5 /* Build configuration list for PBXProject "Metatext" */; @@ -714,6 +795,7 @@ D047FA8B24C3E21200AF17C5 /* Metatext (iOS) */, D047FA9324C3E21200AF17C5 /* Metatext (macOS) */, D0666A2024C677B400F3F04B /* Tests */, + D0E5361824E3EB4D00FB1CE1 /* Notification Service Extension */, ); }; /* End PBXProject section */ @@ -744,6 +826,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D0E5361724E3EB4D00FB1CE1 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -830,11 +919,12 @@ D0DC175824D0130800A75C65 /* HTTPStubs.swift in Sources */, D0DC177724D0CF2600A75C65 /* MockKeychainService.swift in Sources */, D0EC8DC224DF7D9C00A08489 /* IdentityService.swift in Sources */, + D0E5363024E5436C00FB1CE1 /* PushNotification.swift in Sources */, D0C963FB24CC359D003BD330 /* AlertItem.swift in Sources */, D0DC174624CFEC2000A75C65 /* StubbingURLProtocol.swift in Sources */, D019E6F024DF7C2F00697C7D /* DatabaseError.swift in Sources */, D019E6D724DF728400697C7D /* MastodonEncoder.swift in Sources */, - D0EC8DE524E0B44500A08489 /* NotificationService.swift in Sources */, + D0EC8DE524E0B44500A08489 /* UserNotificationService.swift in Sources */, D0EC8DCB24DFA06700A08489 /* IdentitiesService.swift in Sources */, D0091B7124DD68220040E8D2 /* PreferencesViewModel.swift in Sources */, D0DC174D24CFF1F100A75C65 /* Stubbing.swift in Sources */, @@ -872,6 +962,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + D0E5363124E5453E00FB1CE1 /* PushNotification.swift in Sources */, D04FD73A24D4A7B4007D572D /* AccountEndpoint+Stubbing.swift in Sources */, D0DB6F0A24C65AC000D965FE /* AddIdentityViewModel.swift in Sources */, D0CD847424DBDEC700CF380C /* MastodonPreferences.swift in Sources */, @@ -891,7 +982,7 @@ D0BEC93924C9632800E864C4 /* RootViewModel.swift in Sources */, D0ED1BC224CED48800B4899C /* HTTPClient.swift in Sources */, D0666A4C24C6C37700F3F04B /* Identity.swift in Sources */, - D0EC8DE424E0B44400A08489 /* NotificationService.swift in Sources */, + D0EC8DE424E0B44400A08489 /* UserNotificationService.swift in Sources */, D0EC8DCC24DFA06700A08489 /* IdentitiesService.swift in Sources */, D0666A5524C6C3E500F3F04B /* Emoji.swift in Sources */, D019E6EE24DF7BF300697C7D /* IdentityDatabase.swift in Sources */, @@ -957,6 +1048,21 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D0E5361524E3EB4D00FB1CE1 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D0E5363224E5453F00FB1CE1 /* PushNotification.swift in Sources */, + D0E5362C24E534BD00FB1CE1 /* Unknowable.swift in Sources */, + D0E5362D24E5430F00FB1CE1 /* MastodonDecoder.swift in Sources */, + D0E5362E24E5432000FB1CE1 /* MastodonAPI.swift in Sources */, + D0E5361C24E3EB4D00FB1CE1 /* NotificationService.swift in Sources */, + D0E5362724E4047C00FB1CE1 /* NSError+Extensions.swift in Sources */, + D0E5362524E3FE2300FB1CE1 /* SecretsService.swift in Sources */, + D0E5362624E3FE2C00FB1CE1 /* KeychainService.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -965,6 +1071,11 @@ target = D047FA8B24C3E21200AF17C5 /* Metatext (iOS) */; targetProxy = D0666A2624C677B400F3F04B /* PBXContainerItemProxy */; }; + D0E5361F24E3EB4D00FB1CE1 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D0E5361824E3EB4D00FB1CE1 /* Notification Service Extension */; + targetProxy = D0E5361E24E3EB4D00FB1CE1 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -1082,6 +1193,7 @@ D047FAB724C3E21200AF17C5 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Metatext.entitlements; @@ -1106,6 +1218,7 @@ D047FAB824C3E21200AF17C5 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Metatext.entitlements; @@ -1226,6 +1339,51 @@ }; name = Release; }; + D0E5362224E3EB4D00FB1CE1 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = "Notification Service Extension/Notification Service Extension.entitlements"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 82HL67AXQ2; + INFOPLIST_FILE = "Notification Service Extension/Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.metabolist.metatext.notification-service-extension"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + D0E5362324E3EB4D00FB1CE1 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = "Notification Service Extension/Notification Service Extension.entitlements"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 82HL67AXQ2; + INFOPLIST_FILE = "Notification Service Extension/Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.metabolist.metatext.notification-service-extension"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -1265,6 +1423,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + D0E5362124E3EB4D00FB1CE1 /* Build configuration list for PBXNativeTarget "Notification Service Extension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D0E5362224E3EB4D00FB1CE1 /* Debug */, + D0E5362324E3EB4D00FB1CE1 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/Notification Service Extension/Info.plist b/Notification Service Extension/Info.plist new file mode 100644 index 0000000..7393769 --- /dev/null +++ b/Notification Service Extension/Info.plist @@ -0,0 +1,31 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Notification Service Extension + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSExtension + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationService + + + diff --git a/Notification Service Extension/Notification Service Extension.entitlements b/Notification Service Extension/Notification Service Extension.entitlements new file mode 100644 index 0000000..664ace1 --- /dev/null +++ b/Notification Service Extension/Notification Service Extension.entitlements @@ -0,0 +1,10 @@ + + + + + keychain-access-groups + + $(AppIdentifierPrefix)com.metabolist.metatext + + + diff --git a/Notification Service Extension/NotificationService.swift b/Notification Service Extension/NotificationService.swift new file mode 100644 index 0000000..5ee0e32 --- /dev/null +++ b/Notification Service Extension/NotificationService.swift @@ -0,0 +1,166 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import UserNotifications +import CryptoKit + +class NotificationService: UNNotificationServiceExtension { + + var contentHandler: ((UNNotificationContent) -> Void)? + var bestAttemptContent: UNMutableNotificationContent? + + override func didReceive( + _ request: UNNotificationRequest, + withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { + self.contentHandler = contentHandler + bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) + + guard let bestAttemptContent = bestAttemptContent else { return } + + let pushNotification: PushNotification + + do { + let decryptedJSON = try Self.extractAndDecrypt(userInfo: request.content.userInfo) + + pushNotification = try MastodonDecoder().decode(PushNotification.self, from: decryptedJSON) + } catch { + contentHandler(bestAttemptContent) + + return + } + + bestAttemptContent.title = pushNotification.title + bestAttemptContent.body = pushNotification.body + + let fileName = pushNotification.icon.lastPathComponent + let fileURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true).appendingPathComponent(fileName) + + do { + let iconData = try Data(contentsOf: pushNotification.icon) + + try iconData.write(to: fileURL) + bestAttemptContent.attachments = [try UNNotificationAttachment(identifier: fileName, url: fileURL)] + } catch { + // no-op + } + + contentHandler(bestAttemptContent) + } + + override func serviceExtensionTimeWillExpire() { + if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent { + contentHandler(bestAttemptContent) + } + } +} + +enum NotificationServiceError: Error { + case userInfoDataAbsent + case keychainDataAbsent +} + +private extension NotificationService { + static let identityIDUserInfoKey = "i" + static let encryptedMessageUserInfoKey = "m" + static let saltUserInfoKey = "s" + static let serverPublicKeyUserInfoKey = "k" + static let keyLength = 16 + static let nonceLength = 12 + static let pseudoRandomKeyLength = 32 + static let paddedByteCount = 2 + static let curve = "P-256" + + enum HKDFInfo: String { + case auth, aesgcm, nonce + + var bytes: [UInt8] { + Array("Content-Encoding: \(self)\0".utf8) + } + } + + static func extractAndDecrypt(userInfo: [AnyHashable: Any]) throws -> Data { + guard + let identityIDString = userInfo[identityIDUserInfoKey] as? String, + let identityID = UUID(uuidString: identityIDString), + let encryptedMessageBase64 = (userInfo[encryptedMessageUserInfoKey] as? String)?.URLSafeBase64ToBase64(), + let encryptedMessage = Data(base64Encoded: encryptedMessageBase64), + let saltBase64 = (userInfo[saltUserInfoKey] as? String)?.URLSafeBase64ToBase64(), + let salt = Data(base64Encoded: saltBase64), + let serverPublicKeyBase64 = (userInfo[serverPublicKeyUserInfoKey] as? String)?.URLSafeBase64ToBase64(), + let serverPublicKeyData = Data(base64Encoded: serverPublicKeyBase64) + else { throw NotificationServiceError.userInfoDataAbsent } + + let secretsService = SecretsService(identityID: identityID, keychainServiceType: LiveKeychainService.self) + + guard + let auth = try secretsService.getPushAuth(), + let pushKey = try secretsService.getPushKey() + else { throw NotificationServiceError.keychainDataAbsent } + + return try decrypt(encryptedMessage: encryptedMessage, + privateKeyData: pushKey, + serverPublicKeyData: serverPublicKeyData, + auth: auth, + salt: salt) + } + + static func decrypt(encryptedMessage: Data, + privateKeyData: Data, + serverPublicKeyData: Data, + auth: Data, + salt: Data) throws -> Data { + let privateKey = try P256.KeyAgreement.PrivateKey(x963Representation: privateKeyData) + let serverPublicKey = try P256.KeyAgreement.PublicKey(x963Representation: serverPublicKeyData) + let sharedSecret = try privateKey.sharedSecretFromKeyAgreement(with: serverPublicKey) + + var keyInfo = HKDFInfo.aesgcm.bytes + var nonceInfo = HKDFInfo.nonce.bytes + var context = Array(curve.utf8) + let publicKeyData = privateKey.publicKey.x963Representation + + context.append(0) + context.append(0) + context.append(UInt8(publicKeyData.count)) + context += Array(publicKeyData) + context.append(0) + context.append(UInt8(serverPublicKeyData.count)) + context += Array(serverPublicKeyData) + + keyInfo += context + nonceInfo += context + + let pseudoRandomKey = sharedSecret.hkdfDerivedSymmetricKey( + using: SHA256.self, + salt: auth, + sharedInfo: HKDFInfo.auth.bytes, + outputByteCount: pseudoRandomKeyLength) + let key = HKDF.deriveKey( + inputKeyMaterial: pseudoRandomKey, + salt: salt, + info: keyInfo, + outputByteCount: keyLength) + let nonce = HKDF.deriveKey( + inputKeyMaterial: pseudoRandomKey, + salt: salt, + info: nonceInfo, + outputByteCount: nonceLength) + + let sealedBox = try AES.GCM.SealedBox(combined: nonce.withUnsafeBytes(Array.init) + encryptedMessage) + let decrypted = try AES.GCM.open(sealedBox, using: key) + let unpadded = decrypted.suffix(from: paddedByteCount) + + return Data(unpadded) + } +} + +extension String { + func URLSafeBase64ToBase64() -> String { + var base64 = replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + let countMod4 = count % 4 + + if countMod4 != 0 { + base64.append(String(repeating: "=", count: 4 - countMod4)) + } + + return base64 + } +} diff --git a/Shared/Localizations/Localizable.strings b/Shared/Localizations/Localizable.strings index 2ed5036..5b1a923 100644 --- a/Shared/Localizations/Localizable.strings +++ b/Shared/Localizations/Localizable.strings @@ -1,5 +1,6 @@ // Copyright © 2020 Metabolist. All rights reserved. +"apns-default-message" = "New notification"; "add-identity.instance-url" = "Instance URL"; "add-identity.log-in" = "Log in"; "add-identity.browse-anonymously" = "Browse anonymously"; diff --git a/Shared/MetatextApp.swift b/Shared/MetatextApp.swift index d639752..5634663 100644 --- a/Shared/MetatextApp.swift +++ b/Shared/MetatextApp.swift @@ -29,7 +29,7 @@ struct MetatextApp: App { RootView( viewModel: RootViewModel(appDelegate: appDelegate, identitiesService: identitiesService, - notificationService: NotificationService())) + userNotificationService: UserNotificationService())) } } } diff --git a/Shared/Model/PushNotification.swift b/Shared/Model/PushNotification.swift new file mode 100644 index 0000000..924dfcd --- /dev/null +++ b/Shared/Model/PushNotification.swift @@ -0,0 +1,23 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +struct PushNotification: Codable { + enum NotificationType: String, Codable, Unknowable { + case mention + case reblog + case favourite + case follow + case unknown + + static var unknownCase: Self { .unknown } + } + + let accessToken: String + let body: String + let title: String + let icon: URL + let notificationId: Int + let notificationType: NotificationType + let preferredLocale: String +} diff --git a/Shared/Services/KeychainService.swift b/Shared/Services/KeychainService.swift index 64e9332..23310ce 100644 --- a/Shared/Services/KeychainService.swift +++ b/Shared/Services/KeychainService.swift @@ -6,8 +6,8 @@ protocol KeychainService { static func setGenericPassword(data: Data, forAccount key: String, service: String) throws static func deleteGenericPassword(account: String, service: String) throws static func getGenericPassword(account: String, service: String) throws -> Data? - static func generateKeyAndReturnPublicKey(applicationTag: String) throws -> Data - static func getPrivateKey(applicationTag: String) throws -> Data? + static func generateKeyAndReturnPublicKey(applicationTag: String, attributes: [String: Any]) throws -> Data + static func getPrivateKey(applicationTag: String, attributes: [String: Any]) throws -> Data? } struct LiveKeychainService {} @@ -52,13 +52,21 @@ extension LiveKeychainService: KeychainService { } } - static func generateKeyAndReturnPublicKey(applicationTag: String) throws -> Data { - var attributes = keyAttributes + static func generateKeyAndReturnPublicKey(applicationTag: String, attributes: [String: Any]) throws -> Data { + var attributes = attributes var error: Unmanaged? + guard let accessControl = SecAccessControlCreateWithFlags( + kCFAllocatorDefault, + kSecAttrAccessibleAfterFirstUnlock, + [], + &error) + else { throw error?.takeRetainedValue() ?? NSError() } + attributes[kSecPrivateKeyAttrs as String] = [ kSecAttrIsPermanent as String: true, - kSecAttrApplicationTag as String: Data(applicationTag.utf8)] + kSecAttrApplicationTag as String: Data(applicationTag.utf8), + kSecAttrAccessControl as String: accessControl] guard let key = SecKeyCreateRandomKey(attributes as CFDictionary, &error), @@ -69,13 +77,27 @@ extension LiveKeychainService: KeychainService { return publicKeyData } - static func getPrivateKey(applicationTag: String) throws -> Data? { + static func getPrivateKey(applicationTag: String, attributes: [String: Any]) throws -> Data? { var result: AnyObject? - let status = SecItemCopyMatching(keyQueryDictionary(applicationTag: applicationTag) as CFDictionary, &result) + var error: Unmanaged? + var query = keyQueryDictionary(applicationTag: applicationTag) + + query.merge(attributes, uniquingKeysWith: { $1 }) + query[kSecMatchLimit as String] = kSecMatchLimitOne + query[kSecReturnRef as String] = kCFBooleanTrue + + let status = SecItemCopyMatching(query as CFDictionary, &result) switch status { case errSecSuccess: - return result as? Data + // swiftlint:disable force_cast + let secKey = result as! SecKey + // swiftlint:enable force_cast + guard let data = SecKeyCopyExternalRepresentation(secKey, &error) else { + throw error?.takeRetainedValue() ?? NSError() + } + + return data as Data case errSecItemNotFound: return nil default: @@ -85,8 +107,6 @@ extension LiveKeychainService: KeychainService { } private extension LiveKeychainService { - static let keySizeInBits = 256 - static func genericPasswordQueryDictionary(account: String, service: String) -> [String: Any] { [kSecAttrService as String: service, kSecAttrAccount as String: account, @@ -96,13 +116,7 @@ private extension LiveKeychainService { static func keyQueryDictionary(applicationTag: String) -> [String: Any] { [kSecClass as String: kSecClassKey, kSecAttrKeyClass as String: kSecAttrKeyClassPrivate, - kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, - kSecAttrKeySizeInBits as String: keySizeInBits, kSecAttrApplicationTag as String: applicationTag, kSecReturnRef as String: true] } - - static let keyAttributes: [String: Any] = [ - kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, - kSecAttrKeySizeInBits as String: keySizeInBits] } diff --git a/Shared/Services/SecretsService.swift b/Shared/Services/SecretsService.swift index c7405ec..29f5907 100644 --- a/Shared/Services/SecretsService.swift +++ b/Shared/Services/SecretsService.swift @@ -58,17 +58,21 @@ extension SecretsService { } func generatePushKeyAndReturnPublicKey() throws -> Data { - try keychainServiceType.generateKeyAndReturnPublicKey(applicationTag: key(item: .pushKey)) + try keychainServiceType.generateKeyAndReturnPublicKey( + applicationTag: key(item: .pushKey), + attributes: PushKey.attributes) } func getPushKey() throws -> Data? { - try keychainServiceType.getPrivateKey(applicationTag: key(item: .pushKey)) + try keychainServiceType.getPrivateKey( + applicationTag: key(item: .pushKey), + attributes: PushKey.attributes) } func generatePushAuth() throws -> Data { - var bytes = [UInt8](repeating: 0, count: Self.authLength) + var bytes = [UInt8](repeating: 0, count: PushKey.authLength) - _ = SecRandomCopyBytes(kSecRandomDefault, Self.authLength, &bytes) + _ = SecRandomCopyBytes(kSecRandomDefault, PushKey.authLength, &bytes) let pushAuth = Data(bytes) @@ -84,7 +88,6 @@ extension SecretsService { private extension SecretsService { static let keychainServiceName = "com.metabolist.metatext" - private static let authLength = 16 func key(item: Item) -> String { identityID.uuidString + "." + item.rawValue @@ -110,3 +113,11 @@ extension String: SecretsStorable { return string } } + +struct PushKey { + static let authLength = 16 + static let sizeInBits = 256 + static let attributes: [String: Any] = [ + kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, + kSecAttrKeySizeInBits as String: sizeInBits] +} diff --git a/Shared/Services/NotificationService.swift b/Shared/Services/UserNotificationService.swift similarity index 53% rename from Shared/Services/NotificationService.swift rename to Shared/Services/UserNotificationService.swift index c16a467..ef65e2f 100644 --- a/Shared/Services/NotificationService.swift +++ b/Shared/Services/UserNotificationService.swift @@ -4,21 +4,27 @@ import Foundation import Combine import UserNotifications -struct NotificationService { +class UserNotificationService: NSObject { private let userNotificationCenter: UNUserNotificationCenter init(userNotificationCenter: UNUserNotificationCenter = .current()) { self.userNotificationCenter = userNotificationCenter + + super.init() + + userNotificationCenter.delegate = self } } -extension NotificationService { +extension UserNotificationService { func isAuthorized() -> AnyPublisher { getNotificationSettings() .map(\.authorizationStatus) - .flatMap { status -> AnyPublisher in + .flatMap { [weak self] status -> AnyPublisher in if status == .notDetermined { - return requestProvisionalAuthorization().eraseToAnyPublisher() + return self?.requestProvisionalAuthorization() + .eraseToAnyPublisher() + ?? Empty().eraseToAnyPublisher() } return Just(status == .authorized || status == .provisional) @@ -29,17 +35,17 @@ extension NotificationService { } } -private extension NotificationService { +private extension UserNotificationService { func getNotificationSettings() -> AnyPublisher { - Future { promise in - userNotificationCenter.getNotificationSettings { promise(.success($0)) } + Future { [weak self] promise in + self?.userNotificationCenter.getNotificationSettings { promise(.success($0)) } } .eraseToAnyPublisher() } func requestProvisionalAuthorization() -> AnyPublisher { - Future { promise in - userNotificationCenter.requestAuthorization( + Future { [weak self] promise in + self?.userNotificationCenter.requestAuthorization( options: [.alert, .sound, .badge, .provisional]) { granted, error in if let error = error { return promise(.failure(error)) @@ -51,3 +57,13 @@ private extension NotificationService { .eraseToAnyPublisher() } } + +extension UserNotificationService: UNUserNotificationCenterDelegate { + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + print(notification.request.content.body) + completionHandler(.banner) + } +} diff --git a/Shared/View Models/RootViewModel.swift b/Shared/View Models/RootViewModel.swift index 02aafed..f051064 100644 --- a/Shared/View Models/RootViewModel.swift +++ b/Shared/View Models/RootViewModel.swift @@ -10,17 +10,19 @@ class RootViewModel: ObservableObject { private let appDelegate: AppDelegate // swiftlint:enable weak_delegate private let identitiesService: IdentitiesService - private let notificationService: NotificationService + private let userNotificationService: UserNotificationService private var cancellables = Set() - init(appDelegate: AppDelegate, identitiesService: IdentitiesService, notificationService: NotificationService) { + init(appDelegate: AppDelegate, + identitiesService: IdentitiesService, + userNotificationService: UserNotificationService) { self.appDelegate = appDelegate self.identitiesService = identitiesService - self.notificationService = notificationService + self.userNotificationService = userNotificationService newIdentitySelected(id: identitiesService.mostRecentlyUsedIdentityID) - notificationService.isAuthorized() + userNotificationService.isAuthorized() .filter { $0 } .zip(appDelegate.registerForRemoteNotifications()) .map { $1 } @@ -62,7 +64,7 @@ extension RootViewModel { func newIdentityCreated(id: UUID, instanceURL: URL) { newIdentitySelected(id: id) - notificationService.isAuthorized() + userNotificationService.isAuthorized() .filter { $0 } .zip(appDelegate.registerForRemoteNotifications()) .map { (id, instanceURL, $1, nil) } diff --git a/macOS/macOS.entitlements b/macOS/macOS.entitlements index b7b98df..2d82bbf 100644 --- a/macOS/macOS.entitlements +++ b/macOS/macOS.entitlements @@ -10,5 +10,9 @@ com.apple.security.network.client + keychain-access-groups + + $(AppIdentifierPrefix)com.metabolist.metatext +