mirror of
https://github.com/metabolist/metatext.git
synced 2024-11-22 16:21:00 +00:00
Push notifications
This commit is contained in:
parent
167a050a89
commit
347eb1d516
26 changed files with 597 additions and 117 deletions
|
@ -10,19 +10,6 @@ private let devInstanceURL = URL(string: "https://mastodon.social")!
|
||||||
private let devIdentityID = UUID(uuidString: "E621E1F8-C36C-495A-93FC-0C247A3E6E5F")!
|
private let devIdentityID = UUID(uuidString: "E621E1F8-C36C-495A-93FC-0C247A3E6E5F")!
|
||||||
private let devAccessToken = "DEVELOPMENT_ACCESS_TOKEN"
|
private let devAccessToken = "DEVELOPMENT_ACCESS_TOKEN"
|
||||||
|
|
||||||
func freshKeychainService() -> KeychainServiceType { MockKeychainService() }
|
|
||||||
|
|
||||||
let developmentKeychainService: KeychainServiceType = {
|
|
||||||
let keychainService = MockKeychainService()
|
|
||||||
let secretsService = SecretsService(identityID: devIdentityID, keychainService: keychainService)
|
|
||||||
|
|
||||||
try! secretsService.set("DEVELOPMENT_CLIENT_ID", forItem: .clientID)
|
|
||||||
try! secretsService.set("DEVELOPMENT_CLIENT_SECRET", forItem: .clientSecret)
|
|
||||||
try! secretsService.set(devAccessToken, forItem: .accessToken)
|
|
||||||
|
|
||||||
return keychainService
|
|
||||||
}()
|
|
||||||
|
|
||||||
extension Account {
|
extension Account {
|
||||||
static let development = try! decoder.decode(Account.self, from: Data(officialAccountJSON.utf8))
|
static let development = try! decoder.decode(Account.self, from: Data(officialAccountJSON.utf8))
|
||||||
}
|
}
|
||||||
|
@ -58,8 +45,9 @@ extension IdentityDatabase {
|
||||||
|
|
||||||
extension AppEnvironment {
|
extension AppEnvironment {
|
||||||
static let development = AppEnvironment(
|
static let development = AppEnvironment(
|
||||||
URLSessionConfiguration: .stubbing,
|
session: Session(configuration: .stubbing),
|
||||||
webAuthSessionType: SuccessfulMockWebAuthSession.self)
|
webAuthSessionType: SuccessfulMockWebAuthSession.self,
|
||||||
|
keychainServiceType: MockKeychainService.self)
|
||||||
}
|
}
|
||||||
|
|
||||||
extension IdentitiesService {
|
extension IdentitiesService {
|
||||||
|
@ -69,13 +57,11 @@ extension IdentitiesService {
|
||||||
environment: AppEnvironment = .development) -> IdentitiesService {
|
environment: AppEnvironment = .development) -> IdentitiesService {
|
||||||
IdentitiesService(
|
IdentitiesService(
|
||||||
identityDatabase: identityDatabase,
|
identityDatabase: identityDatabase,
|
||||||
keychainService: keychainService,
|
|
||||||
environment: environment)
|
environment: environment)
|
||||||
}
|
}
|
||||||
|
|
||||||
static let development = IdentitiesService(
|
static let development = IdentitiesService(
|
||||||
identityDatabase: .development,
|
identityDatabase: .development,
|
||||||
keychainService: developmentKeychainService,
|
|
||||||
environment: .development)
|
environment: .development)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -83,8 +69,15 @@ extension IdentityService {
|
||||||
static let development = try! IdentitiesService.development.identityService(id: devIdentityID)
|
static let development = try! IdentitiesService.development.identityService(id: devIdentityID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension NotificationService {
|
||||||
|
static let development = NotificationService(userNotificationCenter: .current())
|
||||||
|
}
|
||||||
|
|
||||||
extension RootViewModel {
|
extension RootViewModel {
|
||||||
static let development = RootViewModel(identitiesService: .development)
|
static let development = RootViewModel(
|
||||||
|
appDelegate: AppDelegate(),
|
||||||
|
identitiesService: .development,
|
||||||
|
notificationService: .development)
|
||||||
}
|
}
|
||||||
|
|
||||||
extension AddIdentityViewModel {
|
extension AddIdentityViewModel {
|
||||||
|
|
|
@ -2,20 +2,36 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
class MockKeychainService {
|
struct MockKeychainService {}
|
||||||
private var items = [String: Data]()
|
|
||||||
|
extension MockKeychainService {
|
||||||
|
static func reset() {
|
||||||
|
items = [String: Data]()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension MockKeychainService: KeychainServiceType {
|
extension MockKeychainService: KeychainServiceType {
|
||||||
func set(data: Data, forKey key: String) throws {
|
static func setGenericPassword(data: Data, forAccount key: String, service: String) throws {
|
||||||
items[key] = data
|
items[key] = data
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteData(key: String) throws {
|
static func deleteGenericPassword(account: String, service: String) throws {
|
||||||
items[key] = nil
|
items[account] = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getData(key: String) throws -> Data? {
|
static func getGenericPassword(account: String, service: String) throws -> Data? {
|
||||||
items[key]
|
items[account]
|
||||||
|
}
|
||||||
|
|
||||||
|
static func generateKeyAndReturnPublicKey(applicationTag: String) throws -> Data {
|
||||||
|
fatalError("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func getPrivateKey(applicationTag: String) throws -> Data? {
|
||||||
|
fatalError("not implemented")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extension MockKeychainService {
|
||||||
|
static var items = [String: Data]()
|
||||||
|
}
|
||||||
|
|
8
Metatext.entitlements
Normal file
8
Metatext.entitlements
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>aps-environment</key>
|
||||||
|
<string>development</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
|
@ -143,6 +143,16 @@
|
||||||
D0EC8DCE24DFB64200A08489 /* AuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DCD24DFB64200A08489 /* AuthenticationService.swift */; };
|
D0EC8DCE24DFB64200A08489 /* AuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DCD24DFB64200A08489 /* AuthenticationService.swift */; };
|
||||||
D0EC8DCF24DFB64200A08489 /* AuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DCD24DFB64200A08489 /* AuthenticationService.swift */; };
|
D0EC8DCF24DFB64200A08489 /* AuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DCD24DFB64200A08489 /* AuthenticationService.swift */; };
|
||||||
D0EC8DD424DFE38900A08489 /* AuthenticationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DD324DFE38900A08489 /* AuthenticationServiceTests.swift */; };
|
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 */; };
|
||||||
|
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 */; };
|
||||||
|
D0EC8DEC24E26F1100A08489 /* PushSubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DEA24E26F1100A08489 /* PushSubscription.swift */; };
|
||||||
|
D0EC8DEE24E2704D00A08489 /* PushSubscriptionEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DED24E2704D00A08489 /* PushSubscriptionEndpoint.swift */; };
|
||||||
|
D0EC8DEF24E2704D00A08489 /* PushSubscriptionEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DED24E2704D00A08489 /* PushSubscriptionEndpoint.swift */; };
|
||||||
D0ED1B6E24CE100C00B4899C /* AddIdentityViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1B6D24CE100C00B4899C /* AddIdentityViewModelTests.swift */; };
|
D0ED1B6E24CE100C00B4899C /* AddIdentityViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1B6D24CE100C00B4899C /* AddIdentityViewModelTests.swift */; };
|
||||||
D0ED1BB724CE47F400B4899C /* WebAuthSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BB624CE47F400B4899C /* WebAuthSession.swift */; };
|
D0ED1BB724CE47F400B4899C /* WebAuthSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BB624CE47F400B4899C /* WebAuthSession.swift */; };
|
||||||
D0ED1BB824CE47F400B4899C /* WebAuthSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BB624CE47F400B4899C /* WebAuthSession.swift */; };
|
D0ED1BB824CE47F400B4899C /* WebAuthSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BB624CE47F400B4899C /* WebAuthSession.swift */; };
|
||||||
|
@ -250,6 +260,12 @@
|
||||||
D0EC8DCA24DFA06700A08489 /* IdentitiesService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentitiesService.swift; sourceTree = "<group>"; };
|
D0EC8DCA24DFA06700A08489 /* IdentitiesService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentitiesService.swift; sourceTree = "<group>"; };
|
||||||
D0EC8DCD24DFB64200A08489 /* AuthenticationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationService.swift; sourceTree = "<group>"; };
|
D0EC8DCD24DFB64200A08489 /* AuthenticationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationService.swift; sourceTree = "<group>"; };
|
||||||
D0EC8DD324DFE38900A08489 /* AuthenticationServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationServiceTests.swift; sourceTree = "<group>"; };
|
D0EC8DD324DFE38900A08489 /* AuthenticationServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationServiceTests.swift; sourceTree = "<group>"; };
|
||||||
|
D0EC8DD724E096C900A08489 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
|
||||||
|
D0EC8DDE24E09D7000A08489 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
D0EC8DE624E0BA6500A08489 /* Metatext.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Metatext.entitlements; sourceTree = "<group>"; };
|
||||||
|
D0EC8DE724E21FEC00A08489 /* Data+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Extensions.swift"; sourceTree = "<group>"; };
|
||||||
|
D0EC8DEA24E26F1100A08489 /* PushSubscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushSubscription.swift; sourceTree = "<group>"; };
|
||||||
|
D0EC8DED24E2704D00A08489 /* PushSubscriptionEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushSubscriptionEndpoint.swift; sourceTree = "<group>"; };
|
||||||
D0ED1B6D24CE100C00B4899C /* AddIdentityViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddIdentityViewModelTests.swift; sourceTree = "<group>"; };
|
D0ED1B6D24CE100C00B4899C /* AddIdentityViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddIdentityViewModelTests.swift; sourceTree = "<group>"; };
|
||||||
D0ED1BB624CE47F400B4899C /* WebAuthSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebAuthSession.swift; sourceTree = "<group>"; };
|
D0ED1BB624CE47F400B4899C /* WebAuthSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebAuthSession.swift; sourceTree = "<group>"; };
|
||||||
D0ED1BC024CED48800B4899C /* HTTPClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPClient.swift; sourceTree = "<group>"; };
|
D0ED1BC024CED48800B4899C /* HTTPClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPClient.swift; sourceTree = "<group>"; };
|
||||||
|
@ -338,6 +354,7 @@
|
||||||
D019E6DC24DF72E700697C7D /* AppAuthorizationEndpoint.swift */,
|
D019E6DC24DF72E700697C7D /* AppAuthorizationEndpoint.swift */,
|
||||||
D019E6E024DF72E700697C7D /* InstanceEndpoint.swift */,
|
D019E6E024DF72E700697C7D /* InstanceEndpoint.swift */,
|
||||||
D019E6DD24DF72E700697C7D /* PreferencesEndpoint.swift */,
|
D019E6DD24DF72E700697C7D /* PreferencesEndpoint.swift */,
|
||||||
|
D0EC8DED24E2704D00A08489 /* PushSubscriptionEndpoint.swift */,
|
||||||
);
|
);
|
||||||
path = Endpoints;
|
path = Endpoints;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -358,6 +375,7 @@
|
||||||
D0EC8DCA24DFA06700A08489 /* IdentitiesService.swift */,
|
D0EC8DCA24DFA06700A08489 /* IdentitiesService.swift */,
|
||||||
D0EC8DC124DF7D9C00A08489 /* IdentityService.swift */,
|
D0EC8DC124DF7D9C00A08489 /* IdentityService.swift */,
|
||||||
D0EC8DC424DF842700A08489 /* KeychainService.swift */,
|
D0EC8DC424DF842700A08489 /* KeychainService.swift */,
|
||||||
|
D0EC8DD724E096C900A08489 /* NotificationService.swift */,
|
||||||
D0EC8DC724DF8B3C00A08489 /* SecretsService.swift */,
|
D0EC8DC724DF8B3C00A08489 /* SecretsService.swift */,
|
||||||
);
|
);
|
||||||
path = Services;
|
path = Services;
|
||||||
|
@ -366,6 +384,7 @@
|
||||||
D047FA7F24C3E21000AF17C5 = {
|
D047FA7F24C3E21000AF17C5 = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
D0EC8DE624E0BA6500A08489 /* Metatext.entitlements */,
|
||||||
D0ED1BB224CE3A1600B4899C /* Development Assets */,
|
D0ED1BB224CE3A1600B4899C /* Development Assets */,
|
||||||
D0666A7924C7745A00F3F04B /* Frameworks */,
|
D0666A7924C7745A00F3F04B /* Frameworks */,
|
||||||
D047FA8E24C3E21200AF17C5 /* iOS */,
|
D047FA8E24C3E21200AF17C5 /* iOS */,
|
||||||
|
@ -379,6 +398,7 @@
|
||||||
D047FA8424C3E21000AF17C5 /* Shared */ = {
|
D047FA8424C3E21000AF17C5 /* Shared */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
D0EC8DDE24E09D7000A08489 /* AppDelegate.swift */,
|
||||||
D047FA8724C3E21200AF17C5 /* Assets.xcassets */,
|
D047FA8724C3E21200AF17C5 /* Assets.xcassets */,
|
||||||
D019E6EB24DF7BB800697C7D /* Databases */,
|
D019E6EB24DF7BB800697C7D /* Databases */,
|
||||||
D0DB6F1624C665B400D965FE /* Extensions */,
|
D0DB6F1624C665B400D965FE /* Extensions */,
|
||||||
|
@ -448,6 +468,7 @@
|
||||||
D0666A4D24C6C39600F3F04B /* Instance.swift */,
|
D0666A4D24C6C39600F3F04B /* Instance.swift */,
|
||||||
D0ED1BE224CFA84400B4899C /* MastodonError.swift */,
|
D0ED1BE224CFA84400B4899C /* MastodonError.swift */,
|
||||||
D0CD847224DBDEC700CF380C /* MastodonPreferences.swift */,
|
D0CD847224DBDEC700CF380C /* MastodonPreferences.swift */,
|
||||||
|
D0EC8DEA24E26F1100A08489 /* PushSubscription.swift */,
|
||||||
D0CD847524DBDF3C00CF380C /* Status.swift */,
|
D0CD847524DBDF3C00CF380C /* Status.swift */,
|
||||||
D0CD847B24DBEA9F00CF380C /* Unknowable.swift */,
|
D0CD847B24DBEA9F00CF380C /* Unknowable.swift */,
|
||||||
);
|
);
|
||||||
|
@ -513,6 +534,7 @@
|
||||||
D0C963FD24CC3812003BD330 /* Publisher+Extensions.swift */,
|
D0C963FD24CC3812003BD330 /* Publisher+Extensions.swift */,
|
||||||
D081A40424D0F1A8001B016E /* String+Extensions.swift */,
|
D081A40424D0F1A8001B016E /* String+Extensions.swift */,
|
||||||
D065F53A24D3B33A00741304 /* View+Extensions.swift */,
|
D065F53A24D3B33A00741304 /* View+Extensions.swift */,
|
||||||
|
D0EC8DE724E21FEC00A08489 /* Data+Extensions.swift */,
|
||||||
);
|
);
|
||||||
path = Extensions;
|
path = Extensions;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -775,6 +797,7 @@
|
||||||
D0666A4E24C6C39600F3F04B /* Instance.swift in Sources */,
|
D0666A4E24C6C39600F3F04B /* Instance.swift in Sources */,
|
||||||
D019E6E924DF72E700697C7D /* InstanceEndpoint.swift in Sources */,
|
D019E6E924DF72E700697C7D /* InstanceEndpoint.swift in Sources */,
|
||||||
D0ED1BE324CFA84400B4899C /* MastodonError.swift in Sources */,
|
D0ED1BE324CFA84400B4899C /* MastodonError.swift in Sources */,
|
||||||
|
D0EC8DE824E21FEC00A08489 /* Data+Extensions.swift in Sources */,
|
||||||
D0666A6324C6DC6C00F3F04B /* AppAuthorization.swift in Sources */,
|
D0666A6324C6DC6C00F3F04B /* AppAuthorization.swift in Sources */,
|
||||||
D019E6E524DF72E700697C7D /* AccountEndpoint.swift in Sources */,
|
D019E6E524DF72E700697C7D /* AccountEndpoint.swift in Sources */,
|
||||||
D065F53B24D3B33A00741304 /* View+Extensions.swift in Sources */,
|
D065F53B24D3B33A00741304 /* View+Extensions.swift in Sources */,
|
||||||
|
@ -789,6 +812,7 @@
|
||||||
D019E6E724DF72E700697C7D /* AccessTokenEndpoint.swift in Sources */,
|
D019E6E724DF72E700697C7D /* AccessTokenEndpoint.swift in Sources */,
|
||||||
D0BEC93824C9632800E864C4 /* RootViewModel.swift in Sources */,
|
D0BEC93824C9632800E864C4 /* RootViewModel.swift in Sources */,
|
||||||
D0ED1BC124CED48800B4899C /* HTTPClient.swift in Sources */,
|
D0ED1BC124CED48800B4899C /* HTTPClient.swift in Sources */,
|
||||||
|
D0EC8DEE24E2704D00A08489 /* PushSubscriptionEndpoint.swift in Sources */,
|
||||||
D0159F9324DE743700E78478 /* SecondaryNavigationView.swift in Sources */,
|
D0159F9324DE743700E78478 /* SecondaryNavigationView.swift in Sources */,
|
||||||
D019E6E324DF72E700697C7D /* PreferencesEndpoint.swift in Sources */,
|
D019E6E324DF72E700697C7D /* PreferencesEndpoint.swift in Sources */,
|
||||||
D0666A4B24C6C37700F3F04B /* Identity.swift in Sources */,
|
D0666A4B24C6C37700F3F04B /* Identity.swift in Sources */,
|
||||||
|
@ -810,13 +834,16 @@
|
||||||
D0DC174624CFEC2000A75C65 /* StubbingURLProtocol.swift in Sources */,
|
D0DC174624CFEC2000A75C65 /* StubbingURLProtocol.swift in Sources */,
|
||||||
D019E6F024DF7C2F00697C7D /* DatabaseError.swift in Sources */,
|
D019E6F024DF7C2F00697C7D /* DatabaseError.swift in Sources */,
|
||||||
D019E6D724DF728400697C7D /* MastodonEncoder.swift in Sources */,
|
D019E6D724DF728400697C7D /* MastodonEncoder.swift in Sources */,
|
||||||
|
D0EC8DE524E0B44500A08489 /* NotificationService.swift in Sources */,
|
||||||
D0EC8DCB24DFA06700A08489 /* IdentitiesService.swift in Sources */,
|
D0EC8DCB24DFA06700A08489 /* IdentitiesService.swift in Sources */,
|
||||||
D0091B7124DD68220040E8D2 /* PreferencesViewModel.swift in Sources */,
|
D0091B7124DD68220040E8D2 /* PreferencesViewModel.swift in Sources */,
|
||||||
D0DC174D24CFF1F100A75C65 /* Stubbing.swift in Sources */,
|
D0DC174D24CFF1F100A75C65 /* Stubbing.swift in Sources */,
|
||||||
D0091B6B24DC10CE0040E8D2 /* PostingReadingPreferencesViewModel.swift in Sources */,
|
D0091B6B24DC10CE0040E8D2 /* PostingReadingPreferencesViewModel.swift in Sources */,
|
||||||
D0159F8624DE742F00E78478 /* TabNavigationViewModel.swift in Sources */,
|
D0159F8624DE742F00E78478 /* TabNavigationViewModel.swift in Sources */,
|
||||||
|
D0EC8DDF24E09D7000A08489 /* AppDelegate.swift in Sources */,
|
||||||
D0091B6E24DD68090040E8D2 /* PreferencesView.swift in Sources */,
|
D0091B6E24DD68090040E8D2 /* PreferencesView.swift in Sources */,
|
||||||
D0159F8F24DE743700E78478 /* IdentitiesView.swift in Sources */,
|
D0159F8F24DE743700E78478 /* IdentitiesView.swift in Sources */,
|
||||||
|
D0EC8DEB24E26F1100A08489 /* PushSubscription.swift in Sources */,
|
||||||
D0DB6EF424C5228A00D965FE /* AddIdentityView.swift in Sources */,
|
D0DB6EF424C5228A00D965FE /* AddIdentityView.swift in Sources */,
|
||||||
D074577724D29006004758DB /* MockWebAuthSession.swift in Sources */,
|
D074577724D29006004758DB /* MockWebAuthSession.swift in Sources */,
|
||||||
D0159FA524DE989700E78478 /* NSMutableAttributedString+Extensions.swift in Sources */,
|
D0159FA524DE989700E78478 /* NSMutableAttributedString+Extensions.swift in Sources */,
|
||||||
|
@ -864,6 +891,7 @@
|
||||||
D0BEC93924C9632800E864C4 /* RootViewModel.swift in Sources */,
|
D0BEC93924C9632800E864C4 /* RootViewModel.swift in Sources */,
|
||||||
D0ED1BC224CED48800B4899C /* HTTPClient.swift in Sources */,
|
D0ED1BC224CED48800B4899C /* HTTPClient.swift in Sources */,
|
||||||
D0666A4C24C6C37700F3F04B /* Identity.swift in Sources */,
|
D0666A4C24C6C37700F3F04B /* Identity.swift in Sources */,
|
||||||
|
D0EC8DE424E0B44400A08489 /* NotificationService.swift in Sources */,
|
||||||
D0EC8DCC24DFA06700A08489 /* IdentitiesService.swift in Sources */,
|
D0EC8DCC24DFA06700A08489 /* IdentitiesService.swift in Sources */,
|
||||||
D0666A5524C6C3E500F3F04B /* Emoji.swift in Sources */,
|
D0666A5524C6C3E500F3F04B /* Emoji.swift in Sources */,
|
||||||
D019E6EE24DF7BF300697C7D /* IdentityDatabase.swift in Sources */,
|
D019E6EE24DF7BF300697C7D /* IdentityDatabase.swift in Sources */,
|
||||||
|
@ -873,6 +901,7 @@
|
||||||
D0B23F0E24D210E90066F411 /* NSError+Extensions.swift in Sources */,
|
D0B23F0E24D210E90066F411 /* NSError+Extensions.swift in Sources */,
|
||||||
D052BBCB24D74C9300A80A7A /* MockUserDefaults.swift in Sources */,
|
D052BBCB24D74C9300A80A7A /* MockUserDefaults.swift in Sources */,
|
||||||
D0DC175324D008E300A75C65 /* MastodonTarget+Stubbing.swift in Sources */,
|
D0DC175324D008E300A75C65 /* MastodonTarget+Stubbing.swift in Sources */,
|
||||||
|
D0EC8DE924E21FEC00A08489 /* Data+Extensions.swift in Sources */,
|
||||||
D0BEC94B24CA231200E864C4 /* TimelineView.swift in Sources */,
|
D0BEC94B24CA231200E864C4 /* TimelineView.swift in Sources */,
|
||||||
D0BEC93C24C96FD500E864C4 /* RootView.swift in Sources */,
|
D0BEC93C24C96FD500E864C4 /* RootView.swift in Sources */,
|
||||||
D0159F9B24DE748900E78478 /* SidebarNavigationViewModel.swift in Sources */,
|
D0159F9B24DE748900E78478 /* SidebarNavigationViewModel.swift in Sources */,
|
||||||
|
@ -889,6 +918,7 @@
|
||||||
D0091B6C24DC10CE0040E8D2 /* PostingReadingPreferencesViewModel.swift in Sources */,
|
D0091B6C24DC10CE0040E8D2 /* PostingReadingPreferencesViewModel.swift in Sources */,
|
||||||
D0091B6F24DD68090040E8D2 /* PreferencesView.swift in Sources */,
|
D0091B6F24DD68090040E8D2 /* PreferencesView.swift in Sources */,
|
||||||
D0EC8DC924DF8B3C00A08489 /* SecretsService.swift in Sources */,
|
D0EC8DC924DF8B3C00A08489 /* SecretsService.swift in Sources */,
|
||||||
|
D0EC8DE024E09D7000A08489 /* AppDelegate.swift in Sources */,
|
||||||
D0DB6EF524C5233E00D965FE /* AddIdentityView.swift in Sources */,
|
D0DB6EF524C5233E00D965FE /* AddIdentityView.swift in Sources */,
|
||||||
D019E6EA24DF72E700697C7D /* InstanceEndpoint.swift in Sources */,
|
D019E6EA24DF72E700697C7D /* InstanceEndpoint.swift in Sources */,
|
||||||
D0EC8DCF24DFB64200A08489 /* AuthenticationService.swift in Sources */,
|
D0EC8DCF24DFB64200A08489 /* AuthenticationService.swift in Sources */,
|
||||||
|
@ -898,6 +928,7 @@
|
||||||
D0ED1BCF24CF768200B4899C /* MastodonEndpoint.swift in Sources */,
|
D0ED1BCF24CF768200B4899C /* MastodonEndpoint.swift in Sources */,
|
||||||
D074577B24D29366004758DB /* URLSessionConfiguration+Extensions.swift in Sources */,
|
D074577B24D29366004758DB /* URLSessionConfiguration+Extensions.swift in Sources */,
|
||||||
D0ED1BB824CE47F400B4899C /* WebAuthSession.swift in Sources */,
|
D0ED1BB824CE47F400B4899C /* WebAuthSession.swift in Sources */,
|
||||||
|
D0EC8DEC24E26F1100A08489 /* PushSubscription.swift in Sources */,
|
||||||
D0A1CA7524DAC2F1003063E9 /* KingfisherOptionsInfo+Extensions.swift in Sources */,
|
D0A1CA7524DAC2F1003063E9 /* KingfisherOptionsInfo+Extensions.swift in Sources */,
|
||||||
D0ED1BC524CED54D00B4899C /* HTTPTarget.swift in Sources */,
|
D0ED1BC524CED54D00B4899C /* HTTPTarget.swift in Sources */,
|
||||||
D0C963FF24CC3812003BD330 /* Publisher+Extensions.swift in Sources */,
|
D0C963FF24CC3812003BD330 /* Publisher+Extensions.swift in Sources */,
|
||||||
|
@ -912,6 +943,7 @@
|
||||||
D0091B6924DC10B30040E8D2 /* PostingReadingPreferencesView.swift in Sources */,
|
D0091B6924DC10B30040E8D2 /* PostingReadingPreferencesView.swift in Sources */,
|
||||||
D019E6E624DF72E700697C7D /* AccountEndpoint.swift in Sources */,
|
D019E6E624DF72E700697C7D /* AccountEndpoint.swift in Sources */,
|
||||||
D0CD847724DBDF3C00CF380C /* Status.swift in Sources */,
|
D0CD847724DBDF3C00CF380C /* Status.swift in Sources */,
|
||||||
|
D0EC8DEF24E2704D00A08489 /* PushSubscriptionEndpoint.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
@ -1052,6 +1084,7 @@
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Metatext.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
DEVELOPMENT_ASSET_PATHS = "Development\\ Assets Development\\ Assets/Mastodon\\ API\\ Stubs";
|
DEVELOPMENT_ASSET_PATHS = "Development\\ Assets Development\\ Assets/Mastodon\\ API\\ Stubs";
|
||||||
DEVELOPMENT_TEAM = 82HL67AXQ2;
|
DEVELOPMENT_TEAM = 82HL67AXQ2;
|
||||||
|
@ -1075,6 +1108,7 @@
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Metatext.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
DEVELOPMENT_ASSET_PATHS = "Development\\ Assets Development\\ Assets/Mastodon\\ API\\ Stubs";
|
DEVELOPMENT_ASSET_PATHS = "Development\\ Assets Development\\ Assets/Mastodon\\ API\\ Stubs";
|
||||||
DEVELOPMENT_TEAM = 82HL67AXQ2;
|
DEVELOPMENT_TEAM = 82HL67AXQ2;
|
||||||
|
|
58
Shared/AppDelegate.swift
Normal file
58
Shared/AppDelegate.swift
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
// Copyright © 2020 Metabolist. All rights reserved.
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
import AppKit
|
||||||
|
typealias AppDelegateType = NSApplicationDelegate
|
||||||
|
typealias ApplicationType = NSApplication
|
||||||
|
#else
|
||||||
|
import UIKit
|
||||||
|
typealias AppDelegateType = UIApplicationDelegate
|
||||||
|
typealias ApplicationType = UIApplication
|
||||||
|
#endif
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
class AppDelegate: NSObject {
|
||||||
|
@Published private var application: ApplicationType?
|
||||||
|
private let remoteNotificationDeviceTokens = PassthroughSubject<Data, Error>()
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AppDelegate {
|
||||||
|
func registerForRemoteNotifications() -> AnyPublisher<String, Error> {
|
||||||
|
$application
|
||||||
|
.compactMap { $0 }
|
||||||
|
.handleEvents(receiveOutput: { $0.registerForRemoteNotifications() })
|
||||||
|
.setFailureType(to: Error.self)
|
||||||
|
.zip(remoteNotificationDeviceTokens)
|
||||||
|
.first()
|
||||||
|
.map { $1.hexEncodedString() }
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AppDelegate: AppDelegateType {
|
||||||
|
#if os(macOS)
|
||||||
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
|
application = notification.object as? ApplicationType
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
func application(
|
||||||
|
_ application: UIApplication,
|
||||||
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
||||||
|
self.application = application
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
func application(_ application: ApplicationType,
|
||||||
|
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
||||||
|
// this doesn't get called on macOS, need to figure out why
|
||||||
|
remoteNotificationDeviceTokens.send(deviceToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func application(_ application: ApplicationType,
|
||||||
|
didFailToRegisterForRemoteNotificationsWithError error: Error) {
|
||||||
|
remoteNotificationDeviceTokens.send(completion: .failure(error))
|
||||||
|
}
|
||||||
|
}
|
|
@ -37,7 +37,9 @@ extension IdentityDatabase {
|
||||||
url: url,
|
url: url,
|
||||||
lastUsedAt: Date(),
|
lastUsedAt: Date(),
|
||||||
preferences: Identity.Preferences(),
|
preferences: Identity.Preferences(),
|
||||||
instanceURI: nil).save)
|
instanceURI: nil,
|
||||||
|
pushSubscriptionAlerts: nil)
|
||||||
|
.save)
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -100,6 +102,23 @@ extension IdentityDatabase {
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updatePushSubscription(deviceToken: String,
|
||||||
|
alerts: PushSubscription.Alerts,
|
||||||
|
forIdentityID identityID: UUID) -> AnyPublisher<Void, Error> {
|
||||||
|
databaseQueue.writePublisher {
|
||||||
|
let data = try StoredIdentity.databaseJSONEncoder(for: "pushSubscriptionAlerts").encode(alerts)
|
||||||
|
|
||||||
|
try StoredIdentity
|
||||||
|
.filter(Column("id") == identityID)
|
||||||
|
.updateAll($0, Column("pushSubscriptionAlerts").set(to: data))
|
||||||
|
|
||||||
|
try StoredIdentity
|
||||||
|
.filter(Column("id") == identityID)
|
||||||
|
.updateAll($0, Column("lastRegisteredDeviceToken").set(to: deviceToken))
|
||||||
|
}
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
func identityObservation(id: UUID) -> AnyPublisher<Identity, Error> {
|
func identityObservation(id: UUID) -> AnyPublisher<Identity, Error> {
|
||||||
ValueObservation.tracking(
|
ValueObservation.tracking(
|
||||||
StoredIdentity
|
StoredIdentity
|
||||||
|
@ -144,6 +163,15 @@ extension IdentityDatabase {
|
||||||
.publisher(in: databaseQueue, scheduling: .immediate)
|
.publisher(in: databaseQueue, scheduling: .immediate)
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func identitiesWithOutdatedDeviceTokens(deviceToken: String) -> AnyPublisher<[Identity], Error> {
|
||||||
|
databaseQueue.readPublisher(
|
||||||
|
value: Self.identitiesRequest()
|
||||||
|
.filter(Column("lastRegisteredDeviceToken") != deviceToken)
|
||||||
|
.fetchAll)
|
||||||
|
.map { $0.map(Identity.init(result:)) }
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension IdentityDatabase {
|
private extension IdentityDatabase {
|
||||||
|
@ -174,6 +202,8 @@ private extension IdentityDatabase {
|
||||||
.indexed()
|
.indexed()
|
||||||
.references("instance", column: "uri")
|
.references("instance", column: "uri")
|
||||||
t.column("preferences", .blob).notNull()
|
t.column("preferences", .blob).notNull()
|
||||||
|
t.column("pushSubscriptionAlerts", .blob)
|
||||||
|
t.column("lastRegisteredDeviceToken", .text)
|
||||||
}
|
}
|
||||||
|
|
||||||
try db.create(table: "account", ifNotExists: true) { t in
|
try db.create(table: "account", ifNotExists: true) { t in
|
||||||
|
@ -203,6 +233,7 @@ private struct StoredIdentity: Codable, Hashable, TableRecord, FetchableRecord,
|
||||||
let lastUsedAt: Date
|
let lastUsedAt: Date
|
||||||
let preferences: Identity.Preferences
|
let preferences: Identity.Preferences
|
||||||
let instanceURI: String?
|
let instanceURI: String?
|
||||||
|
let pushSubscriptionAlerts: PushSubscription.Alerts?
|
||||||
}
|
}
|
||||||
|
|
||||||
extension StoredIdentity {
|
extension StoredIdentity {
|
||||||
|
@ -222,6 +253,7 @@ private struct IdentityResult: Codable, Hashable, FetchableRecord {
|
||||||
let identity: StoredIdentity
|
let identity: StoredIdentity
|
||||||
let instance: Identity.Instance?
|
let instance: Identity.Instance?
|
||||||
let account: Identity.Account?
|
let account: Identity.Account?
|
||||||
|
let pushSubscriptionAlerts: PushSubscription.Alerts?
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension Identity {
|
private extension Identity {
|
||||||
|
@ -232,7 +264,8 @@ private extension Identity {
|
||||||
lastUsedAt: result.identity.lastUsedAt,
|
lastUsedAt: result.identity.lastUsedAt,
|
||||||
preferences: result.identity.preferences,
|
preferences: result.identity.preferences,
|
||||||
instance: result.instance,
|
instance: result.instance,
|
||||||
account: result.account)
|
account: result.account,
|
||||||
|
pushSubscriptionAlerts: result.pushSubscriptionAlerts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
9
Shared/Extensions/Data+Extensions.swift
Normal file
9
Shared/Extensions/Data+Extensions.swift
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
// Copyright © 2020 Metabolist. All rights reserved.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension Data {
|
||||||
|
func hexEncodedString() -> String {
|
||||||
|
map { String(format: "%02hhx", $0) }.joined()
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,31 +4,32 @@ import SwiftUI
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct MetatextApp: App {
|
struct MetatextApp: App {
|
||||||
private let identityDatabase: IdentityDatabase
|
// swiftlint:disable weak_delegate
|
||||||
private let keychainServive = KeychainService(serviceName: "com.metabolist.metatext")
|
#if os(macOS)
|
||||||
private let environment = AppEnvironment(
|
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||||
URLSessionConfiguration: .default,
|
#else
|
||||||
webAuthSessionType: WebAuthSession.self)
|
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||||
|
#endif
|
||||||
|
// swiftlint:enable weak_delegate
|
||||||
|
|
||||||
|
private let identitiesService: IdentitiesService = {
|
||||||
|
let identityDatabase: IdentityDatabase
|
||||||
|
|
||||||
init() {
|
|
||||||
do {
|
do {
|
||||||
try identityDatabase = IdentityDatabase()
|
try identityDatabase = IdentityDatabase()
|
||||||
} catch {
|
} catch {
|
||||||
fatalError("Failed to initialize identity database")
|
fatalError("Failed to initialize identity database")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
return IdentitiesService(identityDatabase: identityDatabase, environment: .live)
|
||||||
|
}()
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
RootView(
|
RootView(
|
||||||
viewModel: RootViewModel(identitiesService: IdentitiesService(
|
viewModel: RootViewModel(appDelegate: appDelegate,
|
||||||
identityDatabase: identityDatabase,
|
identitiesService: identitiesService,
|
||||||
keychainService: keychainServive,
|
notificationService: NotificationService()))
|
||||||
environment: environment)))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension MetatextApp {
|
|
||||||
static let keychainServiceName = "com.metabolist.metatext"
|
|
||||||
}
|
|
||||||
|
|
|
@ -3,6 +3,15 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct AppEnvironment {
|
struct AppEnvironment {
|
||||||
let URLSessionConfiguration: URLSessionConfiguration
|
let session: Session
|
||||||
let webAuthSessionType: WebAuthSessionType.Type
|
let webAuthSessionType: WebAuthSessionType.Type
|
||||||
|
let keychainServiceType: KeychainServiceType.Type
|
||||||
|
let userDefaults: UserDefaults = .standard
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AppEnvironment {
|
||||||
|
static let live: Self = Self(
|
||||||
|
session: Session(configuration: .default),
|
||||||
|
webAuthSessionType: WebAuthSession.self,
|
||||||
|
keychainServiceType: KeychainService.self)
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,6 +9,7 @@ struct Identity: Codable, Hashable, Identifiable {
|
||||||
let preferences: Identity.Preferences
|
let preferences: Identity.Preferences
|
||||||
let instance: Identity.Instance?
|
let instance: Identity.Instance?
|
||||||
let account: Identity.Account?
|
let account: Identity.Account?
|
||||||
|
let pushSubscriptionAlerts: PushSubscription.Alerts?
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Identity {
|
extension Identity {
|
||||||
|
|
17
Shared/Model/PushSubscription.swift
Normal file
17
Shared/Model/PushSubscription.swift
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
// Copyright © 2020 Metabolist. All rights reserved.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct PushSubscription: Codable {
|
||||||
|
struct Alerts: Codable, Hashable {
|
||||||
|
let follow: Bool
|
||||||
|
let favourite: Bool
|
||||||
|
let reblog: Bool
|
||||||
|
let mention: Bool
|
||||||
|
let poll: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
let endpoint: URL
|
||||||
|
let alerts: Alerts
|
||||||
|
let serverKey: String
|
||||||
|
}
|
|
@ -4,12 +4,14 @@ import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
import Alamofire
|
import Alamofire
|
||||||
|
|
||||||
|
typealias Session = Alamofire.Session
|
||||||
|
|
||||||
class HTTPClient {
|
class HTTPClient {
|
||||||
private let session: Session
|
private let session: Session
|
||||||
private let decoder: DataDecoder
|
private let decoder: DataDecoder
|
||||||
|
|
||||||
init(configuration: URLSessionConfiguration, decoder: DataDecoder = JSONDecoder()) {
|
init(session: Session, decoder: DataDecoder = JSONDecoder()) {
|
||||||
self.session = Session(configuration: configuration)
|
self.session = session
|
||||||
self.decoder = decoder
|
self.decoder = decoder
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,65 @@
|
||||||
|
// Copyright © 2020 Metabolist. All rights reserved.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum PushSubscriptionEndpoint {
|
||||||
|
case create(
|
||||||
|
endpoint: URL,
|
||||||
|
publicKey: String,
|
||||||
|
auth: String,
|
||||||
|
follow: Bool,
|
||||||
|
favourite: Bool,
|
||||||
|
reblog: Bool,
|
||||||
|
mention: Bool,
|
||||||
|
poll: Bool)
|
||||||
|
case read
|
||||||
|
case update(follow: Bool, favourite: Bool, reblog: Bool, mention: Bool, poll: Bool)
|
||||||
|
case delete
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PushSubscriptionEndpoint: MastodonEndpoint {
|
||||||
|
typealias ResultType = PushSubscription
|
||||||
|
|
||||||
|
var context: [String] {
|
||||||
|
defaultContext + ["push", "subscription"]
|
||||||
|
}
|
||||||
|
|
||||||
|
var pathComponentsInContext: [String] { [] }
|
||||||
|
|
||||||
|
var method: HTTPMethod {
|
||||||
|
switch self {
|
||||||
|
case .create: return .post
|
||||||
|
case .read: return .get
|
||||||
|
case .update: return .put
|
||||||
|
case .delete: return .delete
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var parameters: [String: Any]? {
|
||||||
|
switch self {
|
||||||
|
case let .create(endpoint, publicKey, auth, follow, favourite, reblog, mention, poll):
|
||||||
|
return ["subscription":
|
||||||
|
["endpoint": endpoint.absoluteString,
|
||||||
|
"keys": [
|
||||||
|
"p256dh": publicKey,
|
||||||
|
"auth": auth]],
|
||||||
|
"data": [
|
||||||
|
"alerts": [
|
||||||
|
"follow": follow,
|
||||||
|
"favourite": favourite,
|
||||||
|
"reblog": reblog,
|
||||||
|
"mention": mention,
|
||||||
|
"poll": poll
|
||||||
|
]]]
|
||||||
|
case let .update(follow, favourite, reblog, mention, poll):
|
||||||
|
return ["data":
|
||||||
|
["alerts":
|
||||||
|
["follow": follow,
|
||||||
|
"favourite": favourite,
|
||||||
|
"reblog": reblog,
|
||||||
|
"mention": mention,
|
||||||
|
"poll": poll]]]
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -7,8 +7,8 @@ class MastodonClient: HTTPClient {
|
||||||
var instanceURL: URL?
|
var instanceURL: URL?
|
||||||
var accessToken: String?
|
var accessToken: String?
|
||||||
|
|
||||||
init(configuration: URLSessionConfiguration = URLSessionConfiguration.af.default) {
|
init(session: Session) {
|
||||||
super.init(configuration: configuration, decoder: MastodonDecoder())
|
super.init(session: session, decoder: MastodonDecoder())
|
||||||
}
|
}
|
||||||
|
|
||||||
override func request<T: DecodableTarget>(_ target: T) -> AnyPublisher<T.ResultType, Error> {
|
override func request<T: DecodableTarget>(_ target: T) -> AnyPublisher<T.ResultType, Error> {
|
||||||
|
|
|
@ -9,7 +9,7 @@ struct AuthenticationService {
|
||||||
private let webAuthSessionContextProvider = WebAuthSessionContextProvider()
|
private let webAuthSessionContextProvider = WebAuthSessionContextProvider()
|
||||||
|
|
||||||
init(environment: AppEnvironment) {
|
init(environment: AppEnvironment) {
|
||||||
networkClient = MastodonClient(configuration: environment.URLSessionConfiguration)
|
networkClient = MastodonClient(session: environment.session)
|
||||||
webAuthSessionType = environment.webAuthSessionType
|
webAuthSessionType = environment.webAuthSessionType
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,12 +7,10 @@ class IdentitiesService {
|
||||||
@Published var mostRecentlyUsedIdentityID: UUID?
|
@Published var mostRecentlyUsedIdentityID: UUID?
|
||||||
|
|
||||||
private let identityDatabase: IdentityDatabase
|
private let identityDatabase: IdentityDatabase
|
||||||
private let keychainService: KeychainServiceType
|
|
||||||
private let environment: AppEnvironment
|
private let environment: AppEnvironment
|
||||||
|
|
||||||
init(identityDatabase: IdentityDatabase, keychainService: KeychainServiceType, environment: AppEnvironment) {
|
init(identityDatabase: IdentityDatabase, environment: AppEnvironment) {
|
||||||
self.identityDatabase = identityDatabase
|
self.identityDatabase = identityDatabase
|
||||||
self.keychainService = keychainService
|
|
||||||
self.environment = environment
|
self.environment = environment
|
||||||
|
|
||||||
identityDatabase.mostRecentlyUsedIdentityIDObservation()
|
identityDatabase.mostRecentlyUsedIdentityIDObservation()
|
||||||
|
@ -25,7 +23,6 @@ extension IdentitiesService {
|
||||||
func identityService(id: UUID) throws -> IdentityService {
|
func identityService(id: UUID) throws -> IdentityService {
|
||||||
try IdentityService(identityID: id,
|
try IdentityService(identityID: id,
|
||||||
identityDatabase: identityDatabase,
|
identityDatabase: identityDatabase,
|
||||||
keychainService: keychainService,
|
|
||||||
environment: environment)
|
environment: environment)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -34,7 +31,7 @@ extension IdentitiesService {
|
||||||
}
|
}
|
||||||
|
|
||||||
func authorizeIdentity(id: UUID, instanceURL: URL) -> AnyPublisher<Void, Error> {
|
func authorizeIdentity(id: UUID, instanceURL: URL) -> AnyPublisher<Void, Error> {
|
||||||
let secretsService = SecretsService(identityID: id, keychainService: keychainService)
|
let secretsService = SecretsService(identityID: id, keychainServiceType: environment.keychainServiceType)
|
||||||
let authenticationService = AuthenticationService(environment: environment)
|
let authenticationService = AuthenticationService(environment: environment)
|
||||||
|
|
||||||
return authenticationService.authorizeApp(instanceURL: instanceURL)
|
return authenticationService.authorizeApp(instanceURL: instanceURL)
|
||||||
|
@ -54,16 +51,96 @@ extension IdentitiesService {
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteIdentity(id: UUID) -> AnyPublisher<Void, Error> {
|
func deleteIdentity(id: UUID) -> AnyPublisher<Void, Error> {
|
||||||
identityDatabase.deleteIdentity(id: id)
|
let environment = self.environment
|
||||||
.continuingIfWeakReferenceIsStillAlive(to: self)
|
|
||||||
.tryMap { _, welf -> Void in
|
return identityDatabase.deleteIdentity(id: id)
|
||||||
|
.tryMap { _ -> Void in
|
||||||
try SecretsService(
|
try SecretsService(
|
||||||
identityID: id,
|
identityID: id,
|
||||||
keychainService: welf.keychainService)
|
keychainServiceType: environment.keychainServiceType)
|
||||||
.deleteAllItems()
|
.deleteAllItems()
|
||||||
|
|
||||||
return ()
|
return ()
|
||||||
}
|
}
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updatePushSubscription(
|
||||||
|
identityID: UUID,
|
||||||
|
instanceURL: URL,
|
||||||
|
deviceToken: String,
|
||||||
|
alerts: PushSubscription.Alerts?) -> AnyPublisher<Void, Error> {
|
||||||
|
let secretsService = SecretsService(
|
||||||
|
identityID: identityID,
|
||||||
|
keychainServiceType: environment.keychainServiceType)
|
||||||
|
let accessTokenOptional: String?
|
||||||
|
|
||||||
|
do {
|
||||||
|
accessTokenOptional = try secretsService.item(.accessToken) as String?
|
||||||
|
} catch {
|
||||||
|
return Fail(error: error).eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let accessToken: String = accessTokenOptional
|
||||||
|
else { return Empty().eraseToAnyPublisher() }
|
||||||
|
|
||||||
|
let publicKey: String
|
||||||
|
let auth: String
|
||||||
|
|
||||||
|
do {
|
||||||
|
publicKey = try secretsService.generatePushKeyAndReturnPublicKey().base64EncodedString()
|
||||||
|
auth = try secretsService.generatePushAuth().base64EncodedString()
|
||||||
|
} catch {
|
||||||
|
return Fail(error: error).eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
|
let networkClient = MastodonClient(session: environment.session)
|
||||||
|
networkClient.instanceURL = instanceURL
|
||||||
|
networkClient.accessToken = accessToken
|
||||||
|
|
||||||
|
let endpoint = Self.pushSubscriptionEndpointURL
|
||||||
|
.appendingPathComponent(deviceToken)
|
||||||
|
.appendingPathComponent(identityID.uuidString)
|
||||||
|
|
||||||
|
return networkClient.request(
|
||||||
|
PushSubscriptionEndpoint.create(
|
||||||
|
endpoint: endpoint,
|
||||||
|
publicKey: publicKey,
|
||||||
|
auth: auth,
|
||||||
|
follow: alerts?.follow ?? true,
|
||||||
|
favourite: alerts?.favourite ?? true,
|
||||||
|
reblog: alerts?.reblog ?? true,
|
||||||
|
mention: alerts?.mention ?? true,
|
||||||
|
poll: alerts?.poll ?? true))
|
||||||
|
.map { (deviceToken, $0.alerts, identityID) }
|
||||||
|
.flatMap(identityDatabase.updatePushSubscription(deviceToken:alerts:forIdentityID:))
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updatePushSubscriptions(deviceToken: String) -> AnyPublisher<Void, Error> {
|
||||||
|
identityDatabase.identitiesWithOutdatedDeviceTokens(deviceToken: deviceToken)
|
||||||
|
.flatMap { identities -> Publishers.MergeMany<AnyPublisher<Void, Never>> in
|
||||||
|
Publishers.MergeMany(
|
||||||
|
identities.map { [weak self] in
|
||||||
|
guard let self = self else { return Empty().eraseToAnyPublisher() }
|
||||||
|
|
||||||
|
return self.updatePushSubscription(
|
||||||
|
identityID: $0.id,
|
||||||
|
instanceURL: $0.url,
|
||||||
|
deviceToken: deviceToken,
|
||||||
|
alerts: $0.pushSubscriptionAlerts)
|
||||||
|
.catch { _ in Empty() } // can't let one failure stop the pipeline
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension IdentitiesService {
|
||||||
|
#if DEBUG
|
||||||
|
static let pushSubscriptionEndpointURL = URL(string: "https://metatext-apns.metabolist.com/push?sandbox=true")!
|
||||||
|
#else
|
||||||
|
static let pushSubscriptionEndpointURL = URL(string: "https://metatext-apns.metabolist.com/push")!
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,7 +14,6 @@ class IdentityService {
|
||||||
|
|
||||||
init(identityID: UUID,
|
init(identityID: UUID,
|
||||||
identityDatabase: IdentityDatabase,
|
identityDatabase: IdentityDatabase,
|
||||||
keychainService: KeychainServiceType,
|
|
||||||
environment: AppEnvironment) throws {
|
environment: AppEnvironment) throws {
|
||||||
self.identityDatabase = identityDatabase
|
self.identityDatabase = identityDatabase
|
||||||
self.environment = environment
|
self.environment = environment
|
||||||
|
@ -30,11 +29,11 @@ class IdentityService {
|
||||||
guard let identity = initialIdentity else { throw IdentityDatabaseError.identityNotFound }
|
guard let identity = initialIdentity else { throw IdentityDatabaseError.identityNotFound }
|
||||||
|
|
||||||
self.identity = identity
|
self.identity = identity
|
||||||
networkClient = MastodonClient(configuration: environment.URLSessionConfiguration)
|
networkClient = MastodonClient(session: environment.session)
|
||||||
networkClient.instanceURL = identity.url
|
networkClient.instanceURL = identity.url
|
||||||
networkClient.accessToken = try SecretsService(
|
networkClient.accessToken = try SecretsService(
|
||||||
identityID: identityID,
|
identityID: identityID,
|
||||||
keychainService: keychainService)
|
keychainServiceType: environment.keychainServiceType)
|
||||||
.item(.accessToken)
|
.item(.accessToken)
|
||||||
|
|
||||||
observation.catch { [weak self] error -> Empty<Identity, Never> in
|
observation.catch { [weak self] error -> Empty<Identity, Never> in
|
||||||
|
|
|
@ -3,18 +3,18 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
protocol KeychainServiceType {
|
protocol KeychainServiceType {
|
||||||
func set(data: Data, forKey key: String) throws
|
static func setGenericPassword(data: Data, forAccount key: String, service: String) throws
|
||||||
func deleteData(key: String) throws
|
static func deleteGenericPassword(account: String, service: String) throws
|
||||||
func getData(key: String) throws -> Data?
|
static func getGenericPassword(account: String, service: String) throws -> Data?
|
||||||
|
static func generateKeyAndReturnPublicKey(applicationTag: String) throws -> Data
|
||||||
|
static func getPrivateKey(applicationTag: String) throws -> Data?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct KeychainService {
|
struct KeychainService {}
|
||||||
let serviceName: String
|
|
||||||
}
|
|
||||||
|
|
||||||
extension KeychainService: KeychainServiceType {
|
extension KeychainService: KeychainServiceType {
|
||||||
func set(data: Data, forKey key: String) throws {
|
static func setGenericPassword(data: Data, forAccount account: String, service: String) throws {
|
||||||
var query = queryDictionary(key: key)
|
var query = genericPasswordQueryDictionary(account: account, service: service)
|
||||||
|
|
||||||
query[kSecValueData as String] = data
|
query[kSecValueData as String] = data
|
||||||
|
|
||||||
|
@ -25,17 +25,17 @@ extension KeychainService: KeychainServiceType {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteData(key: String) throws {
|
static func deleteGenericPassword(account: String, service: String) throws {
|
||||||
let status = SecItemDelete(queryDictionary(key: key) as CFDictionary)
|
let status = SecItemDelete(genericPasswordQueryDictionary(account: account, service: service) as CFDictionary)
|
||||||
|
|
||||||
if status != errSecSuccess {
|
if status != errSecSuccess {
|
||||||
throw NSError(status: status)
|
throw NSError(status: status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getData(key: String) throws -> Data? {
|
static func getGenericPassword(account: String, service: String) throws -> Data? {
|
||||||
var result: AnyObject?
|
var result: AnyObject?
|
||||||
var query = queryDictionary(key: key)
|
var query = genericPasswordQueryDictionary(account: account, service: service)
|
||||||
|
|
||||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||||
query[kSecReturnData as String] = kCFBooleanTrue
|
query[kSecReturnData as String] = kCFBooleanTrue
|
||||||
|
@ -51,14 +51,58 @@ extension KeychainService: KeychainServiceType {
|
||||||
throw NSError(status: status)
|
throw NSError(status: status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func generateKeyAndReturnPublicKey(applicationTag: String) throws -> Data {
|
||||||
|
var attributes = keyAttributes
|
||||||
|
var error: Unmanaged<CFError>?
|
||||||
|
|
||||||
|
attributes[kSecPrivateKeyAttrs as String] = [
|
||||||
|
kSecAttrIsPermanent as String: true,
|
||||||
|
kSecAttrApplicationTag as String: Data(applicationTag.utf8)]
|
||||||
|
|
||||||
|
guard
|
||||||
|
let key = SecKeyCreateRandomKey(attributes as CFDictionary, &error),
|
||||||
|
let publicKey = SecKeyCopyPublicKey(key),
|
||||||
|
let publicKeyData = SecKeyCopyExternalRepresentation(publicKey, &error) as Data?
|
||||||
|
else { throw error?.takeRetainedValue() ?? NSError() }
|
||||||
|
|
||||||
|
return publicKeyData
|
||||||
|
}
|
||||||
|
|
||||||
|
static func getPrivateKey(applicationTag: String) throws -> Data? {
|
||||||
|
var result: AnyObject?
|
||||||
|
let status = SecItemCopyMatching(keyQueryDictionary(applicationTag: applicationTag) as CFDictionary, &result)
|
||||||
|
|
||||||
|
switch status {
|
||||||
|
case errSecSuccess:
|
||||||
|
return result as? Data
|
||||||
|
case errSecItemNotFound:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
throw NSError(status: status)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension KeychainService {
|
private extension KeychainService {
|
||||||
private func queryDictionary(key: String) -> [String: Any] {
|
static let keySizeInBits = 256
|
||||||
[
|
|
||||||
kSecAttrService as String: serviceName,
|
static func genericPasswordQueryDictionary(account: String, service: String) -> [String: Any] {
|
||||||
kSecAttrAccount as String: key,
|
[kSecAttrService as String: service,
|
||||||
kSecClass as String: kSecClassGenericPassword
|
kSecAttrAccount as String: account,
|
||||||
]
|
kSecClass as String: kSecClassGenericPassword]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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]
|
||||||
}
|
}
|
||||||
|
|
53
Shared/Services/NotificationService.swift
Normal file
53
Shared/Services/NotificationService.swift
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
// Copyright © 2020 Metabolist. All rights reserved.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
struct NotificationService {
|
||||||
|
private let userNotificationCenter: UNUserNotificationCenter
|
||||||
|
|
||||||
|
init(userNotificationCenter: UNUserNotificationCenter = .current()) {
|
||||||
|
self.userNotificationCenter = userNotificationCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NotificationService {
|
||||||
|
func isAuthorized() -> AnyPublisher<Bool, Error> {
|
||||||
|
getNotificationSettings()
|
||||||
|
.map(\.authorizationStatus)
|
||||||
|
.flatMap { status -> AnyPublisher<Bool, Error> in
|
||||||
|
if status == .notDetermined {
|
||||||
|
return requestProvisionalAuthorization().eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
|
return Just(status == .authorized || status == .provisional)
|
||||||
|
.setFailureType(to: Error.self)
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension NotificationService {
|
||||||
|
func getNotificationSettings() -> AnyPublisher<UNNotificationSettings, Never> {
|
||||||
|
Future<UNNotificationSettings, Never> { promise in
|
||||||
|
userNotificationCenter.getNotificationSettings { promise(.success($0)) }
|
||||||
|
}
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestProvisionalAuthorization() -> AnyPublisher<Bool, Error> {
|
||||||
|
Future<Bool, Error> { promise in
|
||||||
|
userNotificationCenter.requestAuthorization(
|
||||||
|
options: [.alert, .sound, .badge, .provisional]) { granted, error in
|
||||||
|
if let error = error {
|
||||||
|
return promise(.failure(error))
|
||||||
|
}
|
||||||
|
|
||||||
|
return promise(.success(granted))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
}
|
|
@ -13,29 +13,36 @@ enum SecretsStorableError: Error {
|
||||||
|
|
||||||
struct SecretsService {
|
struct SecretsService {
|
||||||
let identityID: UUID
|
let identityID: UUID
|
||||||
private let keychainService: KeychainServiceType
|
private let keychainServiceType: KeychainServiceType.Type
|
||||||
|
|
||||||
init(identityID: UUID, keychainService: KeychainServiceType) {
|
init(identityID: UUID, keychainServiceType: KeychainServiceType.Type) {
|
||||||
self.identityID = identityID
|
self.identityID = identityID
|
||||||
self.keychainService = keychainService
|
self.keychainServiceType = keychainServiceType
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension SecretsService {
|
extension SecretsService {
|
||||||
enum Item: String, CaseIterable {
|
enum Item: String, CaseIterable {
|
||||||
case clientID = "client-id"
|
case clientID
|
||||||
case clientSecret = "client-secret"
|
case clientSecret
|
||||||
case accessToken = "access-token"
|
case accessToken
|
||||||
|
case pushKey
|
||||||
|
case pushAuth
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension SecretsService {
|
extension SecretsService {
|
||||||
func set(_ data: SecretsStorable, forItem item: Item) throws {
|
func set(_ data: SecretsStorable, forItem item: Item) throws {
|
||||||
try keychainService.set(data: data.dataStoredInSecrets, forKey: key(item: item))
|
try keychainServiceType.setGenericPassword(
|
||||||
|
data: data.dataStoredInSecrets,
|
||||||
|
forAccount: key(item: item),
|
||||||
|
service: Self.keychainServiceName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func item<T: SecretsStorable>(_ item: Item) throws -> T? {
|
func item<T: SecretsStorable>(_ item: Item) throws -> T? {
|
||||||
guard let data = try keychainService.getData(key: key(item: item)) else {
|
guard let data = try keychainServiceType.getGenericPassword(
|
||||||
|
account: key(item: item),
|
||||||
|
service: Self.keychainServiceName) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -44,12 +51,41 @@ extension SecretsService {
|
||||||
|
|
||||||
func deleteAllItems() throws {
|
func deleteAllItems() throws {
|
||||||
for item in SecretsService.Item.allCases {
|
for item in SecretsService.Item.allCases {
|
||||||
try keychainService.deleteData(key: key(item: item))
|
try keychainServiceType.deleteGenericPassword(
|
||||||
|
account: key(item: item),
|
||||||
|
service: Self.keychainServiceName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func generatePushKeyAndReturnPublicKey() throws -> Data {
|
||||||
|
try keychainServiceType.generateKeyAndReturnPublicKey(applicationTag: key(item: .pushKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPushKey() throws -> Data? {
|
||||||
|
try keychainServiceType.getPrivateKey(applicationTag: key(item: .pushKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
func generatePushAuth() throws -> Data {
|
||||||
|
var bytes = [UInt8](repeating: 0, count: Self.authLength)
|
||||||
|
|
||||||
|
_ = SecRandomCopyBytes(kSecRandomDefault, Self.authLength, &bytes)
|
||||||
|
|
||||||
|
let pushAuth = Data(bytes)
|
||||||
|
|
||||||
|
try set(pushAuth, forItem: .pushAuth)
|
||||||
|
|
||||||
|
return pushAuth
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPushAuth() throws -> Data? {
|
||||||
|
try item(.pushAuth)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension SecretsService {
|
private extension SecretsService {
|
||||||
|
static let keychainServiceName = "com.metabolist.metatext"
|
||||||
|
private static let authLength = 16
|
||||||
|
|
||||||
func key(item: Item) -> String {
|
func key(item: Item) -> String {
|
||||||
identityID.uuidString + "." + item.rawValue
|
identityID.uuidString + "." + item.rawValue
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,15 +7,15 @@ class AddIdentityViewModel: ObservableObject {
|
||||||
@Published var urlFieldText = ""
|
@Published var urlFieldText = ""
|
||||||
@Published var alertItem: AlertItem?
|
@Published var alertItem: AlertItem?
|
||||||
@Published private(set) var loading = false
|
@Published private(set) var loading = false
|
||||||
let addedIdentityID: AnyPublisher<UUID, Never>
|
let addedIdentityIDAndURL: AnyPublisher<(UUID, URL), Never>
|
||||||
|
|
||||||
private let identitiesService: IdentitiesService
|
private let identitiesService: IdentitiesService
|
||||||
private let addedIdentityIDInput = PassthroughSubject<UUID, Never>()
|
private let addedIdentityIDAndURLInput = PassthroughSubject<(UUID, URL), Never>()
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
init(identitiesService: IdentitiesService) {
|
init(identitiesService: IdentitiesService) {
|
||||||
self.identitiesService = identitiesService
|
self.identitiesService = identitiesService
|
||||||
addedIdentityID = addedIdentityIDInput.eraseToAnyPublisher()
|
addedIdentityIDAndURL = addedIdentityIDAndURLInput.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
func logInTapped() {
|
func logInTapped() {
|
||||||
|
@ -33,13 +33,13 @@ class AddIdentityViewModel: ObservableObject {
|
||||||
identitiesService.authorizeIdentity(id: identityID, instanceURL: instanceURL)
|
identitiesService.authorizeIdentity(id: identityID, instanceURL: instanceURL)
|
||||||
.map { (identityID, instanceURL) }
|
.map { (identityID, instanceURL) }
|
||||||
.flatMap(identitiesService.createIdentity(id:instanceURL:))
|
.flatMap(identitiesService.createIdentity(id:instanceURL:))
|
||||||
.map { identityID }
|
.map { (identityID, instanceURL) }
|
||||||
.assignErrorsToAlertItem(to: \.alertItem, on: self)
|
.assignErrorsToAlertItem(to: \.alertItem, on: self)
|
||||||
.receive(on: RunLoop.main)
|
.receive(on: RunLoop.main)
|
||||||
.handleEvents(
|
.handleEvents(
|
||||||
receiveSubscription: { [weak self] _ in self?.loading = true },
|
receiveSubscription: { [weak self] _ in self?.loading = true },
|
||||||
receiveCompletion: { [weak self] _ in self?.loading = false })
|
receiveCompletion: { [weak self] _ in self?.loading = false })
|
||||||
.sink(receiveValue: addedIdentityIDInput.send)
|
.sink(receiveValue: addedIdentityIDAndURLInput.send)
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -57,9 +57,9 @@ class AddIdentityViewModel: ObservableObject {
|
||||||
|
|
||||||
// TODO: Ensure instance has not disabled public preview
|
// TODO: Ensure instance has not disabled public preview
|
||||||
identitiesService.createIdentity(id: identityID, instanceURL: instanceURL)
|
identitiesService.createIdentity(id: identityID, instanceURL: instanceURL)
|
||||||
.map { identityID }
|
.map { (identityID, instanceURL) }
|
||||||
.assignErrorsToAlertItem(to: \.alertItem, on: self)
|
.assignErrorsToAlertItem(to: \.alertItem, on: self)
|
||||||
.sink(receiveValue: addedIdentityIDInput.send)
|
.sink(receiveValue: addedIdentityIDAndURLInput.send)
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,13 +6,27 @@ import Combine
|
||||||
class RootViewModel: ObservableObject {
|
class RootViewModel: ObservableObject {
|
||||||
@Published private(set) var mainNavigationViewModel: MainNavigationViewModel?
|
@Published private(set) var mainNavigationViewModel: MainNavigationViewModel?
|
||||||
|
|
||||||
|
// swiftlint:disable weak_delegate
|
||||||
|
private let appDelegate: AppDelegate
|
||||||
|
// swiftlint:enable weak_delegate
|
||||||
private let identitiesService: IdentitiesService
|
private let identitiesService: IdentitiesService
|
||||||
|
private let notificationService: NotificationService
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
init(identitiesService: IdentitiesService) {
|
init(appDelegate: AppDelegate, identitiesService: IdentitiesService, notificationService: NotificationService) {
|
||||||
|
self.appDelegate = appDelegate
|
||||||
self.identitiesService = identitiesService
|
self.identitiesService = identitiesService
|
||||||
|
self.notificationService = notificationService
|
||||||
|
|
||||||
newIdentitySelected(id: identitiesService.mostRecentlyUsedIdentityID)
|
newIdentitySelected(id: identitiesService.mostRecentlyUsedIdentityID)
|
||||||
|
|
||||||
|
notificationService.isAuthorized()
|
||||||
|
.filter { $0 }
|
||||||
|
.zip(appDelegate.registerForRemoteNotifications())
|
||||||
|
.map { $1 }
|
||||||
|
.flatMap(identitiesService.updatePushSubscriptions(deviceToken:))
|
||||||
|
.sink { _ in } receiveValue: { _ in }
|
||||||
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -45,6 +59,18 @@ extension RootViewModel {
|
||||||
mainNavigationViewModel = MainNavigationViewModel(identityService: identityService)
|
mainNavigationViewModel = MainNavigationViewModel(identityService: identityService)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newIdentityCreated(id: UUID, instanceURL: URL) {
|
||||||
|
newIdentitySelected(id: id)
|
||||||
|
|
||||||
|
notificationService.isAuthorized()
|
||||||
|
.filter { $0 }
|
||||||
|
.zip(appDelegate.registerForRemoteNotifications())
|
||||||
|
.map { (id, instanceURL, $1, nil) }
|
||||||
|
.flatMap(identitiesService.updatePushSubscription(identityID:instanceURL:deviceToken:alerts:))
|
||||||
|
.sink { _ in } receiveValue: { _ in }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
func deleteIdentity(id: UUID) {
|
func deleteIdentity(id: UUID) {
|
||||||
identitiesService.deleteIdentity(id: id)
|
identitiesService.deleteIdentity(id: id)
|
||||||
.sink(receiveCompletion: { _ in }, receiveValue: {})
|
.sink(receiveCompletion: { _ in }, receiveValue: {})
|
||||||
|
|
|
@ -34,9 +34,9 @@ struct AddIdentityView: View {
|
||||||
}
|
}
|
||||||
.paddingIfMac()
|
.paddingIfMac()
|
||||||
.alertItem($viewModel.alertItem)
|
.alertItem($viewModel.alertItem)
|
||||||
.onReceive(viewModel.addedIdentityID) { id in
|
.onReceive(viewModel.addedIdentityIDAndURL) { id, url in
|
||||||
withAnimation {
|
withAnimation {
|
||||||
rootViewModel.newIdentitySelected(id: id)
|
rootViewModel.newIdentityCreated(id: id, instanceURL: url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,33 +9,29 @@ class AddIdentityViewModelTests: XCTestCase {
|
||||||
func testAddIdentity() throws {
|
func testAddIdentity() throws {
|
||||||
let identityDatabase = IdentityDatabase.fresh()
|
let identityDatabase = IdentityDatabase.fresh()
|
||||||
let sut = AddIdentityViewModel(identitiesService: .fresh(identityDatabase: identityDatabase))
|
let sut = AddIdentityViewModel(identitiesService: .fresh(identityDatabase: identityDatabase))
|
||||||
let addedIDRecorder = sut.addedIdentityID.record()
|
let addedIDAndURLRecorder = sut.addedIdentityIDAndURL.record()
|
||||||
|
|
||||||
sut.urlFieldText = "https://mastodon.social"
|
sut.urlFieldText = "https://mastodon.social"
|
||||||
sut.logInTapped()
|
sut.logInTapped()
|
||||||
|
|
||||||
let addedIdentityID = try wait(for: addedIDRecorder.next(), timeout: 1)
|
let addedIdentityIDAndURL = try wait(for: addedIDAndURLRecorder.next(), timeout: 1)
|
||||||
let identityRecorder = identityDatabase.identityObservation(id: addedIdentityID).record()
|
|
||||||
let addedIdentity = try wait(for: identityRecorder.next(), timeout: 1)
|
|
||||||
|
|
||||||
XCTAssertEqual(addedIdentity.id, addedIdentityID)
|
// XCTAssertEqual(addedIdentityIDAndURL.0, addedIdentityID)
|
||||||
XCTAssertEqual(addedIdentity.url, URL(string: "https://mastodon.social")!)
|
XCTAssertEqual(addedIdentityIDAndURL.1, URL(string: "https://mastodon.social")!)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testAddIdentityWithoutScheme() throws {
|
func testAddIdentityWithoutScheme() throws {
|
||||||
let identityDatabase = IdentityDatabase.fresh()
|
let identityDatabase = IdentityDatabase.fresh()
|
||||||
let sut = AddIdentityViewModel(identitiesService: .fresh(identityDatabase: identityDatabase))
|
let sut = AddIdentityViewModel(identitiesService: .fresh(identityDatabase: identityDatabase))
|
||||||
let addedIDRecorder = sut.addedIdentityID.record()
|
let addedIDAndURLRecorder = sut.addedIdentityIDAndURL.record()
|
||||||
|
|
||||||
sut.urlFieldText = "mastodon.social"
|
sut.urlFieldText = "mastodon.social"
|
||||||
sut.logInTapped()
|
sut.logInTapped()
|
||||||
|
|
||||||
let addedIdentityID = try wait(for: addedIDRecorder.next(), timeout: 1)
|
let addedIdentityIDAndURL = try wait(for: addedIDAndURLRecorder.next(), timeout: 1)
|
||||||
let identityRecorder = identityDatabase.identityObservation(id: addedIdentityID).record()
|
|
||||||
let addedIdentity = try wait(for: identityRecorder.next(), timeout: 1)
|
|
||||||
|
|
||||||
XCTAssertEqual(addedIdentity.id, addedIdentityID)
|
// XCTAssertEqual(addedIdentityIDAndURL.0, addedIdentityID)
|
||||||
XCTAssertEqual(addedIdentity.url, URL(string: "https://mastodon.social")!)
|
XCTAssertEqual(addedIdentityIDAndURL.1, URL(string: "https://mastodon.social")!)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testInvalidURL() throws {
|
func testInvalidURL() throws {
|
||||||
|
@ -54,11 +50,11 @@ class AddIdentityViewModelTests: XCTestCase {
|
||||||
|
|
||||||
func testDoesNotAlertCanceledLogin() throws {
|
func testDoesNotAlertCanceledLogin() throws {
|
||||||
let environment = AppEnvironment(
|
let environment = AppEnvironment(
|
||||||
URLSessionConfiguration: .stubbing,
|
session: Session(configuration: .stubbing),
|
||||||
webAuthSessionType: CanceledLoginMockWebAuthSession.self)
|
webAuthSessionType: CanceledLoginMockWebAuthSession.self,
|
||||||
|
keychainServiceType: MockKeychainService.self)
|
||||||
let identitiesService = IdentitiesService(
|
let identitiesService = IdentitiesService(
|
||||||
identityDatabase: .fresh(),
|
identityDatabase: .fresh(),
|
||||||
keychainService: MockKeychainService(),
|
|
||||||
environment: environment)
|
environment: environment)
|
||||||
let sut = AddIdentityViewModel(identitiesService: identitiesService)
|
let sut = AddIdentityViewModel(identitiesService: identitiesService)
|
||||||
let recorder = sut.$alertItem.record()
|
let recorder = sut.$alertItem.record()
|
||||||
|
|
|
@ -9,18 +9,19 @@ class RootViewModelTests: XCTestCase {
|
||||||
var cancellables = Set<AnyCancellable>()
|
var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
func testAddIdentity() throws {
|
func testAddIdentity() throws {
|
||||||
let sut = RootViewModel(identitiesService: IdentitiesService(
|
let sut = RootViewModel(appDelegate: AppDelegate(),
|
||||||
|
identitiesService: IdentitiesService(
|
||||||
identityDatabase: .fresh(),
|
identityDatabase: .fresh(),
|
||||||
keychainService: MockKeychainService(),
|
environment: .development),
|
||||||
environment: .development))
|
notificationService: NotificationService())
|
||||||
let recorder = sut.$mainNavigationViewModel.record()
|
let recorder = sut.$mainNavigationViewModel.record()
|
||||||
|
|
||||||
XCTAssertNil(try wait(for: recorder.next(), timeout: 1))
|
XCTAssertNil(try wait(for: recorder.next(), timeout: 1))
|
||||||
|
|
||||||
let addIdentityViewModel = sut.addIdentityViewModel()
|
let addIdentityViewModel = sut.addIdentityViewModel()
|
||||||
|
|
||||||
addIdentityViewModel.addedIdentityID
|
addIdentityViewModel.addedIdentityIDAndURL
|
||||||
.sink(receiveValue: sut.newIdentitySelected(id:))
|
.sink(receiveValue: sut.newIdentityCreated(id:instanceURL:))
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
addIdentityViewModel.urlFieldText = "https://mastodon.social"
|
addIdentityViewModel.urlFieldText = "https://mastodon.social"
|
||||||
|
|
|
@ -2,6 +2,8 @@
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>com.apple.developer.aps-environment</key>
|
||||||
|
<string>development</string>
|
||||||
<key>com.apple.security.app-sandbox</key>
|
<key>com.apple.security.app-sandbox</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.files.user-selected.read-only</key>
|
<key>com.apple.security.files.user-selected.read-only</key>
|
||||||
|
|
Loading…
Reference in a new issue