diff --git a/DittoToolsApp/DittoToolsApp.xcodeproj/project.pbxproj b/DittoToolsApp/DittoToolsApp.xcodeproj/project.pbxproj index 9f86bfd..6a183c6 100644 --- a/DittoToolsApp/DittoToolsApp.xcodeproj/project.pbxproj +++ b/DittoToolsApp/DittoToolsApp.xcodeproj/project.pbxproj @@ -7,46 +7,42 @@ objects = { /* Begin PBXBuildFile section */ - 016757D928A32E9400347491 /* CollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 016757D828A32E9400347491 /* CollectionView.swift */; }; - 016757DD28A3313300347491 /* PresenceViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 016757DC28A3313300347491 /* PresenceViewer.swift */; }; - 016757DF28A3393B00347491 /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = 016757DE28A3393B00347491 /* Config.swift */; }; 01F34C9728A2EAE3003BDF17 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01F34C9628A2EAE3003BDF17 /* ContentView.swift */; }; - 01F34C9928A2EAE5003BDF17 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 01F34C9828A2EAE5003BDF17 /* Assets.xcassets */; }; 01F34C9C28A2EAE5003BDF17 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 01F34C9B28A2EAE5003BDF17 /* Preview Assets.xcassets */; }; 01F34CA628A2EAE5003BDF17 /* DittoToolsAppTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01F34CA528A2EAE5003BDF17 /* DittoToolsAppTests.swift */; }; 01F34CB028A2EAE5003BDF17 /* DittoToolsAppUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01F34CAF28A2EAE5003BDF17 /* DittoToolsAppUITests.swift */; }; 01F34CB228A2EAE5003BDF17 /* DittoToolsAppUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01F34CB128A2EAE5003BDF17 /* DittoToolsAppUITestsLaunchTests.swift */; }; - 01F34CC528A3046C003BDF17 /* DittoManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01F34CC428A3046C003BDF17 /* DittoManager.swift */; }; - 01F34CD028A304F0003BDF17 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01F34CCA28A304F0003BDF17 /* AppSettings.swift */; }; - 01F34CD128A304F0003BDF17 /* AuthorizationsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01F34CCB28A304F0003BDF17 /* AuthorizationsManager.swift */; }; - 01F34CE228A30781003BDF17 /* MainThreadMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01F34CE028A30781003BDF17 /* MainThreadMonitor.swift */; }; - 01F34CE328A30781003BDF17 /* DiagnosticsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01F34CE128A30781003BDF17 /* DiagnosticsManager.swift */; }; - 01F34CE928A307CD003BDF17 /* Transport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01F34CE528A307CD003BDF17 /* Transport.swift */; }; - 01F34CEB28A307CD003BDF17 /* Server.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01F34CE728A307CD003BDF17 /* Server.swift */; }; - 01F34CEC28A307CD003BDF17 /* ServerConnectionType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01F34CE828A307CD003BDF17 /* ServerConnectionType.swift */; }; - 01F34CEE28A307E2003BDF17 /* IdentityType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01F34CED28A307E2003BDF17 /* IdentityType.swift */; }; - 01F34CF728A30F5E003BDF17 /* DataBrowser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01F34CF628A30F5E003BDF17 /* DataBrowser.swift */; }; - 01F34D0228A310A5003BDF17 /* Login.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01F34D0128A310A5003BDF17 /* Login.swift */; }; - 01F34D0528A31147003BDF17 /* PrimaryFormButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01F34D0428A31147003BDF17 /* PrimaryFormButton.swift */; }; - 01F34D0728A3119E003BDF17 /* MenuListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01F34D0628A3119E003BDF17 /* MenuListItem.swift */; }; 01F34D0928A31644003BDF17 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01F34D0828A31644003BDF17 /* AppDelegate.swift */; }; 074F006D2C514DB900211F51 /* DittoObjC in Frameworks */ = {isa = PBXBuildFile; productRef = 074F006C2C514DB900211F51 /* DittoObjC */; }; 074F006F2C514DB900211F51 /* DittoSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 074F006E2C514DB900211F51 /* DittoSwift */; }; 0E03301C2B8D3C0100C20156 /* DittoPermissionsHealth in Frameworks */ = {isa = PBXBuildFile; productRef = 0E03301B2B8D3C0100C20156 /* DittoPermissionsHealth */; }; 0E60040F2B7BFCD500C27FAF /* DittoPresenceDegradation in Frameworks */ = {isa = PBXBuildFile; productRef = 0E60040E2B7BFCD500C27FAF /* DittoPresenceDegradation */; }; - 0E6F0FA62B7C17270088C0CF /* PresenceDegradationViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E6F0FA52B7C17270088C0CF /* PresenceDegradationViewer.swift */; }; - 0EAA7B242B8D41100078B7F0 /* PermissionsHealthViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EAA7B232B8D41100078B7F0 /* PermissionsHealthViewer.swift */; }; 0ED091502C640FFF00F6403B /* DittoAllToolsMenu in Frameworks */ = {isa = PBXBuildFile; productRef = 0ED0914F2C640FFF00F6403B /* DittoAllToolsMenu */; }; - 0EFAEBC52B99322D00F26744 /* HeartBeatViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EFAEBC42B99322D00F26744 /* HeartBeatViewer.swift */; }; 146ED3F629C4F13100A56229 /* DittoPeersList in Frameworks */ = {isa = PBXBuildFile; productRef = 146ED3F529C4F13100A56229 /* DittoPeersList */; }; - 146ED3FA29C4F2A000A56229 /* PeersListViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 146ED3F929C4F2A000A56229 /* PeersListViewer.swift */; }; - 1474FC932A295A3100C0AC4E /* LoggingDetailsViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1474FC922A295A3100C0AC4E /* LoggingDetailsViewer.swift */; }; 14B7342C2A296A4E0081CEF2 /* DittoExportLogs in Frameworks */ = {isa = PBXBuildFile; productRef = 14B7342B2A296A4E0081CEF2 /* DittoExportLogs */; }; 14E35DB12B7F345C0018EC3B /* DittoHeartbeat in Frameworks */ = {isa = PBXBuildFile; productRef = 14E35DB02B7F345C0018EC3B /* DittoHeartbeat */; }; + 235C3CDC2D10815000AB35AE /* MenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 235C3CDA2D10815000AB35AE /* MenuView.swift */; }; + 235C3CDE2D10817300AB35AE /* SyncButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 235C3CDD2D10817300AB35AE /* SyncButton.swift */; }; + 235C3CE92D10817D00AB35AE /* FormInputData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 235C3CE42D10817D00AB35AE /* FormInputData.swift */; }; + 235C3CEA2D10817D00AB35AE /* IdentityFormInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 235C3CE02D10817D00AB35AE /* IdentityFormInputView.swift */; }; + 235C3CEB2D10817D00AB35AE /* CredentialsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 235C3CE72D10817D00AB35AE /* CredentialsView.swift */; }; + 235C3CEC2D10817D00AB35AE /* FormViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 235C3CE52D10817D00AB35AE /* FormViewModel.swift */; }; + 235C3CED2D10817D00AB35AE /* FormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 235C3CE32D10817D00AB35AE /* FormView.swift */; }; + 235C3CEE2D10817D00AB35AE /* IdentityFormTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 235C3CE12D10817D00AB35AE /* IdentityFormTextField.swift */; }; + 235C3CEF2D10817D00AB35AE /* ClearableTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 235C3CDF2D10817D00AB35AE /* ClearableTextField.swift */; }; + 235C3CF12D10819400AB35AE /* UIScrollView+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 235C3CF02D10819400AB35AE /* UIScrollView+Extension.swift */; }; + 235C3CF22D10830600AB35AE /* Credentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = 235C3CD82D10813300AB35AE /* Credentials.swift */; }; + 235C3CF32D10830600AB35AE /* DittoIdentity+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 235C3CD72D10813300AB35AE /* DittoIdentity+Extension.swift */; }; + 235C3CF52D10831000AB35AE /* KeychainService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 235C3CD52D10810100AB35AE /* KeychainService.swift */; }; + 235C3CF72D10831000AB35AE /* AuthenticationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 235C3CD32D10810100AB35AE /* AuthenticationDelegate.swift */; }; + 235C3CF82D10831500AB35AE /* DittoServiceError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 235C3CD12D10810100AB35AE /* DittoServiceError.swift */; }; + 235C3CF92D10831500AB35AE /* DittoService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 235C3CCF2D10810100AB35AE /* DittoService.swift */; }; + 235C3CFA2D10831500AB35AE /* DittoService+PersistenceDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 235C3CD02D10810100AB35AE /* DittoService+PersistenceDirectory.swift */; }; + 23B911022CC00EB600FD41EF /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 23B911012CC00EB600FD41EF /* Assets.xcassets */; }; + 23DCC4062D1186C0008E92B6 /* CredentialsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23DCC4052D1186C0008E92B6 /* CredentialsService.swift */; }; CC80DC002A1EACCE004A2A65 /* DittoExportData in Frameworks */ = {isa = PBXBuildFile; productRef = CC80DBFF2A1EACCE004A2A65 /* DittoExportData */; }; F87DC47C2988584200899FEC /* DittoDataBrowser in Frameworks */ = {isa = PBXBuildFile; productRef = F87DC47B2988584200899FEC /* DittoDataBrowser */; }; F87DC4802988584200899FEC /* DittoPresenceViewer in Frameworks */ = {isa = PBXBuildFile; productRef = F87DC47F2988584200899FEC /* DittoPresenceViewer */; }; - F87DC481298858EC00899FEC /* DiskUsageViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F87DC46F298854E600899FEC /* DiskUsageViewer.swift */; }; F87DC4832988599C00899FEC /* DittoDiskUsage in Frameworks */ = {isa = PBXBuildFile; productRef = F87DC4822988599C00899FEC /* DittoDiskUsage */; }; /* End PBXBuildFile section */ @@ -68,12 +64,8 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ - 016757D828A32E9400347491 /* CollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionView.swift; sourceTree = ""; }; - 016757DC28A3313300347491 /* PresenceViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresenceViewer.swift; sourceTree = ""; }; - 016757DE28A3393B00347491 /* Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = ""; }; 01F34C9128A2EAE3003BDF17 /* DittoToolsApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DittoToolsApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 01F34C9628A2EAE3003BDF17 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; - 01F34C9828A2EAE5003BDF17 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 01F34C9B28A2EAE5003BDF17 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 01F34CA128A2EAE5003BDF17 /* DittoToolsAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DittoToolsAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 01F34CA528A2EAE5003BDF17 /* DittoToolsAppTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DittoToolsAppTests.swift; sourceTree = ""; }; @@ -81,26 +73,28 @@ 01F34CAF28A2EAE5003BDF17 /* DittoToolsAppUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DittoToolsAppUITests.swift; sourceTree = ""; }; 01F34CB128A2EAE5003BDF17 /* DittoToolsAppUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DittoToolsAppUITestsLaunchTests.swift; sourceTree = ""; }; 01F34CC328A2EC4A003BDF17 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; - 01F34CC428A3046C003BDF17 /* DittoManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DittoManager.swift; sourceTree = ""; }; - 01F34CCA28A304F0003BDF17 /* AppSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; - 01F34CCB28A304F0003BDF17 /* AuthorizationsManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthorizationsManager.swift; sourceTree = ""; }; 01F34CE028A30781003BDF17 /* MainThreadMonitor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainThreadMonitor.swift; sourceTree = ""; }; 01F34CE128A30781003BDF17 /* DiagnosticsManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DiagnosticsManager.swift; sourceTree = ""; }; - 01F34CE528A307CD003BDF17 /* Transport.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Transport.swift; sourceTree = ""; }; - 01F34CE728A307CD003BDF17 /* Server.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Server.swift; sourceTree = ""; }; - 01F34CE828A307CD003BDF17 /* ServerConnectionType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ServerConnectionType.swift; sourceTree = ""; }; - 01F34CED28A307E2003BDF17 /* IdentityType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityType.swift; sourceTree = ""; }; - 01F34CF628A30F5E003BDF17 /* DataBrowser.swift */ = {isa = PBXFileReference; indentWidth = 3; lastKnownFileType = sourcecode.swift; path = DataBrowser.swift; sourceTree = ""; }; - 01F34D0128A310A5003BDF17 /* Login.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Login.swift; sourceTree = ""; }; - 01F34D0428A31147003BDF17 /* PrimaryFormButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryFormButton.swift; sourceTree = ""; }; - 01F34D0628A3119E003BDF17 /* MenuListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuListItem.swift; sourceTree = ""; }; 01F34D0828A31644003BDF17 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 0E6F0FA52B7C17270088C0CF /* PresenceDegradationViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresenceDegradationViewer.swift; sourceTree = ""; }; - 0EAA7B232B8D41100078B7F0 /* PermissionsHealthViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionsHealthViewer.swift; sourceTree = ""; }; - 0EFAEBC42B99322D00F26744 /* HeartBeatViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeartBeatViewer.swift; sourceTree = ""; }; - 146ED3F929C4F2A000A56229 /* PeersListViewer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PeersListViewer.swift; sourceTree = ""; }; - 1474FC922A295A3100C0AC4E /* LoggingDetailsViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggingDetailsViewer.swift; sourceTree = ""; }; - F87DC46F298854E600899FEC /* DiskUsageViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskUsageViewer.swift; sourceTree = ""; }; + 235C3CCF2D10810100AB35AE /* DittoService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DittoService.swift; sourceTree = ""; }; + 235C3CD02D10810100AB35AE /* DittoService+PersistenceDirectory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DittoService+PersistenceDirectory.swift"; sourceTree = ""; }; + 235C3CD12D10810100AB35AE /* DittoServiceError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DittoServiceError.swift; sourceTree = ""; }; + 235C3CD32D10810100AB35AE /* AuthenticationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationDelegate.swift; sourceTree = ""; }; + 235C3CD52D10810100AB35AE /* KeychainService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainService.swift; sourceTree = ""; }; + 235C3CD72D10813300AB35AE /* DittoIdentity+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DittoIdentity+Extension.swift"; sourceTree = ""; }; + 235C3CD82D10813300AB35AE /* Credentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Credentials.swift; sourceTree = ""; }; + 235C3CDA2D10815000AB35AE /* MenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuView.swift; sourceTree = ""; }; + 235C3CDD2D10817300AB35AE /* SyncButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncButton.swift; sourceTree = ""; }; + 235C3CDF2D10817D00AB35AE /* ClearableTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClearableTextField.swift; sourceTree = ""; }; + 235C3CE02D10817D00AB35AE /* IdentityFormInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityFormInputView.swift; sourceTree = ""; }; + 235C3CE12D10817D00AB35AE /* IdentityFormTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityFormTextField.swift; sourceTree = ""; }; + 235C3CE32D10817D00AB35AE /* FormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormView.swift; sourceTree = ""; }; + 235C3CE42D10817D00AB35AE /* FormInputData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormInputData.swift; sourceTree = ""; }; + 235C3CE52D10817D00AB35AE /* FormViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormViewModel.swift; sourceTree = ""; }; + 235C3CE72D10817D00AB35AE /* CredentialsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialsView.swift; sourceTree = ""; }; + 235C3CF02D10819400AB35AE /* UIScrollView+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIScrollView+Extension.swift"; sourceTree = ""; }; + 23B911012CC00EB600FD41EF /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 23DCC4052D1186C0008E92B6 /* CredentialsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialsService.swift; sourceTree = ""; }; F87DC47A2988581000899FEC /* DittoSwiftTools */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = DittoSwiftTools; path = ..; sourceTree = ""; }; /* End PBXFileReference section */ @@ -167,16 +161,13 @@ 01F34C9328A2EAE3003BDF17 /* DittoToolsApp */ = { isa = PBXGroup; children = ( + 235C3CD62D10810100AB35AE /* Services */, 01F34CE428A30798003BDF17 /* Model */, - 01F34D0328A3113A003BDF17 /* Views */, - 01F34D0028A310A5003BDF17 /* Pages */, + 01F34D0028A310A5003BDF17 /* Views */, 01F34CDF28A3076D003BDF17 /* Debug Tools */, 01F34CC328A2EC4A003BDF17 /* Info.plist */, 01F34D0828A31644003BDF17 /* AppDelegate.swift */, - 01F34CCA28A304F0003BDF17 /* AppSettings.swift */, - 01F34CCB28A304F0003BDF17 /* AuthorizationsManager.swift */, - 01F34CC428A3046C003BDF17 /* DittoManager.swift */, - 01F34C9828A2EAE5003BDF17 /* Assets.xcassets */, + 23B911012CC00EB600FD41EF /* Assets.xcassets */, 01F34C9A28A2EAE5003BDF17 /* Preview Content */, ); path = DittoToolsApp; @@ -219,38 +210,19 @@ 01F34CE428A30798003BDF17 /* Model */ = { isa = PBXGroup; children = ( - 01F34CE728A307CD003BDF17 /* Server.swift */, - 01F34CE828A307CD003BDF17 /* ServerConnectionType.swift */, - 01F34CE528A307CD003BDF17 /* Transport.swift */, - 01F34CED28A307E2003BDF17 /* IdentityType.swift */, - 016757DE28A3393B00347491 /* Config.swift */, + 235C3CD72D10813300AB35AE /* DittoIdentity+Extension.swift */, + 235C3CD82D10813300AB35AE /* Credentials.swift */, ); path = Model; sourceTree = ""; }; - 01F34D0028A310A5003BDF17 /* Pages */ = { + 01F34D0028A310A5003BDF17 /* Views */ = { isa = PBXGroup; children = ( - 0EFAEBC42B99322D00F26744 /* HeartBeatViewer.swift */, - 0EAA7B232B8D41100078B7F0 /* PermissionsHealthViewer.swift */, - 0E6F0FA52B7C17270088C0CF /* PresenceDegradationViewer.swift */, - 016757D828A32E9400347491 /* CollectionView.swift */, 01F34C9628A2EAE3003BDF17 /* ContentView.swift */, - 01F34CF628A30F5E003BDF17 /* DataBrowser.swift */, - F87DC46F298854E600899FEC /* DiskUsageViewer.swift */, - 1474FC922A295A3100C0AC4E /* LoggingDetailsViewer.swift */, - 01F34D0128A310A5003BDF17 /* Login.swift */, - 146ED3F929C4F2A000A56229 /* PeersListViewer.swift */, - 016757DC28A3313300347491 /* PresenceViewer.swift */, - ); - path = Pages; - sourceTree = ""; - }; - 01F34D0328A3113A003BDF17 /* Views */ = { - isa = PBXGroup; - children = ( - 01F34D0428A31147003BDF17 /* PrimaryFormButton.swift */, - 01F34D0628A3119E003BDF17 /* MenuListItem.swift */, + 235C3CDB2D10815000AB35AE /* Menu View */, + 235C3CE82D10817D00AB35AE /* Credentials View */, + 235C3CF02D10819400AB35AE /* UIScrollView+Extension.swift */, ); path = Views; sourceTree = ""; @@ -269,6 +241,66 @@ name = Frameworks; sourceTree = ""; }; + 235C3CD22D10810100AB35AE /* Ditto Service */ = { + isa = PBXGroup; + children = ( + 235C3CCF2D10810100AB35AE /* DittoService.swift */, + 235C3CD02D10810100AB35AE /* DittoService+PersistenceDirectory.swift */, + 235C3CD12D10810100AB35AE /* DittoServiceError.swift */, + ); + path = "Ditto Service"; + sourceTree = ""; + }; + 235C3CD62D10810100AB35AE /* Services */ = { + isa = PBXGroup; + children = ( + 235C3CD22D10810100AB35AE /* Ditto Service */, + 235C3CD32D10810100AB35AE /* AuthenticationDelegate.swift */, + 23DCC4052D1186C0008E92B6 /* CredentialsService.swift */, + 235C3CD52D10810100AB35AE /* KeychainService.swift */, + ); + path = Services; + sourceTree = ""; + }; + 235C3CDB2D10815000AB35AE /* Menu View */ = { + isa = PBXGroup; + children = ( + 235C3CDA2D10815000AB35AE /* MenuView.swift */, + 235C3CDD2D10817300AB35AE /* SyncButton.swift */, + ); + path = "Menu View"; + sourceTree = ""; + }; + 235C3CE22D10817D00AB35AE /* Components */ = { + isa = PBXGroup; + children = ( + 235C3CDF2D10817D00AB35AE /* ClearableTextField.swift */, + 235C3CE02D10817D00AB35AE /* IdentityFormInputView.swift */, + 235C3CE12D10817D00AB35AE /* IdentityFormTextField.swift */, + ); + path = Components; + sourceTree = ""; + }; + 235C3CE62D10817D00AB35AE /* Form */ = { + isa = PBXGroup; + children = ( + 235C3CE32D10817D00AB35AE /* FormView.swift */, + 235C3CE42D10817D00AB35AE /* FormInputData.swift */, + 235C3CE52D10817D00AB35AE /* FormViewModel.swift */, + ); + path = Form; + sourceTree = ""; + }; + 235C3CE82D10817D00AB35AE /* Credentials View */ = { + isa = PBXGroup; + children = ( + 235C3CE22D10817D00AB35AE /* Components */, + 235C3CE62D10817D00AB35AE /* Form */, + 235C3CE72D10817D00AB35AE /* CredentialsView.swift */, + ); + path = "Credentials View"; + sourceTree = ""; + }; F87DC4662988501000899FEC /* Packages */ = { isa = PBXGroup; children = ( @@ -399,8 +431,8 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 23B911022CC00EB600FD41EF /* Assets.xcassets in Resources */, 01F34C9C28A2EAE5003BDF17 /* Preview Assets.xcassets in Resources */, - 01F34C9928A2EAE5003BDF17 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -425,30 +457,26 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 0EFAEBC52B99322D00F26744 /* HeartBeatViewer.swift in Sources */, - 0EAA7B242B8D41100078B7F0 /* PermissionsHealthViewer.swift in Sources */, - 016757DD28A3313300347491 /* PresenceViewer.swift in Sources */, - 0E6F0FA62B7C17270088C0CF /* PresenceDegradationViewer.swift in Sources */, - 01F34CD128A304F0003BDF17 /* AuthorizationsManager.swift in Sources */, - 1474FC932A295A3100C0AC4E /* LoggingDetailsViewer.swift in Sources */, + 235C3CDE2D10817300AB35AE /* SyncButton.swift in Sources */, + 235C3CF22D10830600AB35AE /* Credentials.swift in Sources */, + 235C3CF32D10830600AB35AE /* DittoIdentity+Extension.swift in Sources */, + 235C3CDC2D10815000AB35AE /* MenuView.swift in Sources */, 01F34C9728A2EAE3003BDF17 /* ContentView.swift in Sources */, - 01F34CC528A3046C003BDF17 /* DittoManager.swift in Sources */, - 01F34CF728A30F5E003BDF17 /* DataBrowser.swift in Sources */, - 01F34CD028A304F0003BDF17 /* AppSettings.swift in Sources */, - 01F34CE328A30781003BDF17 /* DiagnosticsManager.swift in Sources */, - 01F34CEC28A307CD003BDF17 /* ServerConnectionType.swift in Sources */, - 01F34D0728A3119E003BDF17 /* MenuListItem.swift in Sources */, - 01F34CEE28A307E2003BDF17 /* IdentityType.swift in Sources */, - 01F34CE928A307CD003BDF17 /* Transport.swift in Sources */, - 01F34D0228A310A5003BDF17 /* Login.swift in Sources */, - 01F34CEB28A307CD003BDF17 /* Server.swift in Sources */, - 01F34CE228A30781003BDF17 /* MainThreadMonitor.swift in Sources */, 01F34D0928A31644003BDF17 /* AppDelegate.swift in Sources */, - 146ED3FA29C4F2A000A56229 /* PeersListViewer.swift in Sources */, - 016757DF28A3393B00347491 /* Config.swift in Sources */, - F87DC481298858EC00899FEC /* DiskUsageViewer.swift in Sources */, - 016757D928A32E9400347491 /* CollectionView.swift in Sources */, - 01F34D0528A31147003BDF17 /* PrimaryFormButton.swift in Sources */, + 235C3CF82D10831500AB35AE /* DittoServiceError.swift in Sources */, + 235C3CF92D10831500AB35AE /* DittoService.swift in Sources */, + 235C3CFA2D10831500AB35AE /* DittoService+PersistenceDirectory.swift in Sources */, + 235C3CE92D10817D00AB35AE /* FormInputData.swift in Sources */, + 235C3CEA2D10817D00AB35AE /* IdentityFormInputView.swift in Sources */, + 23DCC4062D1186C0008E92B6 /* CredentialsService.swift in Sources */, + 235C3CEB2D10817D00AB35AE /* CredentialsView.swift in Sources */, + 235C3CF12D10819400AB35AE /* UIScrollView+Extension.swift in Sources */, + 235C3CEC2D10817D00AB35AE /* FormViewModel.swift in Sources */, + 235C3CED2D10817D00AB35AE /* FormView.swift in Sources */, + 235C3CEE2D10817D00AB35AE /* IdentityFormTextField.swift in Sources */, + 235C3CEF2D10817D00AB35AE /* ClearableTextField.swift in Sources */, + 235C3CF52D10831000AB35AE /* KeychainService.swift in Sources */, + 235C3CF72D10831000AB35AE /* AuthenticationDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -537,13 +565,14 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.5; + IPHONEOS_DEPLOYMENT_TARGET = 14.7; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + TVOS_DEPLOYMENT_TARGET = 15.6; }; name = Debug; }; @@ -593,12 +622,13 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.5; + IPHONEOS_DEPLOYMENT_TARGET = 14.7; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; + TVOS_DEPLOYMENT_TARGET = 15.6; VALIDATE_PRODUCT = YES; }; name = Release; @@ -606,8 +636,10 @@ 01F34CB628A2EAE5003BDF17 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon"; + "ASSETCATALOG_COMPILER_APPICON_NAME[sdk=appletv*]" = "tvOS Assets"; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "DittoToolsApp/Preview\\ Content"; @@ -615,6 +647,7 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = DittoToolsApp/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Ditto Tools"; INFOPLIST_KEY_LSApplicationCategoryType = ""; INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Bluetooth sync"; INFOPLIST_KEY_NSBluetoothPeripheralUsageDescription = "Uses Bluetooth to connect and sync with nearby devices\n"; @@ -624,6 +657,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 14.7; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -636,14 +670,17 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,3"; + TVOS_DEPLOYMENT_TARGET = 15.6; }; name = Debug; }; 01F34CB728A2EAE5003BDF17 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon"; + "ASSETCATALOG_COMPILER_APPICON_NAME[sdk=appletv*]" = "tvOS Assets"; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "DittoToolsApp/Preview\\ Content"; @@ -651,6 +688,7 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = DittoToolsApp/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Ditto Tools"; INFOPLIST_KEY_LSApplicationCategoryType = ""; INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Bluetooth sync"; INFOPLIST_KEY_NSBluetoothPeripheralUsageDescription = "Uses Bluetooth to connect and sync with nearby devices\n"; @@ -660,6 +698,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 14.7; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -672,6 +711,7 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,3"; + TVOS_DEPLOYMENT_TARGET = 15.6; }; name = Release; }; diff --git a/DittoToolsApp/DittoToolsApp/AppDelegate.swift b/DittoToolsApp/DittoToolsApp/AppDelegate.swift index f76e041..b3c563a 100644 --- a/DittoToolsApp/DittoToolsApp/AppDelegate.swift +++ b/DittoToolsApp/DittoToolsApp/AppDelegate.swift @@ -1,8 +1,7 @@ // -// DittoMessagesApp.swift -// DittoMessages +// AppDelegate.swift // -// Created by Maximilian Alexander on 7/18/22. +// Copyright © 2024 DittoLive Incorporated. All rights reserved. // import SwiftUI diff --git a/DittoToolsApp/DittoToolsApp/AppSettings.swift b/DittoToolsApp/DittoToolsApp/AppSettings.swift deleted file mode 100644 index 6f71d43..0000000 --- a/DittoToolsApp/DittoToolsApp/AppSettings.swift +++ /dev/null @@ -1,200 +0,0 @@ -// -// Copyright © 2021 DittoLive Incorporated. All rights reserved. -// - -import UIKit -import DittoSwift - - -/// A singleton instance which manages the app settings. The persisted settings -/// include enabled transports and list of available servers. These settings are -/// persisted in `UserDefaults` and so are available on subsequent app launches. -/// -/// This should be re-written to use a private Ditto collection as a local store. -class AppSettings { - - // MARK: - Constants - - private struct UserDefaultsKeys { - static let availableServers = "live.ditto.DittoCarsApp.settings.available-servers" - static let selectedTCPServerId = "live.ditto.DittoCarsApp.settings.selected-tcp-server-id" - static let selectedWebsocketServerId = "live.ditto.DittoCarsApp.settings.selected-websocket-server-id" - static let enabledTransports = "live.ditto.DittoCarsApp.settings.enabled-transports" - static let backgroundNotificationsEnabled = "live.ditto.DittoCarsApp.settings.background-notifications-enabled" - static let diagnosticsLogsEnabled = "live.ditto.DittoCarsApp.settings.diagnostics-logs-enabled" - static let loggingOption = "live.ditto.DittoCarsApp.settings.loggingOption" - } - - private struct Defaults { - /// The default transports to enable if no other settings are saved - static let enabledTransports: Set = Set(Transport.p2pTransports) - - /// The default server list, used if no other settings are saved - static let servers: [Server] = [] - } - - // MARK: - Properties - - private(set) var servers: [Server] { - didSet { - let encoded = try! JSONEncoder().encode(self.servers) - UserDefaults.standard.set(encoded, forKey: UserDefaultsKeys.availableServers) - } - } - - var selectedTCPServer: Server? { - didSet { - let encoded = try! JSONEncoder().encode(self.selectedTCPServer?.id) - UserDefaults.standard.set(encoded, forKey: UserDefaultsKeys.selectedTCPServerId) - } - } - - var selectedWebsocketServer: Server? { - didSet { - let encoded = try! JSONEncoder().encode(self.selectedWebsocketServer?.id) - UserDefaults.standard.set(encoded, forKey: UserDefaultsKeys.selectedWebsocketServerId) - } - } - - var enabledTransports: Set { - didSet { - let encoded = try! JSONEncoder().encode(self.enabledTransports) - UserDefaults.standard.set(encoded, forKey: UserDefaultsKeys.enabledTransports) - } - } - - var backgroundNotificationsEnabled: Bool { - didSet { - UserDefaults.standard.set(self.backgroundNotificationsEnabled, - forKey: UserDefaultsKeys.backgroundNotificationsEnabled) - - if !oldValue && self.backgroundNotificationsEnabled { - AuthorizationsManager.shared.requestNotificationAuthorization() - } - } - } - - var diagnosticLogsEnabled: Bool { - didSet { - UserDefaults.standard.set(self.diagnosticLogsEnabled, forKey: UserDefaultsKeys.diagnosticsLogsEnabled) - DiagnosticsManager.shared.isEnabled = self.diagnosticLogsEnabled - } - } - - /// This property is initialized in the private init() below, setting UserDefaults with a default value, .debug, if not yet set. - var loggingOption: DittoLogger.LoggingOptions { - didSet { - UserDefaults.standard.set(self.loggingOption.rawValue, forKey: UserDefaultsKeys.loggingOption) - } - } - - // MARK: - Singleton - - /// Singleton instance. All access is via `AppSettings.shared`. - static var shared = AppSettings() - - // MARK: - Functions & Computed Properties - - func removeServer(_ server: Server) { - self.servers.removeAll(where: { $0.id == server.id }) - - // Maybe the server being removed was in use as our selected websocket or tcp - // server. If so, remove it. - if self.selectedWebsocketServer == server { - self.selectedWebsocketServer = nil - } - if self.selectedTCPServer == server { - self.selectedTCPServer = nil - } - } - - /// Adds a new server, or updates an existing server with the same `id`. - func addOrAmendServer(_ server: Server) { - if let existingIndex = self.servers.firstIndex(where: { $0.id == server.id }) { - servers[existingIndex] = server - } else { - self.servers.append(server) - } - - // Maybe a current selection has been invalidated (i.e. our websocket server was - // amended such that its websocket port was removed). - if self.selectedWebsocketServer == server { - self.selectedWebsocketServer = server.websocketPort == nil ? nil : server - } - if self.selectedTCPServer == server { - self.selectedTCPServer = server.tcpPort == nil ? nil : server - } - } - - func setTransportEnabled(_ transport: Transport, enabled: Bool) { - if enabled { - self.enabledTransports.insert(transport) - } else { - self.enabledTransports.remove(transport) - } - } - - func isTransportEnabled(_ transport: Transport) -> Bool { - return self.enabledTransports.contains(transport) - } - - func populateDefaultServers() -> Int { - var numAdded = 0 - for server in Defaults.servers { - if !self.servers.contains(where: { $0.id == server.id }) { - numAdded += 1 - } - self.addOrAmendServer(server) - } - - return numAdded - } - - var areAllDefaultServersPresent: Bool { - return Set(self.servers.map { $0.id }).isSuperset(of: Set(Defaults.servers.map { $0.id })) - } - - // MARK: - Private Functions - - private init() { - self.servers = Self.loadJSON(key: UserDefaultsKeys.availableServers, defaultValue: Defaults.servers) - self.enabledTransports = Self.loadJSON(key: UserDefaultsKeys.enabledTransports, - defaultValue: Defaults.enabledTransports) - - let tcpServerId: UUID? = Self.loadJSON(key: UserDefaultsKeys.selectedTCPServerId, defaultValue: nil) - self.selectedTCPServer = self.servers.first(where: { $0.id == tcpServerId }) - - let websocketServerId: UUID? = Self.loadJSON(key: UserDefaultsKeys.selectedWebsocketServerId, defaultValue: nil) - self.selectedWebsocketServer = self.servers.first(where: { $0.id == websocketServerId }) - - self.backgroundNotificationsEnabled = UserDefaults.standard.bool( - forKey: UserDefaultsKeys.backgroundNotificationsEnabled) - self.diagnosticLogsEnabled = UserDefaults.standard.bool(forKey: UserDefaultsKeys.diagnosticsLogsEnabled) - - if let logOption = UserDefaults.standard.object(forKey: UserDefaultsKeys.loggingOption) as? Int { - self.loggingOption = DittoLogger.LoggingOptions(rawValue: logOption)! - } else { - self.loggingOption = DittoLogger.LoggingOptions(rawValue: DittoLogger.LoggingOptions.debug.rawValue)! - } - } - - // MARK: - Static Functions - - private static func loadJSON(key: String, defaultValue: T) -> T { - if let value = UserDefaults.standard.object(forKey: key) { - if let data = value as? Data, let decoded = try? JSONDecoder().decode(T.self, from: data) { - return decoded - } else { - // Found a saved value, but it couldn't be loaded. Presumably it was from - // an older version of the cars app and is now incompatible. This data - // isn't crucial, so let's just erase it so it's fixed for next time. - UserDefaults.standard.removeObject(forKey: key) - return defaultValue - } - } else { - // No previously saved transports - use defaults - return defaultValue - } - } - -} diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/App Icon.appiconset/Artwork - Dark.png b/DittoToolsApp/DittoToolsApp/Assets.xcassets/App Icon.appiconset/Artwork - Dark.png new file mode 100644 index 0000000..422825e Binary files /dev/null and b/DittoToolsApp/DittoToolsApp/Assets.xcassets/App Icon.appiconset/Artwork - Dark.png differ diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/App Icon.appiconset/Artwork - Light.png b/DittoToolsApp/DittoToolsApp/Assets.xcassets/App Icon.appiconset/Artwork - Light.png new file mode 100644 index 0000000..4f8f3bf Binary files /dev/null and b/DittoToolsApp/DittoToolsApp/Assets.xcassets/App Icon.appiconset/Artwork - Light.png differ diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/App Icon.appiconset/Artwork - Tinted.png b/DittoToolsApp/DittoToolsApp/Assets.xcassets/App Icon.appiconset/Artwork - Tinted.png new file mode 100644 index 0000000..efc073a Binary files /dev/null and b/DittoToolsApp/DittoToolsApp/Assets.xcassets/App Icon.appiconset/Artwork - Tinted.png differ diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/App Icon.appiconset/Contents.json b/DittoToolsApp/DittoToolsApp/Assets.xcassets/App Icon.appiconset/Contents.json new file mode 100644 index 0000000..f448732 --- /dev/null +++ b/DittoToolsApp/DittoToolsApp/Assets.xcassets/App Icon.appiconset/Contents.json @@ -0,0 +1,38 @@ +{ + "images" : [ + { + "filename" : "Artwork - Light.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "Artwork - Dark.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "filename" : "Artwork - Tinted.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png b/DittoToolsApp/DittoToolsApp/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png deleted file mode 100644 index 9469eaf..0000000 Binary files a/DittoToolsApp/DittoToolsApp/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png and /dev/null differ diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x-1.jpg b/DittoToolsApp/DittoToolsApp/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x-1.jpg deleted file mode 100644 index c16584a..0000000 Binary files a/DittoToolsApp/DittoToolsApp/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x-1.jpg and /dev/null differ diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.jpg b/DittoToolsApp/DittoToolsApp/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.jpg deleted file mode 100644 index c16584a..0000000 Binary files a/DittoToolsApp/DittoToolsApp/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.jpg and /dev/null differ diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png b/DittoToolsApp/DittoToolsApp/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png deleted file mode 100644 index e90325f..0000000 Binary files a/DittoToolsApp/DittoToolsApp/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png and /dev/null differ diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png b/DittoToolsApp/DittoToolsApp/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png deleted file mode 100644 index 4c93dc5..0000000 Binary files a/DittoToolsApp/DittoToolsApp/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png and /dev/null differ diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x-1.png b/DittoToolsApp/DittoToolsApp/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x-1.png deleted file mode 100644 index 3e9b5d9..0000000 Binary files a/DittoToolsApp/DittoToolsApp/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x-1.png and /dev/null differ diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png b/DittoToolsApp/DittoToolsApp/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png deleted file mode 100644 index 3e9b5d9..0000000 Binary files a/DittoToolsApp/DittoToolsApp/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png and /dev/null differ diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png b/DittoToolsApp/DittoToolsApp/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png deleted file mode 100644 index ab8222e..0000000 Binary files a/DittoToolsApp/DittoToolsApp/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png and /dev/null differ diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x-1.png b/DittoToolsApp/DittoToolsApp/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x-1.png deleted file mode 100644 index f3a0d25..0000000 Binary files a/DittoToolsApp/DittoToolsApp/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x-1.png and /dev/null differ diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png b/DittoToolsApp/DittoToolsApp/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png deleted file mode 100644 index f3a0d25..0000000 Binary files a/DittoToolsApp/DittoToolsApp/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png and /dev/null differ diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png b/DittoToolsApp/DittoToolsApp/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png deleted file mode 100644 index 2c90226..0000000 Binary files a/DittoToolsApp/DittoToolsApp/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png and /dev/null differ diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png b/DittoToolsApp/DittoToolsApp/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png deleted file mode 100644 index a2da367..0000000 Binary files a/DittoToolsApp/DittoToolsApp/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png and /dev/null differ diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png b/DittoToolsApp/DittoToolsApp/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png deleted file mode 100644 index a75deec..0000000 Binary files a/DittoToolsApp/DittoToolsApp/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png and /dev/null differ diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png b/DittoToolsApp/DittoToolsApp/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png deleted file mode 100644 index 3bac241..0000000 Binary files a/DittoToolsApp/DittoToolsApp/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png and /dev/null differ diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/DittoToolsApp/DittoToolsApp/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index d70ef1a..0000000 --- a/DittoToolsApp/DittoToolsApp/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,112 +0,0 @@ -{ - "images" : [ - { - "idiom" : "iphone", - "scale" : "2x", - "size" : "20x20" - }, - { - "idiom" : "iphone", - "scale" : "3x", - "size" : "20x20" - }, - { - "filename" : "AppIcon-29x29@2x-1.jpg", - "idiom" : "iphone", - "scale" : "2x", - "size" : "29x29" - }, - { - "filename" : "AppIcon-29x29@3x.png", - "idiom" : "iphone", - "scale" : "3x", - "size" : "29x29" - }, - { - "filename" : "AppIcon-40x40@2x-1.png", - "idiom" : "iphone", - "scale" : "2x", - "size" : "40x40" - }, - { - "filename" : "AppIcon-60x60@2x-1.png", - "idiom" : "iphone", - "scale" : "3x", - "size" : "40x40" - }, - { - "filename" : "AppIcon-60x60@2x.png", - "idiom" : "iphone", - "scale" : "2x", - "size" : "60x60" - }, - { - "filename" : "AppIcon-60x60@3x.png", - "idiom" : "iphone", - "scale" : "3x", - "size" : "60x60" - }, - { - "idiom" : "ipad", - "scale" : "1x", - "size" : "20x20" - }, - { - "idiom" : "ipad", - "scale" : "2x", - "size" : "20x20" - }, - { - "filename" : "AppIcon-29x29@1x.png", - "idiom" : "ipad", - "scale" : "1x", - "size" : "29x29" - }, - { - "filename" : "AppIcon-29x29@2x.jpg", - "idiom" : "ipad", - "scale" : "2x", - "size" : "29x29" - }, - { - "filename" : "AppIcon-40x40@1x.png", - "idiom" : "ipad", - "scale" : "1x", - "size" : "40x40" - }, - { - "filename" : "AppIcon-40x40@2x.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "40x40" - }, - { - "filename" : "AppIcon-76x76@1x.png", - "idiom" : "ipad", - "scale" : "1x", - "size" : "76x76" - }, - { - "filename" : "AppIcon-76x76@2x.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "76x76" - }, - { - "filename" : "AppIcon-83.5x83.5@2x.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "83.5x83.5" - }, - { - "filename" : "AppIcon-512@2x.png", - "idiom" : "ios-marketing", - "scale" : "1x", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/Symbols/Contents.json b/DittoToolsApp/DittoToolsApp/Assets.xcassets/Symbols/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/DittoToolsApp/DittoToolsApp/Assets.xcassets/Symbols/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/Symbols/Ditto.LogoMark.Blue.imageset/Contents.json b/DittoToolsApp/DittoToolsApp/Assets.xcassets/Symbols/Ditto.LogoMark.Blue.imageset/Contents.json new file mode 100644 index 0000000..d6b181c --- /dev/null +++ b/DittoToolsApp/DittoToolsApp/Assets.xcassets/Symbols/Ditto.LogoMark.Blue.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Ditto.LogoMark.Blue.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/Symbols/Ditto.LogoMark.Blue.imageset/Ditto.LogoMark.Blue.svg b/DittoToolsApp/DittoToolsApp/Assets.xcassets/Symbols/Ditto.LogoMark.Blue.imageset/Ditto.LogoMark.Blue.svg new file mode 100644 index 0000000..3ba8cb0 --- /dev/null +++ b/DittoToolsApp/DittoToolsApp/Assets.xcassets/Symbols/Ditto.LogoMark.Blue.imageset/Ditto.LogoMark.Blue.svg @@ -0,0 +1,4 @@ + + + + diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/Symbols/key.2.on.ring.fill.symbolset/Contents.json b/DittoToolsApp/DittoToolsApp/Assets.xcassets/Symbols/key.2.on.ring.fill.symbolset/Contents.json new file mode 100644 index 0000000..cb81c5b --- /dev/null +++ b/DittoToolsApp/DittoToolsApp/Assets.xcassets/Symbols/key.2.on.ring.fill.symbolset/Contents.json @@ -0,0 +1,12 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "filename" : "key.2.on.ring.fill.svg", + "idiom" : "universal" + } + ] +} diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/Symbols/key.2.on.ring.fill.symbolset/key.2.on.ring.fill.svg b/DittoToolsApp/DittoToolsApp/Assets.xcassets/Symbols/key.2.on.ring.fill.symbolset/key.2.on.ring.fill.svg new file mode 100644 index 0000000..b955118 --- /dev/null +++ b/DittoToolsApp/DittoToolsApp/Assets.xcassets/Symbols/key.2.on.ring.fill.symbolset/key.2.on.ring.fill.svg @@ -0,0 +1,101 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.4.0 + Requires Xcode 14 or greater + Generated from key.2.on.ring.fill + Typeset at 100.0 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/dittoBlue.colorset/Contents.json b/DittoToolsApp/DittoToolsApp/Assets.xcassets/dittoBlue.colorset/Contents.json new file mode 100644 index 0000000..1e7da17 --- /dev/null +++ b/DittoToolsApp/DittoToolsApp/Assets.xcassets/dittoBlue.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.965", + "green" : "0.392", + "red" : "0.169" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.961", + "green" : "0.263", + "red" : "0.102" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Back~tv-app-store@1280w.png b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Back~tv-app-store@1280w.png new file mode 100644 index 0000000..2f897d2 Binary files /dev/null and b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Back~tv-app-store@1280w.png differ diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json new file mode 100644 index 0000000..7c3d7d6 --- /dev/null +++ b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Back~tv-app-store@1280w.png", + "idiom" : "tv" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Contents.json b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon - App Store.imagestack/Contents.json b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon - App Store.imagestack/Contents.json new file mode 100644 index 0000000..de59d88 --- /dev/null +++ b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon - App Store.imagestack/Contents.json @@ -0,0 +1,17 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "layers" : [ + { + "filename" : "Front.imagestacklayer" + }, + { + "filename" : "Middle.imagestacklayer" + }, + { + "filename" : "Back.imagestacklayer" + } + ] +} diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Contents.json b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Contents.json new file mode 100644 index 0000000..4d52f2d --- /dev/null +++ b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Front~tv-app-store@1280w.png", + "idiom" : "tv" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Front~tv-app-store@1280w.png b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Front~tv-app-store@1280w.png new file mode 100644 index 0000000..a47d1b9 Binary files /dev/null and b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Front~tv-app-store@1280w.png differ diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Contents.json b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json new file mode 100644 index 0000000..b2e0513 --- /dev/null +++ b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Middle~tv-app-store@1280w.png", + "idiom" : "tv" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/Middle~tv-app-store@1280w.png b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/Middle~tv-app-store@1280w.png new file mode 100644 index 0000000..f396763 Binary files /dev/null and b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/Middle~tv-app-store@1280w.png differ diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Contents.json b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Back~tv.png b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Back~tv.png new file mode 100644 index 0000000..84fb1ae Binary files /dev/null and b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Back~tv.png differ diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Back~tv@2x.png b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Back~tv@2x.png new file mode 100644 index 0000000..93a5d39 Binary files /dev/null and b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Back~tv@2x.png differ diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json new file mode 100644 index 0000000..a407496 --- /dev/null +++ b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json @@ -0,0 +1,18 @@ +{ + "images" : [ + { + "filename" : "Back~tv.png", + "idiom" : "tv", + "scale" : "1x" + }, + { + "filename" : "Back~tv@2x.png", + "idiom" : "tv", + "scale" : "2x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon.imagestack/Contents.json b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon.imagestack/Contents.json new file mode 100644 index 0000000..de59d88 --- /dev/null +++ b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon.imagestack/Contents.json @@ -0,0 +1,17 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "layers" : [ + { + "filename" : "Front.imagestacklayer" + }, + { + "filename" : "Middle.imagestacklayer" + }, + { + "filename" : "Back.imagestacklayer" + } + ] +} diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json new file mode 100644 index 0000000..26b7f23 --- /dev/null +++ b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json @@ -0,0 +1,18 @@ +{ + "images" : [ + { + "filename" : "Front~tv.png", + "idiom" : "tv", + "scale" : "1x" + }, + { + "filename" : "Front~tv@2x.png", + "idiom" : "tv", + "scale" : "2x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Front~tv.png b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Front~tv.png new file mode 100644 index 0000000..6971c81 Binary files /dev/null and b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Front~tv.png differ diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Front~tv@2x.png b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Front~tv@2x.png new file mode 100644 index 0000000..a85ed5b Binary files /dev/null and b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Front~tv@2x.png differ diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json new file mode 100644 index 0000000..d1356f8 --- /dev/null +++ b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json @@ -0,0 +1,18 @@ +{ + "images" : [ + { + "filename" : "Middle~tv.png", + "idiom" : "tv", + "scale" : "1x" + }, + { + "filename" : "Middle~tv@2x.png", + "idiom" : "tv", + "scale" : "2x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Middle~tv.png b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Middle~tv.png new file mode 100644 index 0000000..70169a5 Binary files /dev/null and b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Middle~tv.png differ diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Middle~tv@2x.png b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Middle~tv@2x.png new file mode 100644 index 0000000..4de2dfd Binary files /dev/null and b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Middle~tv@2x.png differ diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon.imagestack/Middle.imagestacklayer/Contents.json b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon.imagestack/Middle.imagestacklayer/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/App Icon.imagestack/Middle.imagestacklayer/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/Contents.json b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/Contents.json new file mode 100644 index 0000000..f47ba43 --- /dev/null +++ b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/Contents.json @@ -0,0 +1,32 @@ +{ + "assets" : [ + { + "filename" : "App Icon - App Store.imagestack", + "idiom" : "tv", + "role" : "primary-app-icon", + "size" : "1280x768" + }, + { + "filename" : "App Icon.imagestack", + "idiom" : "tv", + "role" : "primary-app-icon", + "size" : "400x240" + }, + { + "filename" : "Top Shelf Image Wide.imageset", + "idiom" : "tv", + "role" : "top-shelf-image-wide", + "size" : "2320x720" + }, + { + "filename" : "Top Shelf Image.imageset", + "idiom" : "tv", + "role" : "top-shelf-image", + "size" : "1920x720" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/Top Shelf Image Wide.imageset/Contents.json b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/Top Shelf Image Wide.imageset/Contents.json new file mode 100644 index 0000000..cf913b4 --- /dev/null +++ b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/Top Shelf Image Wide.imageset/Contents.json @@ -0,0 +1,18 @@ +{ + "images" : [ + { + "filename" : "Top Shelf Wide: 2320 x 720 pt.png", + "idiom" : "tv", + "scale" : "1x" + }, + { + "filename" : "Top Shelf Wide: 2320 x 720 pt@2x.png", + "idiom" : "tv", + "scale" : "2x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/Top Shelf Image Wide.imageset/Top Shelf Wide: 2320 x 720 pt.png b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/Top Shelf Image Wide.imageset/Top Shelf Wide: 2320 x 720 pt.png new file mode 100644 index 0000000..eef6e0a Binary files /dev/null and b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/Top Shelf Image Wide.imageset/Top Shelf Wide: 2320 x 720 pt.png differ diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/Top Shelf Image Wide.imageset/Top Shelf Wide: 2320 x 720 pt@2x.png b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/Top Shelf Image Wide.imageset/Top Shelf Wide: 2320 x 720 pt@2x.png new file mode 100644 index 0000000..af89148 Binary files /dev/null and b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/Top Shelf Image Wide.imageset/Top Shelf Wide: 2320 x 720 pt@2x.png differ diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/Top Shelf Image.imageset/Contents.json b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/Top Shelf Image.imageset/Contents.json new file mode 100644 index 0000000..a7f0ce6 --- /dev/null +++ b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/Top Shelf Image.imageset/Contents.json @@ -0,0 +1,18 @@ +{ + "images" : [ + { + "filename" : "Top Shelf: 1920 x 720 pt.png", + "idiom" : "tv", + "scale" : "1x" + }, + { + "filename" : "Top Shelf: 1920 x 720 pt@2x.png", + "idiom" : "tv", + "scale" : "2x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/Top Shelf Image.imageset/Top Shelf: 1920 x 720 pt.png b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/Top Shelf Image.imageset/Top Shelf: 1920 x 720 pt.png new file mode 100644 index 0000000..2c12ed4 Binary files /dev/null and b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/Top Shelf Image.imageset/Top Shelf: 1920 x 720 pt.png differ diff --git a/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/Top Shelf Image.imageset/Top Shelf: 1920 x 720 pt@2x.png b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/Top Shelf Image.imageset/Top Shelf: 1920 x 720 pt@2x.png new file mode 100644 index 0000000..21f72fb Binary files /dev/null and b/DittoToolsApp/DittoToolsApp/Assets.xcassets/tvOS Assets.brandassets/Top Shelf Image.imageset/Top Shelf: 1920 x 720 pt@2x.png differ diff --git a/DittoToolsApp/DittoToolsApp/AuthorizationsManager.swift b/DittoToolsApp/DittoToolsApp/AuthorizationsManager.swift deleted file mode 100644 index cf1a0ae..0000000 --- a/DittoToolsApp/DittoToolsApp/AuthorizationsManager.swift +++ /dev/null @@ -1,112 +0,0 @@ -// -// Copyright © 2021 DittoLive Incorporated. All rights reserved. -// - -import UIKit -import CoreBluetooth - -// MARK: - AuthorizationStatus - -/// Each sub-component has its own strongly typed authorization status -/// and includes a few kinds of authorization we're not overly concerned -/// with. We define a simpler category here which corresponds to the -/// major decisions our app needs to take. -enum AuthorizationStatus: CaseIterable, Equatable, Hashable { - case authorized - case denied - case notDetermined -} - -extension AuthorizationStatus: CustomStringConvertible { - var description: String { - switch self { - case .authorized: - return "authorized" - case .denied: - return "denied" - case .notDetermined: - return "not yet requested" - } - } -} - -// MARK: - AuthorizationsManager - -/// A singleton which offers a convenient single point for interacting -/// with the various user authorizations we might need (notifications, -/// bluetooth, etc.) -/// -/// We unfortunately can't seem to (easily) check for local network -/// authorization. -class AuthorizationsManager { - - // MARK: - Properties - - var bleAuthorizationStatus: AuthorizationStatus { - switch CBCentralManager.authorization { - case .allowedAlways: - return .authorized - case .notDetermined: - return .notDetermined - case .restricted: - return .denied - case .denied: - return .denied - @unknown default: - print("WARNING: Unknown CBCentralManager status") - return .notDetermined - } - } - - var localNotificationAuthorizationStatus: AuthorizationStatus { - var status = AuthorizationStatus.notDetermined - // Such a hack. Look away. - let semaphore = DispatchSemaphore(value: 0) - - UNUserNotificationCenter.current().getNotificationSettings { settings in - switch settings.authorizationStatus { - case .notDetermined: - status = .notDetermined - case .denied: - status = .denied - case .authorized: - status = .authorized - case .ephemeral: - status = .authorized - case .provisional: - status = .authorized - @unknown default: - print("WARNING: Unknown UNUserNotificationCenter status") - status = .notDetermined - } - semaphore.signal() - } - - _ = semaphore.wait(wallTimeout: .distantFuture) - return status - } - - // MARK: - Singleton - - /// Singleton instance. All access is via `AuthorizationsManager.shared`. - static var shared = AuthorizationsManager() - - // MARK: - Private Constructor - - private init() {} - - // MARK: - Functions - - func requestNotificationAuthorization() { - let notificationCenter = UNUserNotificationCenter.current() - notificationCenter.requestAuthorization(options: [.alert, .sound]) { granted, error in - if !granted { - print("Request for user notifications authorization was denied") - } - if let error = error { - print("Request for user notifications authorization failed with error \(error)") - } - } - } - -} diff --git a/DittoToolsApp/DittoToolsApp/DittoManager.swift b/DittoToolsApp/DittoToolsApp/DittoManager.swift deleted file mode 100644 index 0b388b1..0000000 --- a/DittoToolsApp/DittoToolsApp/DittoManager.swift +++ /dev/null @@ -1,208 +0,0 @@ -// -// Copyright © 2021 DittoLive Incorporated. All rights reserved. -// - -import Combine -import DittoExportLogs -import DittoSwift -import Foundation - -class AuthDelegate: DittoAuthenticationDelegate { - func authenticationRequired(authenticator: DittoAuthenticator) { - let provider = DittoManager.shared.config.authenticationProvider - let token = DittoManager.shared.config.authenticationToken - print("login with \(token), \(provider)") - authenticator.login(token: token, provider: provider) {json, error in - if let err = error { - print("Error authenticating \(String(describing: err.localizedDescription))") - } - } - } - - func authenticationExpiringSoon(authenticator: DittoAuthenticator, secondsRemaining: Int64) { - let provider = DittoManager.shared.config.authenticationProvider - let token = DittoManager.shared.config.authenticationToken - print("Auth token expiring in \(secondsRemaining)") - authenticator.login(token: token, provider: provider) {json, error in - if let err = error { - print("Error authenticating \(String(describing: err.localizedDescription))") - } - } - } -} - -struct DittoStartError: Error { - let message: String - - init(_ message: String) { - self.message = message - } - - public var localizedDescription: String { - return message - } -} - -/// A singleton which manages our `Ditto` object. -class DittoManager: ObservableObject { - - // MARK: - Properties - - var ditto: Ditto? = Ditto() - - @Published var config = DittoConfig( - appID: "YOUR_APP_ID_HERE", - playgroundToken: "YOUR_TOKEN_HERE", - identityType: IdentityType.onlinePlayground, - offlineLicenseToken: "YOUR_OFFLINE_LICENSE_HERE", - authenticationProvider: "", - authenticationToken: "", - useIsolatedDirectories: true - ) - @Published var colls = [DittoCollection]() - @Published var loggingOption: DittoLogger.LoggingOptions - private var cancellables = Set() - - // MARK: - Singleton - - /// Singleton instance. All access is via `DittoManager.shared`. - static var shared = DittoManager() - var collectionsObserver: DittoLiveQuery? - var collectionsSubscription: DittoSubscription? - var authDelegate = AuthDelegate() - - // MARK: - Private Constructor - - private init() { - self.loggingOption = AppSettings.shared.loggingOption - - // make sure log level is set _before_ starting ditto - $loggingOption - .sink {[weak self] option in - AppSettings.shared.loggingOption = option - self?.setupLogging() - } - .store(in: &cancellables) - } - - func getPersistenceDir (config: DittoConfig) -> URL? { - if (!config.useIsolatedDirectories) { return nil } - print("Giving isolated directory") - return topLevelDittoDir() - .appendingPathComponent(config.appID) - .appendingPathComponent(UUID().uuidString) - } - - // MARK: - Functions - - func restartDitto() throws { - self.ditto?.stopSync() - self.ditto = nil - let persistenceDir = getPersistenceDir(config: config) - - // make sure our log level is set _before_ starting ditto. - setupLogging() - - switch (self.config.identityType) { - case IdentityType.onlinePlayground: - let appID = UUID(uuidString: self.config.appID) - let token = UUID(uuidString: self.config.playgroundToken) - if (appID == nil || token == nil) { - throw DittoStartError("AppID and Token are not valid UUIDs.") - } - self.ditto = Ditto(identity: .onlinePlayground(appID: self.config.appID, token: self.config.playgroundToken), persistenceDirectory: persistenceDir) - case IdentityType.onlineWithAuthentication: - self.authDelegate = AuthDelegate() - let appID = UUID(uuidString: self.config.appID) - if (appID == nil) { - throw DittoStartError("AppID is not a valid UUID.") - } - self.ditto = Ditto(identity: .onlineWithAuthentication(appID: self.config.appID, authenticationDelegate: self.authDelegate), persistenceDirectory: persistenceDir) - case IdentityType.offlinePlayground: - self.ditto = Ditto(identity: .offlinePlayground(appID: self.config.appID), persistenceDirectory: persistenceDir) - try self.ditto!.setOfflineOnlyLicenseToken(self.config.offlineLicenseToken) - } - - self.ditto!.delegate = self - - do { - try ditto!.startSync() - } catch { - assertionFailure(error.localizedDescription) - } - - DispatchQueue.main.async { - // Let the DittoManager finish getting created, then apply initial diagnostics setting - DiagnosticsManager.shared.isEnabled = AppSettings.shared.diagnosticLogsEnabled - } - - setupLiveQueries() - } - - func setupLiveQueries () { - self.collectionsSubscription = DittoManager.shared.ditto?.store.collections().subscribe() - self.collectionsObserver = DittoManager.shared.ditto?.store.collections().observeLocal(eventHandler: { event in - self.colls = DittoManager.shared.ditto?.store.collections().exec() ?? [] - }) - } - - func setupLogging() { - let logOption = AppSettings.shared.loggingOption - switch logOption { - case .disabled: - DittoLogger.enabled = false - default: - DittoLogger.enabled = true - DittoLogger.minimumLogLevel = DittoLogLevel(rawValue: logOption.rawValue)! - } - } -} - - -// MARK: - DittoDelegate - -extension DittoManager: DittoDelegate { - - func dittoTransportConditionDidChange(ditto: Ditto, - condition: DittoTransportCondition, - subsystem: DittoConditionSource) { - print("Condition update from \(subsystem)") - - if condition == .BleDisabled { - print("BLE disabled") - } else if condition == .NoBleCentralPermission { - print("Permission missing for BLE") - } else if condition == .NoBlePeripheralPermission { - print("Permission missing for BLE") - } else if condition == .Ok { - print("Ok") - } - } - - // Test code: currently frequently used by @thombles but otherwise not - // called from anywhere. - func dittoIdentityProviderAuthenticationRequest(ditto: Ditto, request: DittoAuthenticationRequest) { - print("CarsApp: Authentication Request") - - if request.thirdPartyToken == "jellybeans" { - let success = DittoAuthenticationSuccess() - success.userID = "tom@ditto.live" - success.accessExpires = Date().addingTimeInterval(3600) - success.addWritePermission(forCollection: "test", queryString: "true") - success.addReadPermission(forCollection: "test", queryString: "true") - request.allow(success) - } else { - request.deny() - } - } -} - -func topLevelDittoDir() -> URL { - let fileManager = FileManager.default - return try! fileManager.url( - for: .documentDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: false - ).appendingPathComponent("ditto_top_level") -} diff --git a/DittoToolsApp/DittoToolsApp/Model/Config.swift b/DittoToolsApp/DittoToolsApp/Model/Config.swift deleted file mode 100644 index 6c29aaf..0000000 --- a/DittoToolsApp/DittoToolsApp/Model/Config.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// Config.swift -// debug -// -// Created by Rae McKelvey on 8/9/22. -// - -import DittoSwift -import Foundation - - -struct DittoConfig { - var appID = "" - var playgroundToken = "" - var identityType = IdentityType.onlinePlayground - var offlineLicenseToken = "" - var authenticationProvider = "" - var authenticationToken = "" - var useIsolatedDirectories = true -} - - diff --git a/DittoToolsApp/DittoToolsApp/Model/Credentials.swift b/DittoToolsApp/DittoToolsApp/Model/Credentials.swift new file mode 100644 index 0000000..614c980 --- /dev/null +++ b/DittoToolsApp/DittoToolsApp/Model/Credentials.swift @@ -0,0 +1,52 @@ +// +// Credentials.swift +// +// Copyright © 2024 DittoLive Incorporated. All rights reserved. +// + +import DittoSwift + + +/// A representation of the credentials required to initialize a Ditto instance. +/// +/// The `Credentials` structure encapsulates the `DittoIdentity` object +/// and any supplementary credentials needed for specific identity types. +/// It is used to configure and manage the identity settings for a Ditto instance. +struct Credentials { + + // - MARK: Ditto Identity + + /// The core identity used to configure the Ditto instance. + /// + /// This includes information such as the identity type (e.g., `offlinePlayground`, + /// `onlinePlayground`, `sharedKey`, etc.) and any associated parameters required + /// for initialization (e.g., App ID, site ID, or shared key). + let identity: DittoIdentity + + // - MARK: Supplementary Credentials + + /// The name of the callback method or hook used by the SDK for authentication purposes. + /// + /// This property specifies the name of the method or endpoint the SDK should invoke + /// to handle authentication. If a custom authentication URL is provided, the + /// `authProvider` acts as the callback or hook for custom authentication workflows. + /// It is optional and primarily used for identity types like `onlineWithAuthentication`. + var authProvider: String? + + /// The token used to authenticate with the authentication provider. + /// + /// This is often provided by the authentication system and required for + /// secure access to the Ditto service. + var authToken: String? + + /// The offline license token used for offline capabilities. + /// + /// Required for `offlinePlayground` and `sharedKey` identities to validate + /// offline use of the Ditto service. + var offlineLicenseToken: String? + + /// The shared key used for `sharedKey` identities. + /// + /// Used to secure data and establish identity for shared key configurations. + var sharedKey: String? +} diff --git a/DittoToolsApp/DittoToolsApp/Model/DittoIdentity+Extension.swift b/DittoToolsApp/DittoToolsApp/Model/DittoIdentity+Extension.swift new file mode 100644 index 0000000..9022b2a --- /dev/null +++ b/DittoToolsApp/DittoToolsApp/Model/DittoIdentity+Extension.swift @@ -0,0 +1,94 @@ +// +// DittoIdentity+Extension.swift +// +// Copyright © 2024 DittoLive Incorporated. All rights reserved. +// + +import DittoSwift + + +/// Extension to `DittoIdentity` for extracting associated values and managing identity types. +extension DittoIdentity { + + /// Retrieves the `appID` associated with the `DittoIdentity` instance. + /// + /// This computed property returns the `appID` value for identity types that include it + /// (e.g., `offlinePlayground`, `onlineWithAuthentication`, etc.). If the identity type + /// does not have an `appID` (e.g., `manual`), it returns `nil`. + /// + /// - Returns: The `appID` if available, or `nil` for identity types that do not have one. + var appID: String? { + switch self { + case .offlinePlayground(let appID, _): + return appID + case .onlineWithAuthentication(let appID, _, _, _): + return appID + case .onlinePlayground(let appID, _, _, _): + return appID + case .sharedKey(let appID, _, _): + return appID + case .manual: + return nil + @unknown default: + fatalError("Encountered an unknown DittoIdentity case.") + } + } +} + + + +/// Extension to `DittoIdentity` for defining identity types without associated values. +/// +/// The `DittoIdentity` enum does not directly conform to the `CaseIterable` protocol +/// because it has associated values, and `CaseIterable` only works with enums +/// that have no associated values. To enable iteration over identity types, +/// this extension introduces a new enum, `IdentityType`, which represents the +/// distinct identity types without any associated values. +extension DittoIdentity { + + /// Enum representing the different identity types of `DittoIdentity`. + /// + /// This enum simplifies working with identity types by removing the associated values + /// present in `DittoIdentity`. It conforms to `CaseIterable`, enabling iteration over all + /// identity types (e.g., for use in a `Picker`). + enum IdentityType: String, CaseIterable { + case offlinePlayground = "Offline Playground" + case onlineWithAuthentication = "Online with Authentication" + case onlinePlayground = "Online Playground" + case sharedKey = "Shared Key" + case manual = "Manual" + } + + /// Computed property to derive the `IdentityType` from a `DittoIdentity` instance. + /// + /// This property maps the current `DittoIdentity` case to its corresponding `IdentityType`. + /// This allows you to work with the identity type in a simpler, associated-value-free format. + /// + /// - Returns: The corresponding `IdentityType` for the current `DittoIdentity` instance. + var identityType: IdentityType { + switch self { + case .offlinePlayground: + return .offlinePlayground + case .onlineWithAuthentication: + return .onlineWithAuthentication + case .onlinePlayground: + return .onlinePlayground + case .sharedKey: + return .sharedKey + case .manual: + return .manual + @unknown default: + fatalError("Encountered an unknown DittoIdentity case.") + } + } + + /// A static property providing all possible identity types. + /// + /// This property mirrors the `CaseIterable` functionality for the `IdentityType` enum, + /// allowing you to access all identity types in a single array. + /// + /// - Returns: An array containing all cases of the `IdentityType` enum. + static var identityTypes: [IdentityType] { + return IdentityType.allCases + } +} diff --git a/DittoToolsApp/DittoToolsApp/Model/IdentityType.swift b/DittoToolsApp/DittoToolsApp/Model/IdentityType.swift deleted file mode 100644 index 9f79d8c..0000000 --- a/DittoToolsApp/DittoToolsApp/Model/IdentityType.swift +++ /dev/null @@ -1,11 +0,0 @@ -// -// Copyright © 2022 DittoLive Incorporated. All rights reserved. -// - -import Foundation - -enum IdentityType: String { - case onlinePlayground - case offlinePlayground - case onlineWithAuthentication -} diff --git a/DittoToolsApp/DittoToolsApp/Model/Server.swift b/DittoToolsApp/DittoToolsApp/Model/Server.swift deleted file mode 100644 index 62af831..0000000 --- a/DittoToolsApp/DittoToolsApp/Model/Server.swift +++ /dev/null @@ -1,90 +0,0 @@ -// -// Copyright © 2021 DittoLive Incorporated. All rights reserved. -// - -import Foundation -import Network - -struct Server: Identifiable, Codable, Equatable, Hashable { - - // MARK: - Properties - - /// A UUID string which identifies this server. Only used locally in - /// the cars app to save and update settings. - let id: UUID - - /// A customizable user-friendly name intended to provide some - /// context (for example "HyDRA integration cluster") - let name: String - - /// Server host, as either an IPv4 address or hostname. - let host: String - - /// Port number of the server for TCP connections, if enabled. TCP - /// connections (effectively the same as our mDNS mesh protocol - /// but with a fixed-IP instead of a dynamically discovered host). - let tcpPort: UInt16? - - /// Port number of the server for Websocket connections, if enabled. - /// If 443 then `wss://` will be used, otherwise `ws://`. - let websocketPort: UInt16? - - // MARK: - Initializer - - /// Constructor. If `host` or `port` were invalid, returns nil, otherwise - /// returns a valid `Server` object. - /// - /// - Parameters: - /// - name: A customizable user-friendly name intended to provide some - /// context (for example "HyDRA integration cluster") - /// - host: Server host, as either an IP address or hostname. - /// - port: Port number of the server. Cannot be 0. - init?(id: UUID, name: String, host: String, tcpPort: UInt16?, websocketPort: UInt16?) { - let validatedHost: String - if IPv4Address(host) != nil { - validatedHost = host - } else if !host.isEmpty && host.unicodeScalars.allSatisfy({ CharacterSet.urlHostAllowed.contains($0) }) { - validatedHost = host - } else { - print("Invalid host for server - must be either an IPv4 address or hostname: \(host)") - return nil - } - - self.id = id - self.name = name.isEmpty ? "Server" : name - self.host = validatedHost - self.tcpPort = tcpPort - self.websocketPort = websocketPort - } - - // MARK: - Functions - - func urlString(formattedFor connectionType: ServerConnectionType) -> String { - let port = self.port(for: connectionType) - - let portString = port?.description.prepending(":") ?? "" - let schemeString = connectionType.scheme(forPort: port) - - return "\(schemeString)\(self.host)\(portString)" - } - - func port(for connectionType: ServerConnectionType) -> UInt16? { - switch connectionType { - case .tcp: return self.tcpPort - case .websocket: return self.websocketPort - } - } - -} - -extension Server: CustomStringConvertible { - var description: String { - self.host - } -} - -fileprivate extension String { - func prepending(_ other: String) -> String { - return other + self - } -} diff --git a/DittoToolsApp/DittoToolsApp/Model/ServerConnectionType.swift b/DittoToolsApp/DittoToolsApp/Model/ServerConnectionType.swift deleted file mode 100644 index f0d0a5b..0000000 --- a/DittoToolsApp/DittoToolsApp/Model/ServerConnectionType.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// Copyright © 2021 DittoLive Incorporated. All rights reserved. -// - -import Foundation - -enum ServerConnectionType: CaseIterable { - case tcp - case websocket - - func scheme(forPort port: UInt16?) -> String { - guard let port = port else { - return "" - } - - switch self { - case .websocket where port == 443: - return "wss://" - case .websocket: - return "ws://" - case .tcp: - return "" - } - } - - init?(from transport: Transport) { - switch transport { - case .tcpServer: - self = .tcp - case .websocketServer: - self = .websocket - default: - return nil - } - } - - func toTransport() -> Transport { - switch self { - case .tcp: - return .tcpServer - case .websocket: - return .websocketServer - } - } -} - -// MARK: CustomStringConvertible - -extension ServerConnectionType: CustomStringConvertible { - var description: String { - switch self { - case .tcp: return "Static TCP" - case .websocket: return "Websocket" - } - } -} diff --git a/DittoToolsApp/DittoToolsApp/Model/Transport.swift b/DittoToolsApp/DittoToolsApp/Model/Transport.swift deleted file mode 100644 index 9e11b39..0000000 --- a/DittoToolsApp/DittoToolsApp/Model/Transport.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// Copyright © 2021 DittoLive Incorporated. All rights reserved. -// - -import Foundation - -enum Transport: String, CaseIterable, Codable, Equatable, Hashable { - case bluetooth - case wifi - case awdl - case tcpServer - case websocketServer - - static var p2pTransports: [Self] { - [.bluetooth, .wifi, .awdl] - } - - static var serverTransports: [Self] { - [.tcpServer, .websocketServer] - } -} - -// MARK: CustomStringConvertible - -extension Transport: CustomStringConvertible { - var description: String { - switch self { - case .bluetooth: - return "Bluetooth" - case .wifi: - return "mDNS" - case .awdl: - return "AWDL" - case .tcpServer: - return "Static TCP" - case .websocketServer: - return "Websocket" - } - } -} diff --git a/DittoToolsApp/DittoToolsApp/Pages/ContentView.swift b/DittoToolsApp/DittoToolsApp/Pages/ContentView.swift deleted file mode 100644 index c18d20f..0000000 --- a/DittoToolsApp/DittoToolsApp/Pages/ContentView.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// ContentView.swift -// -// Copyright © 2024 DittoLive Incorporated. All rights reserved. -// - -import SwiftUI -import DittoAllToolsMenu -import DittoSwift - - -class MainListViewModel: ObservableObject { - @Published var isShowingLoginSheet = DittoManager.shared.ditto == nil -} - - -struct ContentView: View { - - @StateObject private var viewModel = MainListViewModel() - - @ObservedObject private var dittoModel = DittoManager.shared - - var body: some View { - NavigationView { - AllToolsMenu(ditto: dittoModel.ditto!) - .navigationTitle("Ditto Tools") - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button(action: { - viewModel.isShowingLoginSheet.toggle() - }) { - Image(systemName: "gear") - } - } - } - - // Default view when no tool is selected. - Text("Please select a tool.") - .font(.body) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .foregroundColor(.secondary) -#if !os(tvOS) - .background(Color(UIColor.systemBackground)) -#endif - } - .navigationViewStyle(DoubleColumnNavigationViewStyle()) - .sheet(isPresented: $viewModel.isShowingLoginSheet, content: { - Login() - .onSubmit { - viewModel.isShowingLoginSheet = false - } - }) - } -} - - -struct ContentView_Previews: PreviewProvider { - static var previews: some View { - ContentView() - } -} diff --git a/DittoToolsApp/DittoToolsApp/Pages/DataBrowser.swift b/DittoToolsApp/DittoToolsApp/Pages/DataBrowser.swift deleted file mode 100644 index feda2c7..0000000 --- a/DittoToolsApp/DittoToolsApp/Pages/DataBrowser.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// DataBrowser.swift -// debug -// -// Created by Rae McKelvey on 8/9/22. -// - -import Combine -import DittoDataBrowser -import DittoSwift -import SwiftUI - -struct DataBrowserView: View { - - var body: some View { - DataBrowser(ditto: DittoManager.shared.ditto!) - } -} - -struct DataBrowserView_Previews: PreviewProvider { - static var previews: some View { - DataBrowserView() - } -} - diff --git a/DittoToolsApp/DittoToolsApp/Pages/DiskUsageViewer.swift b/DittoToolsApp/DittoToolsApp/Pages/DiskUsageViewer.swift deleted file mode 100644 index cb77e14..0000000 --- a/DittoToolsApp/DittoToolsApp/Pages/DiskUsageViewer.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// DiskUsageViewer.swift -// DittoToolsApp -// -// Created by Ben Chatelain on 2023-01-30. -// - -import DittoDiskUsage -import SwiftUI - -struct DiskUsageViewer: View { - - var body: some View { - DittoDiskUsageView(ditto: DittoManager.shared.ditto!) - EmptyView() - } -} diff --git a/DittoToolsApp/DittoToolsApp/Pages/HeartBeatViewer.swift b/DittoToolsApp/DittoToolsApp/Pages/HeartBeatViewer.swift deleted file mode 100644 index 3bb4505..0000000 --- a/DittoToolsApp/DittoToolsApp/Pages/HeartBeatViewer.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// SwiftUIView.swift -// -// -// Created by Walker Erekson on 3/6/24. -// - -import SwiftUI -import DittoHeartbeat - -struct HeartBeatViewer: View { - var body: some View { - HeartbeatView(ditto: DittoManager.shared.ditto!) - } -} - diff --git a/DittoToolsApp/DittoToolsApp/Pages/LoggingDetailsViewer.swift b/DittoToolsApp/DittoToolsApp/Pages/LoggingDetailsViewer.swift deleted file mode 100644 index 816c49e..0000000 --- a/DittoToolsApp/DittoToolsApp/Pages/LoggingDetailsViewer.swift +++ /dev/null @@ -1,25 +0,0 @@ -/// -// LoggingDetailsViewer.swift -// DittoToolsApp -// -// Created by Eric Turner on 6/1/23. -// -// Copyright © 2023 DittoLive Incorporated. All rights reserved. - -import DittoExportLogs -import DittoSwift -import SwiftUI - -struct LoggingDetailsViewer: View { - @ObservedObject var dittoManager = DittoManager.shared - - var body: some View { - LoggingDetailsView(loggingOption: $dittoManager.loggingOption) - } -} - -struct LoggingDetailsViewer_Previews: PreviewProvider { - static var previews: some View { - LoggingDetailsViewer(dittoManager: DittoManager.shared) - } -} diff --git a/DittoToolsApp/DittoToolsApp/Pages/Login.swift b/DittoToolsApp/DittoToolsApp/Pages/Login.swift deleted file mode 100644 index d9fbb22..0000000 --- a/DittoToolsApp/DittoToolsApp/Pages/Login.swift +++ /dev/null @@ -1,99 +0,0 @@ - -import SwiftUI - -struct Login: View { - @Environment(\.dismiss) var dismiss - - class ViewModel: ObservableObject { - @ObservedObject var dittoModel = DittoManager.shared - @Published var isPresentingAlert = false - var error: String = "" - @Published var useIsolatedDirectories = true - @Published var config = DittoConfig() - - init () { - self.config = dittoModel.config - } - - var isDisabled: Bool { - return DittoManager.shared.config.appID.count < 3 - } - - func changeIdentity() { - dittoModel.config = config - do { - try dittoModel.restartDitto() - } catch let err { - print("Error when starting ditto \(err)") - self.isPresentingAlert = true - self.error = err.localizedDescription - } - } - } - - @StateObject var viewModel = ViewModel() - - var body: some View { - Form { - Section { - HStack { - Picker("Identity", selection: $viewModel.config.identityType) { - Text("Online Playground").tag(IdentityType.onlinePlayground) - Text("Offline Playground").tag(IdentityType.offlinePlayground) - Text("Online With Authentication").tag(IdentityType.onlineWithAuthentication) - } - } - HStack { - Text("App ID") - TextField("", text: $viewModel.config.appID) - } - switch (viewModel.config.identityType) { - case IdentityType.onlinePlayground: - HStack { - Text("Playground Token") - TextField("", text: $viewModel.config.playgroundToken) - } - case IdentityType.offlinePlayground: - HStack { - Text("Offline License Token") - TextField("", text: $viewModel.config.offlineLicenseToken).textInputAutocapitalization(.never) - } - case IdentityType.onlineWithAuthentication: - HStack { - Text("Provider") - TextField("", text: $viewModel.config.authenticationProvider).textInputAutocapitalization(.never) - } - HStack { - Text("Token") - TextField("", text: $viewModel.config.authenticationToken).textInputAutocapitalization(.never) - } - } - } - Section { - PrimaryFormButton(action: { - viewModel.changeIdentity() - dismiss() - }, text: "Restart Ditto", textColor: viewModel.isDisabled ? .secondary : .accentColor, isLoading: false, isDisabled: false) - } - .navigationTitle("") - /* - .sheet(isPresented: $viewModel.isPresentingImagePicker, content: { - ImagePicker(sourceType: viewModel.sourceType, isSquareMode: true) { image in - let file = try! File.insert(image: image) - viewModel.fileId = file._id - viewModel.image = image - } - }) */ - .alert("Ditto failed to start.", isPresented: $viewModel.isPresentingAlert, actions: { - Button("Dismiss", role: .cancel) { dismiss() } - }) - } - } -} - -struct DemoLoginPage_Previews: PreviewProvider { - static var previews: some View { - Login() - .preferredColorScheme(.dark) - } -} diff --git a/DittoToolsApp/DittoToolsApp/Services/AuthenticationDelegate.swift b/DittoToolsApp/DittoToolsApp/Services/AuthenticationDelegate.swift new file mode 100644 index 0000000..1f7469b --- /dev/null +++ b/DittoToolsApp/DittoToolsApp/Services/AuthenticationDelegate.swift @@ -0,0 +1,89 @@ +// +// AuthenticationDelegate.swift +// +// Copyright © 2024 DittoLive Incorporated. All rights reserved. +// + +import DittoSwift + +/// A delegate responsible for handling authentication events for the Ditto SDK. +/// +/// This class implements the `DittoAuthenticationDelegate` protocol and provides +/// functionality to authenticate users when required and refresh authentication when +/// it is about to expire. +public class AuthenticationDelegate: DittoAuthenticationDelegate { + + /// Called when authentication is required by the Ditto SDK. + /// + /// This method retrieves the active credentials and attempts to log in + /// using the stored authentication token and provider. If either is missing, the + /// process is aborted, and an error message is printed. + /// + /// - Parameter authenticator: The `DittoAuthenticator` instance responsible for handling the login process. + public func authenticationRequired(authenticator: DittoAuthenticator) { + // Retrieve the current credentials + guard let credentials = CredentialsService.shared.activeCredentials else { + return + } + + // Ensure both authToken and authProvider are available + guard let authToken = credentials.authToken, + let authProvider = credentials.authProvider + else { + print("Missing authToken or authProvider in the credentials.") + return + } + + // Log the attempt for debugging purposes + print("Attempting login with \(authToken), \(authProvider)") + + // Perform login using the provided credentials + authenticator.login(token: authToken, provider: authProvider) { json, error in + if let err = error { + // Log an error if authentication fails + print("Error authenticating: \(err.localizedDescription)") + } else { + // Log a success message with the response + print("Authentication succeeded with response: \(String(describing: json))") + } + } + } + + /// Called when the authentication is about to expire. + /// + /// This method retrieves the active credentials and attempts to refresh + /// authentication using the stored authentication token and provider. If either is + /// missing, the process is aborted, and an error message is printed. + /// + /// - Parameters: + /// - authenticator: The `DittoAuthenticator` instance responsible for handling the login process. + /// - secondsRemaining: The number of seconds remaining before the current authentication expires. + public func authenticationExpiringSoon(authenticator: DittoAuthenticator, secondsRemaining: Int64) { + // Retrieve the current credentials + guard let credentials = CredentialsService.shared.activeCredentials else { + return + } + + // Ensure both authToken and authProvider are available + guard let authToken = credentials.authToken, + let authProvider = credentials.authProvider + else { + print("Missing authToken or authProvider in the Credentials.") + return + } + + // Log the token expiry time for debugging purposes + print("Auth token expiring in \(secondsRemaining)") + + // Perform login using the provided credentials to refresh authentication + authenticator.login(token: authToken, provider: authProvider) { json, error in + if let err = error { + // Log an error if authentication fails + print("Error authenticating: \(err.localizedDescription)") + } else { + // Log a success message with the response + print("Authentication succeeded with response: \(String(describing: json))") + } + } + } +} diff --git a/DittoToolsApp/DittoToolsApp/Services/CredentialsService.swift b/DittoToolsApp/DittoToolsApp/Services/CredentialsService.swift new file mode 100644 index 0000000..2cf0699 --- /dev/null +++ b/DittoToolsApp/DittoToolsApp/Services/CredentialsService.swift @@ -0,0 +1,101 @@ +// +// CredentialsService.swift +// +// Copyright © 2024 DittoLive Incorporated. All rights reserved. +// + +import DittoSwift + +#warning("TODO: comments: the only public interface for this is the activeConfiguration, which can be retrieved (it'll attempt to fetch it from Keychain, if there is one) or set to nil, which will remove it from keychain entirely. Everything else is private.") + +public class CredentialsService { + + // Shared Singleton Instance + public static let shared = CredentialsService() + + private init() { } + + // Current active credentials + private var storedCredentials: Credentials? + + /// The active credentials used by the app. + /// + /// This property retrieves or sets the currently active credentials. + /// If no configuration is cached in memory (`storedCredentials`), it attempts + /// to load credentials from the Keychain using the `authenticationDelegate`. + /// + /// Setting this property: + /// - Saves the new configuration to the Keychain if a valid credentials object is provided. + /// - Removes the credentials from the Keychain if `nil` is assigned. + /// + /// Retrieving this property: + /// - Returns the cached credentials (`storedCredentials`) if available. + /// - Loads and caches the credentials object from the Keychain if one exists. + /// - Returns `nil` if no credentials are found. + /// + /// - Note: Clearing this property (setting it to `nil`) removes the associated + /// credentials from the Keychain. + /// + /// Example: + /// ```swift + /// if let credentials = CredentialsService.shared.activeCredentials { + /// print("Loaded credentials: \(credentials)") + /// } else { + /// print("No active credentials found.") + /// } + /// ``` + var activeCredentials: Credentials? { + get { + // Return the cached credentials if already set + if let credentials = storedCredentials { + return credentials + } + + // Attempt to load the credentials from the Keychain if not cached, using the stored authenticationDelegate + if let loadedCredentials = loadCredentialsFromKeychain(authDelegate: authenticationDelegate) { + storedCredentials = loadedCredentials // Cache it for future access + return loadedCredentials + } + + // Return nil if no credentials are found in Keychain + return nil + } + set { + // Cache the new credentials in memory + storedCredentials = newValue + + // Save the new credentials to the Keychain, or remove them if nil + if let newCredentials = newValue { + saveCredentialsToKeychain(newCredentials) + print("CredentialsService added credentials!") + } else { + removeCredentialsFromKeychain() + print("CredentialsService removed credentials!") + } + } + } + + public private(set) var authenticationDelegate = AuthenticationDelegate() + + // MARK: - Keychain Integration + + private func saveCredentialsToKeychain(_ credentials: Credentials) { + if KeychainService.saveCredentialsToKeychain(credentials) { + print("Saved credentials to Keychain!") + } + } + + private func removeCredentialsFromKeychain() { + if KeychainService.removeCredentialsFromKeychain() { + print("Credentials removed from Keychain.") + } + } + + private func loadCredentialsFromKeychain(authDelegate: AuthenticationDelegate?) -> Credentials? { + if let credentials = KeychainService.loadCredentialsFromKeychain(authDelegate: authDelegate) { + activeCredentials = credentials + return credentials + } + return nil + } +} diff --git a/DittoToolsApp/DittoToolsApp/Services/Ditto Service/DittoService+PersistenceDirectory.swift b/DittoToolsApp/DittoToolsApp/Services/Ditto Service/DittoService+PersistenceDirectory.swift new file mode 100644 index 0000000..5e75fda --- /dev/null +++ b/DittoToolsApp/DittoToolsApp/Services/Ditto Service/DittoService+PersistenceDirectory.swift @@ -0,0 +1,61 @@ +// +// DittoService+PersistenceDirectory.swift +// +// Copyright © 2024 DittoLive Incorporated. All rights reserved. +// + +import Foundation + + +extension DittoService { + + /// Generates the persistence directory URL for Ditto's data storage. + /// + /// This method calculates the appropriate directory path where Ditto will store its persistent data. + /// The directory structure can include an app-specific subdirectory and, optionally, an isolated + /// subdirectory for unique storage contexts. + /// + /// - Parameters: + /// - appID: An optional string representing the application identifier. If provided, it will be used + /// as a subdirectory within the main persistence directory. Defaults to an empty string. + /// - useIsolatedDirectories: A Boolean flag indicating whether to create an isolated subdirectory + /// for unique storage. Defaults to `false`. + /// - Returns: A `URL` pointing to the calculated persistence directory. + /// - Throws: `DittoServiceError.initializationFailed` if the directory cannot be located or created. + static func persistenceDirectoryURL(appID: String? = "", useIsolatedDirectories: Bool = false) throws -> URL { + do { + // Determine the base directory for persistent storage + #if os(tvOS) + // Use caches directory for tvOS due to limited persistent storage + let persistenceDirectory: FileManager.SearchPathDirectory = .cachesDirectory + #else + // Use document directory for other platforms for long-term persistence + let persistenceDirectory: FileManager.SearchPathDirectory = .documentDirectory + #endif + + // Get the root directory URL for the chosen persistence directory + var rootDirectoryURL = try FileManager.default.url( + for: persistenceDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ).appendingPathComponent("ditto") // Append the "ditto" subdirectory + + // Add an app-specific subdirectory if appID is provided and not empty + if let appID = appID, !appID.isEmpty { + rootDirectoryURL = rootDirectoryURL.appendingPathComponent(appID) + } + + // Append a unique UUID subdirectory if isolated directories are requested + if useIsolatedDirectories { + rootDirectoryURL = rootDirectoryURL.appendingPathComponent(UUID().uuidString) + } + + // Return the fully constructed URL + return rootDirectoryURL + } catch { + // Throw a specific error if directory creation or access fails + throw DittoServiceError.initializationFailed("Failed to get persistence directory: \(error.localizedDescription)") + } + } +} diff --git a/DittoToolsApp/DittoToolsApp/Services/Ditto Service/DittoService.swift b/DittoToolsApp/DittoToolsApp/Services/Ditto Service/DittoService.swift new file mode 100644 index 0000000..25c39e3 --- /dev/null +++ b/DittoToolsApp/DittoToolsApp/Services/Ditto Service/DittoService.swift @@ -0,0 +1,277 @@ +// +// DittoService.swift +// +// Copyright © 2024 DittoLive Incorporated. All rights reserved. +// + +import Combine +import DittoSwift + +/// A service that manages the lifecycle of a Ditto instance, including initialization, synchronization, and live queries. +/// +/// `DittoService` is designed as a singleton to provide a centralized interface for working with a Ditto instance +/// within an app. It allows for initializing Ditto with specific credentials, managing its synchronization +/// engine, and observing changes to collections via live queries. +/// +/// ## Features +/// - **Singleton Access**: Use `DittoService.shared` to access the single instance. +/// - **Sync Engine Management**: Start, stop, or restart the Ditto synchronization engine. +/// - **Collection Observations**: Automatically subscribe to and observe changes in the collections stored by Ditto. +/// - **Identity Management**: Initialize Ditto with secure Credentials to manage offline license tokens. +/// +/// ## Topics +/// ### Initialization +/// - `initializeDitto(with:useIsolatedDirectories:)` +/// - `destroyDittoInstance(clearConfig:)` +/// +/// ### Synchronization +/// - `startSyncEngine()` +/// - `stopSyncEngine()` +/// - `restartSyncEngine()` +/// +/// ### Collection Observations +/// - `setupLiveQueries()` +/// +/// ### Delegate Handling +/// - `dittoTransportConditionDidChange(ditto:condition:subsystem:)` +/// +/// ## Usage +/// ```swift +/// let dittoService = DittoService.shared +/// try? dittoService.initializeDitto(with: credentials) +/// dittoService.startSyncEngine() +/// ``` +/// +/// - Note: This class is tightly coupled with the Ditto SDK and requires appropriate identity and license configurations to function. +public class DittoService: ObservableObject { + + // MARK: - Properties + + /// Optional Ditto instance that can be initialized later + @Published public private(set) var ditto: Ditto? + + @Published var collections = [DittoCollection]() + + var collectionsSubscription: DittoSubscription? + var collectionsObserver: DittoLiveQuery? + + // MARK: - Singleton + + /// Shared instance of the `DittoService`. + public static let shared = DittoService() + + /// Initializes the `DittoService` singleton. + /// + /// The private initializer sets up logging, attempts to restore active Credentials + /// from storage, and initializes the Ditto instance if possible. + private init() { + + // Configure Ditto logging + DittoLogger.minimumLogLevel = DittoLogLevel.restoreFromStorage() + DittoLogger.enabled = true + + // Attempt to initialize Ditto using the active credentials + if let activeCredentials = CredentialsService.shared.activeCredentials { + do { + try initializeDitto(with: activeCredentials) + } catch { + assertionFailure("Failed to initialize Ditto: \(error.localizedDescription)") + } + } + } + + // MARK: - Ditto Instance Management + + /// Initializes the Ditto instance with the given Credentials. + /// + /// - Parameters: + /// - credentials: The credentials used to initialize Ditto. + /// - useIsolatedDirectories: A flag indicating whether to use isolated directories for persistence. + /// - Throws: `DittoServiceError` if initialization fails. + func initializeDitto(with credentials: Credentials, useIsolatedDirectories: Bool = true) throws { + + // Clear any existing instance before initializing a new one + destroyDittoInstance() + + do { + // Determine the persistence directory based on the app ID and directory isolation preference + let storageDirectoryURL = try DittoService.persistenceDirectoryURL( + appID: credentials.identity.appID, + useIsolatedDirectories: useIsolatedDirectories) + + // Attempt to initialize the Ditto instance with the provided credentials + ditto = Ditto( + identity: credentials.identity, + persistenceDirectory: storageDirectoryURL + ) + + // Unwrap to ensure the value is valid and available throughout the rest of the method + guard let ditto else { + throw DittoServiceError.noInstance + } + + print("Ditto instance initialized successfully.") + + // Save the credentials as the active credentials + CredentialsService.shared.activeCredentials = credentials + + // Conditionally set the offline license token if required by the identity type + try setOfflineLicenseTokenIfNeeded(for: credentials, on: ditto) + + // Start the sync engine and set up live queries + try startSyncEngine() + try setupLiveQueries() + + print("Ditto initialization process completed successfully.") + + } catch let error as DittoServiceError { + // log and rethrow known service errors + print("Ditto initialization failed: \(error.localizedDescription)") + throw error + } catch { + throw DittoServiceError.initializationFailed("Unexpected error: \(error.localizedDescription)") + } + + #warning("TODO: Add diagnostics and live query setup") + // DispatchQueue.main.async { + // // Configure diagnostics if needed + // // DiagnosticsManager.shared.isEnabled = AppSettings.shared.diagnosticLogsEnabled + // } + } + + /// Clears the current Ditto instance and optionally removes the active credentials. + /// + /// This method deallocates the existing `Ditto` instance by setting it to `nil` and optionally clears the + /// active credentials from the `CredentialsService`. Clearing the credentials will delete + /// credentials completely, requiring the user to re-enter them in future operations. + /// + /// - Parameter clearingCredentials: A Boolean value indicating whether the active credentials + /// should also be cleared. If `true`, the active credentials will be removed. Defaults to `false`. + func destroyDittoInstance(clearingCredentials: Bool = false) { + + // Stop observing changes to collections + collectionsObserver?.stop() + collectionsObserver = nil + + // Cancel any active subscriptions + collectionsSubscription?.cancel() + collectionsSubscription = nil + + // Stop the sync engine if it is active + stopSyncEngine() + + // Remove the delegate and deallocate the Ditto instance + ditto?.delegate = nil + ditto = nil + + // Optionally clear the active credentials + if clearingCredentials { + CredentialsService.shared.activeCredentials = nil + } + + print("Ditto instance destroyed successfully. Ditto = \(String(describing: ditto))") + } + + // MARK: - Private Helper Methods + + /// Sets the offline license token on the Ditto instance if required by the identity type. + private func setOfflineLicenseTokenIfNeeded(for credentials: Credentials, on ditto: Ditto) throws { + let identity = credentials.identity + guard identity.identityType == .offlinePlayground || identity.identityType == .sharedKey else { return } + + guard let offlineLicenseToken = credentials.offlineLicenseToken, !offlineLicenseToken.isEmpty else { + throw DittoServiceError.invalidCredentials("Offline license token is required but not provided.") + } + + do { + try ditto.setOfflineOnlyLicenseToken(offlineLicenseToken) + } catch { + throw DittoServiceError.initializationFailed("Could not set offline license token.") + } + } + + #warning("TODO: What does subscribing to all collections do, in the context of the MenuView?") + /// Sets up live queries to observe collections in the Ditto store. + /// + /// This method subscribes to changes in the collections and updates the `collections` property in real time. + private func setupLiveQueries() throws { + guard let ditto = ditto else { throw DittoServiceError.noInstance } + + // Subscribe to all collections in the Ditto store + self.collectionsSubscription = ditto.store.collections().subscribe() + + // Observe local changes to the collections and update the published property + self.collectionsObserver = ditto.store.collections().observeLocal(eventHandler: { event in + self.collections = ditto.store.collections().exec() + }) + + print("Ditto live queries started up successfully.") + } + + // MARK: - Sync Engine Control + + /// Starts the sync engine on the Ditto instance. + /// + /// - Throws: `DittoServiceError` if the sync engine fails to start. + func startSyncEngine() throws { + guard let ditto = ditto else { throw DittoServiceError.noInstance } + + ditto.delegate = self + + do { + try ditto.startSync() + print("Ditto sync engine started successfully.") + } catch { + throw DittoServiceError.syncFailed(error.localizedDescription) + } + } + + /// Stops the sync engine on the Ditto instance. + func stopSyncEngine() { + guard let ditto = ditto else { return } + + if !ditto.isSyncActive { + return + } + + ditto.stopSync() + print("Ditto sync engine stopped successfully.") + } + + /// Restarts the sync engine by stopping and starting it again. + /// + /// - Throws: `DittoServiceError` if restarting the sync engine fails. + func restartSyncEngine() throws { + stopSyncEngine() + try startSyncEngine() + } +} + +// MARK: - DittoDelegate + +extension DittoService: DittoDelegate { + + /// Handles updates to Ditto's transport condition. + /// + /// - Parameters: + /// - ditto: The Ditto instance reporting the condition change. + /// - condition: The new transport condition. + /// - subsystem: The subsystem reporting the condition change. + public func dittoTransportConditionDidChange( + ditto: Ditto, + condition: DittoTransportCondition, + subsystem: DittoConditionSource + ) { + print("Condition update from \(subsystem)") + + if condition == .BleDisabled { + print("BLE disabled") + } else if condition == .NoBleCentralPermission { + print("Permission missing for BLE") + } else if condition == .NoBlePeripheralPermission { + print("Permission missing for BLE") + } else if condition == .Ok { + print("Ok") + } + } +} diff --git a/DittoToolsApp/DittoToolsApp/Services/Ditto Service/DittoServiceError.swift b/DittoToolsApp/DittoToolsApp/Services/Ditto Service/DittoServiceError.swift new file mode 100644 index 0000000..dc8a7ee --- /dev/null +++ b/DittoToolsApp/DittoToolsApp/Services/Ditto Service/DittoServiceError.swift @@ -0,0 +1,61 @@ +// +// DittoServiceError.swift +// +// Copyright © 2024 DittoLive Incorporated. All rights reserved. +// + +import Foundation + +/// Errors that may occur while interacting with the `DittoService`. +/// +/// These errors provide detailed information about failures encountered during +/// the initialization or operation of a Ditto instance, such as missing identity +/// configurations, invalid inputs, or runtime issues. +enum DittoServiceError: Error { + + /// Indicates that no `Ditto` instance is available. + /// + /// This error occurs when an attempt is made to interact with the service + /// without initializing a `Ditto` instance. + case noInstance + + /// Indicates that invalid credentials were provided. + /// + /// - Parameter message: A custom message detailing the reason why the credentials are invalid. + case invalidCredentials(String) + + /// Indicates that the initialization of the `Ditto` instance failed. + /// + /// - Parameter reason: A detailed description of why the initialization failed. + case initializationFailed(String) + + /// Indicates that starting the sync engine failed. + /// + /// - Parameter reason: A detailed description of why the sync operation failed. + case syncFailed(String) +} + +/// Provides localized error descriptions for `DittoServiceError`. +extension DittoServiceError: LocalizedError { + + /// A human-readable description of the error. + public var errorDescription: String? { + switch self { + case .noInstance: + // Error message for missing Ditto instance + return NSLocalizedString("No Ditto instance is available.", comment: "No instance error") + + case .invalidCredentials(let message): + // Error message for invalid credentials with a specific reason + return NSLocalizedString(message, comment: "Invalid credentials error") + + case .initializationFailed(let reason): + // Error message for Ditto initialization failure with a specific reason + return NSLocalizedString("Ditto initialization failed: \(reason)", comment: "Initialization failure error") + + case .syncFailed(let reason): + // Error message for sync engine failure with a specific reason + return NSLocalizedString("Failed to start sync: \(reason)", comment: "Sync failure error") + } + } +} diff --git a/DittoToolsApp/DittoToolsApp/Services/KeychainService.swift b/DittoToolsApp/DittoToolsApp/Services/KeychainService.swift new file mode 100644 index 0000000..071f6d5 --- /dev/null +++ b/DittoToolsApp/DittoToolsApp/Services/KeychainService.swift @@ -0,0 +1,162 @@ +// +// KeychainService.swift +// +// Copyright © 2024 DittoLive Incorporated. All rights reserved. +// + +import DittoSwift +import Security + +/// A service to save, load, and delete Credentials from the Keychain. +public class KeychainService { + + // Keys used to store data in the Keychain + static let DITTO_CREDENTIALS_KEY = "live.ditto.tools.dittoIdentity" + + // MARK: - Save Credentials to Keychain + + /// Saves the credentials to the Keychain. + /// - Parameter credentials: The Credentials to save. + /// - Returns: `true` if the save was successful, otherwise `false`. + static func saveCredentialsToKeychain(_ credentials: Credentials) -> Bool { + let credentialsData = serializeCredentials(credentials) + return saveToKeychain(data: credentialsData, key: DITTO_CREDENTIALS_KEY) + } + + // MARK: - Load Credentials from Keychain + + /// Loads the credentials from the Keychain. + /// - Parameter authDelegate: The authentication delegate for credentials reconstruction. + /// - Returns: The loaded credentials, or `nil` if loading fails. + static func loadCredentialsFromKeychain(authDelegate: AuthenticationDelegate?) -> Credentials? { + guard let credentialsData = loadFromKeychain(key: DITTO_CREDENTIALS_KEY) else { return nil } + return deserializeCredentials(from: credentialsData, authDelegate: authDelegate) + } + + // MARK: - Remove Credentials from Keychain + + /// Removes the credentials from the Keychain. + /// - Returns: `true` if the removal was successful, otherwise `false`. + static func removeCredentialsFromKeychain() -> Bool { + return deleteFromKeychain(key: DITTO_CREDENTIALS_KEY) + } +} + +extension KeychainService { + + // MARK: - Serialization and Deserialization + + /// Converts `Credentials` into a storable dictionary. + private static func serializeCredentials(_ credentials: Credentials) -> [String: Any] { + var data: [String: Any] = extractIdentityValues(from: credentials.identity) + data["authProvider"] = credentials.authProvider ?? "" + data["authToken"] = credentials.authToken ?? "" + data["offlineLicenseToken"] = credentials.offlineLicenseToken ?? "" + return data + } + + /// Reconstructs `Credentials` from a dictionary. + private static func deserializeCredentials(from data: [String: Any], authDelegate: AuthenticationDelegate?) -> Credentials? { + guard let identity = reconstructIdentity(from: data, authDelegate: authDelegate) else { return nil } + return Credentials( + identity: identity, + authProvider: data["authProvider"] as? String ?? "", + authToken: data["authToken"] as? String ?? "", + offlineLicenseToken: data["offlineLicenseToken"] as? String ?? "" + ) + } + + /// Extracts identity values into a dictionary. + private static func extractIdentityValues(from identity: DittoIdentity) -> [String: Any] { + switch identity { + case .offlinePlayground(let appID, let siteID): + return ["type": "offlinePlayground", "appID": appID ?? "", "siteID": siteID ?? 0] + case .onlineWithAuthentication(let appID, _, let enableCloudSync, let customAuthURL): + return [ + "type": "onlineWithAuthentication", "appID": appID, "enableCloudSync": enableCloudSync, + "customAuthURL": customAuthURL?.absoluteString ?? "", + ] + case .onlinePlayground(let appID, let token, let enableCloudSync, let customAuthURL): + return [ + "type": "onlinePlayground", "appID": appID, "token": token, "enableCloudSync": enableCloudSync, + "customAuthURL": customAuthURL?.absoluteString ?? "", + ] + case .sharedKey(let appID, let sharedKey, let siteID): + return ["type": "sharedKey", "appID": appID, "sharedKey": sharedKey, "siteID": siteID ?? 0] + case .manual(let certificateConfig): + return ["type": "manual", "certificateConfig": certificateConfig] + @unknown default: + fatalError("Encountered an unknown DittoIdentity case.") + } + } + + /// Reconstructs a `DittoIdentity` from a dictionary. + private static func reconstructIdentity(from data: [String: Any], authDelegate: AuthenticationDelegate?) -> DittoIdentity? { + guard let type = data["type"] as? String else { return nil } + switch type { + case "offlinePlayground": + let appID = data["appID"] as? String + let siteID = data["siteID"] as? UInt64 + return .offlinePlayground(appID: appID, siteID: siteID) + case "onlineWithAuthentication": + guard let authDelegate = authDelegate else { return nil } + let appID = data["appID"] as! String + let enableCloudSync = data["enableCloudSync"] as! Bool + let customAuthURL = URL(string: data["customAuthURL"] as! String) + return .onlineWithAuthentication( + appID: appID, authenticationDelegate: authDelegate, enableDittoCloudSync: enableCloudSync, customAuthURL: customAuthURL) + case "onlinePlayground": + let appID = data["appID"] as! String + let token = data["token"] as! String + let enableCloudSync = data["enableCloudSync"] as! Bool + let customAuthURL = URL(string: data["customAuthURL"] as! String) + return .onlinePlayground(appID: appID, token: token, enableDittoCloudSync: enableCloudSync, customAuthURL: customAuthURL) + case "sharedKey": + let appID = data["appID"] as! String + let sharedKey = data["sharedKey"] as! String + let siteID = data["siteID"] as? UInt64 + return .sharedKey(appID: appID, sharedKey: sharedKey, siteID: siteID) + case "manual": + let certificateConfig = data["certificateConfig"] as! String + return .manual(certificateConfig: certificateConfig) + default: + return nil + } + } + + // MARK: - Keychain Utilities + + private static func saveToKeychain(data: [String: Any], key: String) -> Bool { + let jsonData = try? JSONSerialization.data(withJSONObject: data) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecValueData as String: jsonData ?? Data(), + ] + SecItemDelete(query as CFDictionary) + return SecItemAdd(query as CFDictionary, nil) == errSecSuccess + } + + private static func loadFromKeychain(key: String) -> [String: Any]? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecReturnData as String: kCFBooleanTrue!, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var dataTypeRef: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &dataTypeRef) + if status == errSecSuccess, let data = dataTypeRef as? Data { + return try? JSONSerialization.jsonObject(with: data) as? [String: Any] + } + return nil + } + + private static func deleteFromKeychain(key: String) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + ] + return SecItemDelete(query as CFDictionary) == errSecSuccess + } +} diff --git a/DittoToolsApp/DittoToolsApp/Pages/CollectionView.swift b/DittoToolsApp/DittoToolsApp/Views/CollectionView.swift similarity index 100% rename from DittoToolsApp/DittoToolsApp/Pages/CollectionView.swift rename to DittoToolsApp/DittoToolsApp/Views/CollectionView.swift diff --git a/DittoToolsApp/DittoToolsApp/Views/ContentView.swift b/DittoToolsApp/DittoToolsApp/Views/ContentView.swift new file mode 100644 index 0000000..b36f1a8 --- /dev/null +++ b/DittoToolsApp/DittoToolsApp/Views/ContentView.swift @@ -0,0 +1,53 @@ +// +// ContentView.swift +// +// Copyright © 2024 DittoLive Incorporated. All rights reserved. +// + +import DittoAllToolsMenu +import DittoSwift +import SwiftUI + +struct ContentView: View { + + // If the license info is not found, present the Credentials view automatically + @State var isShowingCredentialsView = (CredentialsService.shared.activeCredentials == nil) + + var body: some View { + NavigationView { + MenuView() + .navigationTitle("Ditto Tools") + .navigationBarItems( + trailing: + CredentialsButton + ) + .sheet(isPresented: $isShowingCredentialsView) { + CredentialsView() + } + + // Default view when no tool is selected. + Text("Please select a tool.") + .font(.body) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .foregroundColor(.secondary) + } + .navigationViewStyle(DoubleColumnNavigationViewStyle()) + } + + @ViewBuilder + private var CredentialsButton: some View { + Button(action: { + isShowingCredentialsView.toggle() + }) { + Image("key.2.on.ring.fill") + #if os(tvOS) + .font(.subheadline) + #endif + } + } + +} + +#Preview { + ContentView() +} diff --git a/DittoToolsApp/DittoToolsApp/Views/Credentials View/Components/ClearableTextField.swift b/DittoToolsApp/DittoToolsApp/Views/Credentials View/Components/ClearableTextField.swift new file mode 100644 index 0000000..397087f --- /dev/null +++ b/DittoToolsApp/DittoToolsApp/Views/Credentials View/Components/ClearableTextField.swift @@ -0,0 +1,40 @@ +// +// ClearableTextField.swift +// +// Copyright © 2024 DittoLive Incorporated. All rights reserved. +// + +import SwiftUI + +/// A custom `TextField` with a clear button that mimics the iOS 15+ behavior but works on iOS 14. +/// +/// `ClearableTextField` is a reusable component that allows users to clear the text in a `TextField` by tapping an "x" button, +/// similar to the built-in `UITextField` behavior introduced in iOS 15. +/// The clear button appears when the field is focused, and text is entered. +/// It is designed to work on platforms other than tvOS. +struct ClearableTextField: View { + let placeholder: String + @Binding var text: String + + @State private var isTextFieldFocused: Bool = false + + var body: some View { + HStack { + TextField(placeholder, text: $text, onEditingChanged: { isEditing in + isTextFieldFocused = isEditing + }) + .font(.system(.body, design: .monospaced)) + .autocorrectionDisabled() + .autocapitalization(.none) + +#if !os(tvOS) + Image(systemName: "xmark.circle.fill") + .foregroundColor(Color(UIColor.tertiaryLabel)) // Semantic colors for light/dark mode + .opacity(!text.isEmpty && isTextFieldFocused ? 1 : 0) // Fade animation + .animation(.easeInOut(duration: 0.1), value: text) + .animation(.easeInOut(duration: 0.1), value: isTextFieldFocused) + .onTapGesture { text = "" } +#endif + } + } +} diff --git a/DittoToolsApp/DittoToolsApp/Views/Credentials View/Components/IdentityFormInputView.swift b/DittoToolsApp/DittoToolsApp/Views/Credentials View/Components/IdentityFormInputView.swift new file mode 100644 index 0000000..8a69653 --- /dev/null +++ b/DittoToolsApp/DittoToolsApp/Views/Credentials View/Components/IdentityFormInputView.swift @@ -0,0 +1,43 @@ +// +// IdentityFormInputView.swift +// +// Copyright © 2024 DittoLive Incorporated. All rights reserved. +// + +import SwiftUI + +#warning("TODO: do we need this? I think we can remove it from the implementation, then delete it") + +struct IdentityFormIntInputView: View { + let label: String + var placeholder: String = "" + @Binding var int: UInt64 + var isRequired: Bool = false + + var body: some View { + TextField(placeholder, value: $int, formatter: NumberFormatter()) + } +} + +enum StringValidation { + case uuid + case url + case base64 +} + +private extension String { + func isValidUUID() -> Bool { + return UUID(uuidString: self) != nil + } + + func isValidURL() -> Bool { + return URL(string: self) != nil + } + + func isValidBase64() -> Bool { + guard let _ = Data(base64Encoded: self) else { + return false + } + return true + } +} diff --git a/DittoToolsApp/DittoToolsApp/Views/Credentials View/Components/IdentityFormTextField.swift b/DittoToolsApp/DittoToolsApp/Views/Credentials View/Components/IdentityFormTextField.swift new file mode 100644 index 0000000..4d18f2d --- /dev/null +++ b/DittoToolsApp/DittoToolsApp/Views/Credentials View/Components/IdentityFormTextField.swift @@ -0,0 +1,95 @@ +// +// IdentityFormTextField.swift +// +// Copyright © 2024 DittoLive Incorporated. All rights reserved. +// + +import SwiftUI + +/// A customizable form text field component with platform-specific behavior. +/// +/// `IdentityFormTextField` is a SwiftUI component designed for user input within forms. +/// It displays a label, supports optional or required fields, and includes a placeholder for the text input. +/// - On non-tvOS platforms, the component includes a clearable text field and a "Paste" button for clipboard interaction. +/// - On tvOS, it simplifies the layout by removing clipboard and clearing features. +struct IdentityFormTextField: View { + + /// The label displayed above the text field. + let label: String + + /// The placeholder text shown inside the text field when empty. + let placeholder: String + + /// The text binding for the field's content. + @Binding var text: String + + /// A flag indicating whether the field is required. + /// - If `true`, no "(Optional)" label will be displayed. + /// - Defaults to `false`. + var isRequired: Bool = false + + var body: some View { + +#if os(tvOS) + // On tvOS, we display a basic VStack without the clearable text field or clipboard functionality. + VStack(alignment: .leading) { + // Display the label and optional indicator + HStack(spacing: 4) { + Text(label) + .font(.system(.subheadline)) + .fontWeight(.medium) + + if !isRequired { + Text("(Optional)") + .textCase(.uppercase) + .font(.caption) + .foregroundStyle(.secondary) + } + } + // Display the text field + TextField(placeholder, text: $text) + .font(.system(.body, design: .monospaced)) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + .padding(.vertical, 12) +#else + // On non-tvOS platforms, we display a more advanced layout with an optional clearable text field and paste functionality. + HStack(spacing: 4) { + VStack(alignment: .leading) { + // Display the label and optional indicator + HStack { + Text(label) + .font(.system(.subheadline)) + .fontWeight(.medium) + + if !isRequired { + Text("(Optional)") + .textCase(.uppercase) + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + // Paste button to populate the text field with clipboard content + Button(action: { + if let clipboardText = UIPasteboard.general.string { + text = clipboardText // Set the value of the TextField to the clipboard content + } + }) { + Image(systemName: "doc.on.clipboard.fill") + .resizable() // Make the image resizable + .frame(width: 16, height: 20) // Fix the icon size + } + .contentShape(Rectangle()) // Extend the tappable area visually + .buttonStyle(.borderless) // ensure only the button handles a tap + } + + // Clearable text field for user input + ClearableTextField(placeholder: placeholder, text: $text) + } + } +#endif + } +} diff --git a/DittoToolsApp/DittoToolsApp/Views/Credentials View/CredentialsView.swift b/DittoToolsApp/DittoToolsApp/Views/Credentials View/CredentialsView.swift new file mode 100644 index 0000000..449e895 --- /dev/null +++ b/DittoToolsApp/DittoToolsApp/Views/Credentials View/CredentialsView.swift @@ -0,0 +1,109 @@ +// +// CredentialsView.swift +// +// Copyright © 2024 DittoLive Incorporated. All rights reserved. +// + +import DittoSwift +import SwiftUI + +struct CredentialsView: View { + @Environment(\.presentationMode) var presentationMode + @ObservedObject var dittoService = DittoService.shared + + @StateObject private var viewModel = FormViewModel( + credentialsService: CredentialsService.shared, + dittoService: DittoService.shared + ) + + @State var isPresentingAlert = false + @State var validationError: String? + + var body: some View { + NavigationView { + MultiPlatformLayoutView + .navigationTitle("Credentials") + } + .onAppear { disableInteractiveDismissal() } + .alert(isPresented: $isPresentingAlert) { + Alert( + title: Text("Cannot Apply Credentials"), + message: Text(validationError ?? "An unknown error occurred."), + dismissButton: .default(Text("OK")) + ) + } + } + + /// The main content of the view, a two column layout for tvos with an image and a form, otherwise just the form + @ViewBuilder + private var MultiPlatformLayoutView: some View { + #if os(tvOS) + HStack { + imageView + formView + } + #else + formView + .navigationBarTitleDisplayMode(.inline) + #endif + } + + @ViewBuilder + private var imageView: some View { + Image("key.2.on.ring.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: .infinity) + .padding(180) + .foregroundColor(Color(UIColor.tertiaryLabel)) + } + + /// form for the user to input parameters to create a configuration and apply it + @ViewBuilder + private var formView: some View { + FormView(viewModel: viewModel) + .toolbar { + ToolbarButtons + } + } + + private var ToolbarButtons: some ToolbarContent { + Group { + ToolbarItemGroup(placement: .confirmationAction) { + Button("Apply") { + applyCredentials() + } + } + + #if !os(tvOS) + ToolbarItemGroup(placement: .cancellationAction) { + Button("Cancel") { + presentationMode.wrappedValue.dismiss() + } + .disabled(CredentialsService.shared.activeCredentials == nil) + } + #endif + } + } + + private func applyCredentials() { + do { + try viewModel.apply() + presentationMode.wrappedValue.dismiss() + } catch let error as DittoServiceError { + validationError = error.localizedDescription + isPresentingAlert = true + } catch { + validationError = "An unknown error occurred." + isPresentingAlert = true + } + } + + /// Disables interactive dismissal for modally presented views. + private func disableInteractiveDismissal() { + guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let rootVC = scene.windows.first?.rootViewController?.presentedViewController + else { return } + rootVC.isModalInPresentation = true + } +} diff --git a/DittoToolsApp/DittoToolsApp/Views/Credentials View/Form/FormInputData.swift b/DittoToolsApp/DittoToolsApp/Views/Credentials View/Form/FormInputData.swift new file mode 100644 index 0000000..93c0541 --- /dev/null +++ b/DittoToolsApp/DittoToolsApp/Views/Credentials View/Form/FormInputData.swift @@ -0,0 +1,138 @@ +// +// FormInputData.swift +// +// Copyright © 2024 DittoLive Incorporated. All rights reserved. +// + +import SwiftUI +import DittoSwift + + +/// Represents user input data required to configure and validate a Ditto identity. +/// +/// This structure collects all the necessary fields required to create and validate +/// a `DittoIdentity` configuration. The `validate()` method ensures the integrity +/// of the input data based on the selected identity type. +struct FormInputData { + + /// The selected type of identity for Ditto. + var identityType: DittoIdentity.IdentityType = .onlinePlayground + + /// The App ID associated with the identity. + /// - Must not be empty for most identity types. + var appID: String = "" + + /// The offline license token used for offline playground identities. + /// - Must be a valid UUID. + var offlineLicenseToken: String = "" + + /// The authentication token used for online playground identities. + /// - Must be a valid UUID. + var playgroundToken: String = "" + + /// Indicates whether Ditto Cloud Sync is enabled. + /// - Applies to specific identity types that support cloud synchronization. + var enableDittoCloudSync: Bool = true + + /// The authentication provider used for online identities. + /// - Optional; leave empty if not required by the identity type. + var authProvider: String = "" + + /// The authentication token used for online identities. + /// - Must be a valid UUID if provided. + var authToken: String = "" + + /// A custom authentication URL provided for specific identity types. + /// - Must be a valid URL if not empty. + var customAuthURLString: String = "" + + /// The site ID used for shared key and offline playground identities. + /// - Optional; defaults to 0 if not required. + var siteID: UInt64 = 0 + + /// The shared key used for shared key identities. + /// - Must be a valid UUID if provided. + var sharedKey: String = "" + + /// The certificate configuration used for manual identities. + /// - Required for manual identities; must not be empty. + var certificateConfig: String = "" + + /// Validates the input fields of this structure based on the selected identity type. + /// + /// The validation logic checks for required fields and format constraints + /// (e.g., non-empty strings, valid UUIDs, and properly formatted URLs). + /// - Returns: An array of error messages. If no errors are found, the array is empty. + func validate() -> [String] { + var errors: [String] = [] + + if identityType != .manual { + // App ID is required and must be a valid UUID for most identity types + if appID.isEmpty || UUID(uuidString: appID) == nil { + errors.append("App ID must be a valid UUID.") + } + } + + // Validate fields specific to the selected identity type + switch identityType { + case .offlinePlayground: + // Offline license token is required and must be a valid UUID + if offlineLicenseToken.isEmpty || UUID(uuidString: offlineLicenseToken) == nil { + errors.append("Offline license token must be a valid UUID.") + } + + case .onlineWithAuthentication: + // Validate custom authentication URL, if provided + if !customAuthURLString.isEmpty { + if let urlComponents = URLComponents(string: customAuthURLString), + urlComponents.scheme != nil, // Ensure a scheme like "https" + urlComponents.host != nil { // Ensure a host like "example.com" + // URL is valid, proceed + } else { + errors.append("The Custom Auth URL provided is not a valid format.") + } + } + // Validate authentication token, if provided + if !authToken.isEmpty { + if UUID(uuidString: authToken) == nil { + errors.append("Auth Token must be a valid UUID.") + } + } + + + case .onlinePlayground: + // Playground token is required and must be a valid UUID + if playgroundToken.isEmpty || UUID(uuidString: playgroundToken) == nil { + errors.append("Online Playground auth token must be a valid UUID.") + } + // Validate custom authentication URL, if provided + if !customAuthURLString.isEmpty { + if let urlComponents = URLComponents(string: customAuthURLString), + urlComponents.scheme != nil, // Ensure a scheme like "https" + urlComponents.host != nil { // Ensure a host like "example.com" + // URL is valid, proceed + } else { + errors.append("The Custom Auth URL provided is not a valid format.") + } + } + + case .sharedKey: + // Shared key is required and must be a valid UUID + if sharedKey.isEmpty || UUID(uuidString: sharedKey) == nil { + errors.append("Shared Key must be a valid UUID.") + } + // Offline license token is required and must be a valid UUID + if offlineLicenseToken.isEmpty || UUID(uuidString: offlineLicenseToken) == nil { + errors.append("Offline license token must be a valid UUID.") + } + + case .manual: + // Certificate configuration is required and must not be empty + if certificateConfig.isEmpty { + errors.append("A Certificate Config is required.") + } + } + + return errors + } +} diff --git a/DittoToolsApp/DittoToolsApp/Views/Credentials View/Form/FormView.swift b/DittoToolsApp/DittoToolsApp/Views/Credentials View/Form/FormView.swift new file mode 100644 index 0000000..233d724 --- /dev/null +++ b/DittoToolsApp/DittoToolsApp/Views/Credentials View/Form/FormView.swift @@ -0,0 +1,133 @@ +// +// FormView.swift +// +// Copyright © 2024 DittoLive Incorporated. All rights reserved. +// + +import DittoSwift +import SwiftUI + +/// A view that allows users to configure different identity types for Ditto. +/// +/// `FormView` displays a dynamic form where fields adjust based on the selected identity type. +/// The form gathers input data for creating and applying a `Credentials` configuration object. +struct FormView: View { + + /// The view model containing the identity form state and logic. + @ObservedObject var viewModel: FormViewModel + + /// Tracks whether the confirmation prompt for clearing credentials is shown. + @State private var isShowingConfirmClearCredentials = false + + var body: some View { + Form { + // Section for selecting the identity type. + Section(header: Text("Identity Type")) { + Picker("Type", selection: $viewModel.formInput.identityType) { + ForEach(DittoIdentity.identityTypes, id: \.self) { type in + Text(type.rawValue) + } + } + .pickerStyle(.menu) + } + + // Section for inputting identity-specific details based on the selected type. + Section( + header: Text("Identity Details"), + footer: Text("Applying these credentials will restart the Ditto sync engine.") + .font(.subheadline) + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + .padding() + ) { + // Predefined placeholders + let PLACEHOLDER_UUID = "123e4567-e89b-12d3-a456-426614174000" + let PLACEHOLDER_URL = "https://example.com" + + // Dynamically display fields based on the selected identity type. + switch viewModel.formInput.identityType { + case .offlinePlayground: + IdentityFormTextField(label: "App ID (UUID)", placeholder: PLACEHOLDER_UUID, text: $viewModel.formInput.appID) + IdentityFormIntInputView(label: "Site ID", placeholder: "Site ID (Number)", int: $viewModel.formInput.siteID) + IdentityFormTextField( + label: "Offline License Token (UUID)", placeholder: PLACEHOLDER_UUID, text: $viewModel.formInput.offlineLicenseToken + ) + + case .onlinePlayground: + IdentityFormTextField( + label: "App ID (UUID)", placeholder: PLACEHOLDER_UUID, text: $viewModel.formInput.appID, isRequired: true) + IdentityFormTextField( + label: "Playground Token (UUID)", placeholder: PLACEHOLDER_UUID, text: $viewModel.formInput.playgroundToken, + isRequired: true) + IdentityFormTextField( + label: "Custom Auth URL", placeholder: PLACEHOLDER_URL, text: $viewModel.formInput.customAuthURLString) + Toggle("Enable Cloud Sync", isOn: $viewModel.formInput.enableDittoCloudSync) + + case .onlineWithAuthentication: + IdentityFormTextField( + label: "App ID (UUID)", placeholder: PLACEHOLDER_UUID, text: $viewModel.formInput.appID, isRequired: true) + IdentityFormTextField( + label: "Custom Auth URL", placeholder: PLACEHOLDER_URL, text: $viewModel.formInput.customAuthURLString) + Toggle("Enable Cloud Sync", isOn: $viewModel.formInput.enableDittoCloudSync) + IdentityFormTextField( + label: "Auth Provider", placeholder: "Authentication Provider", text: $viewModel.formInput.authProvider) + IdentityFormTextField(label: "Auth Token (UUID)", placeholder: PLACEHOLDER_UUID, text: $viewModel.formInput.authToken) + + case .sharedKey: + IdentityFormTextField( + label: "App ID (UUID)", placeholder: PLACEHOLDER_UUID, text: $viewModel.formInput.appID, isRequired: true) + IdentityFormTextField( + label: "Shared Key (UUID)", placeholder: PLACEHOLDER_UUID, text: $viewModel.formInput.sharedKey, isRequired: true) + IdentityFormTextField( + label: "Offline License Token (UUID)", placeholder: PLACEHOLDER_UUID, + text: $viewModel.formInput.offlineLicenseToken, isRequired: true) + + case .manual: + IdentityFormTextField( + label: "Certificate Config", placeholder: "Base64-encoded Certificate", + text: $viewModel.formInput.certificateConfig, isRequired: true) + } + } + + #if os(tvOS) + // tvOS-specific buttons for clearing credentials. + Button("Clear Credentials…", role: .destructive) { + isShowingConfirmClearCredentials = true + } + #endif + } + // Alert for confirming clearing credential, as this is destructive. + .actionSheet(isPresented: $isShowingConfirmClearCredentials) { + clearCredentialsActionSheet + } + + #if !os(tvOS) + // Toolbar for non-tvOS platforms with a "Clear Credentials" button. + .toolbar { + ToolbarItem(placement: .bottomBar) { + Button("Clear Credentials…") { + isShowingConfirmClearCredentials = true + } + .foregroundColor(viewModel.canClearCredentials() ? Color(UIColor.systemRed) : nil) + .disabled(!viewModel.canClearCredentials()) + } + } + #endif + } + + /// Alert for clearing credentials. + private var clearCredentialsActionSheet: ActionSheet { + ActionSheet( + title: Text("Are you sure?"), + message: Text("This will permanently clear your saved credentials."), + buttons: [ + .cancel(), + .destructive( + Text("Clear"), + action: viewModel.clearCredentials + ) + ] + ) + } + +} diff --git a/DittoToolsApp/DittoToolsApp/Views/Credentials View/Form/FormViewModel.swift b/DittoToolsApp/DittoToolsApp/Views/Credentials View/Form/FormViewModel.swift new file mode 100644 index 0000000..6e6c9a8 --- /dev/null +++ b/DittoToolsApp/DittoToolsApp/Views/Credentials View/Form/FormViewModel.swift @@ -0,0 +1,176 @@ +// +// FormViewModel.swift +// +// Copyright © 2024 DittoLive Incorporated. All rights reserved. +// + +import DittoSwift +import SwiftUI + +class FormViewModel: ObservableObject { + // Form fields grouped into a struct + @Published var formInput: FormInputData + + // Validation errors for the form + @Published var validationErrors: [String] = [] + + private let credentialsService: CredentialsService + private let dittoService: DittoService + + // Initializer that automatically adopts active configuration if available + init(credentialsService: CredentialsService, dittoService: DittoService) { + self.credentialsService = credentialsService + self.dittoService = dittoService + + // Initialize formInput with default values + self.formInput = FormInputData() + + // Check for an active configuration in the service + if let credentials = credentialsService.activeCredentials { + populateFromCredentials(credentials) + } + } + + /// Populate the ViewModel fields from an Credentials + private func populateFromCredentials(_ credentials: Credentials) { + formInput.identityType = credentials.identity.identityType + + switch credentials.identity { + case .onlinePlayground(let appID, let token, let enableDittoCloudSync, let customAuthURL): + formInput.appID = appID + formInput.playgroundToken = token + formInput.enableDittoCloudSync = enableDittoCloudSync + formInput.customAuthURLString = customAuthURL?.absoluteString ?? "" + + case .onlineWithAuthentication(let appID, _, let enableDittoCloudSync, let customAuthURL): + formInput.appID = appID + formInput.enableDittoCloudSync = enableDittoCloudSync + formInput.customAuthURLString = customAuthURL?.absoluteString ?? "" + formInput.authProvider = credentials.authProvider ?? "" + formInput.authToken = credentials.authToken ?? "" + + case .offlinePlayground(let appID, let siteID): + formInput.appID = appID ?? "" + formInput.siteID = siteID ?? .zero + formInput.offlineLicenseToken = credentials.offlineLicenseToken ?? "" + + case .sharedKey(let appID, let sharedKey, let siteID): + formInput.appID = appID + formInput.sharedKey = sharedKey + formInput.siteID = siteID ?? .zero + formInput.offlineLicenseToken = credentials.offlineLicenseToken ?? "" + + case .manual(let certificateConfig): + formInput.certificateConfig = certificateConfig + + @unknown default: + fatalError("Encountered an unknown DittoIdentity case.") + } + } + + /// Converts the current form data into an `Credentials` object. + /// + /// This utility method generates an `Credentials` instance based on the values + /// entered in the form. It creates the appropriate `DittoIdentity` based on the selected identity type, + /// and adds any necessary supplementary credentials such as authentication tokens or offline license tokens. + /// - Returns: A fully configured `Credentials` object. + private func createCredentials() throws -> Credentials { + + // Validate the form values before trying to create a Credentials object + let validationErrors = formInput.validate() + if let firstError = validationErrors.first { + throw DittoServiceError.invalidCredentials(firstError) + } + + let identity: DittoIdentity + + // Create the appropriate DittoIdentity based on the form data + switch formInput.identityType { + case .offlinePlayground: + identity = .offlinePlayground( + appID: formInput.appID, + siteID: formInput.siteID + ) + + case .onlineWithAuthentication: + identity = .onlineWithAuthentication( + appID: formInput.appID, + authenticationDelegate: credentialsService.authenticationDelegate, + enableDittoCloudSync: formInput.enableDittoCloudSync, + customAuthURL: URL(string: formInput.customAuthURLString) ?? nil + ) + + case .onlinePlayground: + identity = .onlinePlayground( + appID: formInput.appID, + token: formInput.playgroundToken, + enableDittoCloudSync: formInput.enableDittoCloudSync, + customAuthURL: URL(string: formInput.customAuthURLString) + ) + + case .sharedKey: + identity = .sharedKey( + appID: formInput.appID, + sharedKey: formInput.sharedKey, + siteID: formInput.siteID + ) + + case .manual: + identity = .manual(certificateConfig: formInput.certificateConfig) + + @unknown default: + throw DittoServiceError.invalidCredentials("Unsupported or unknown Ditto Identity type encountered.") + } + + // Create Credentials + let credentials = Credentials(identity: identity, + authProvider: formInput.authProvider, + authToken: formInput.authToken, + offlineLicenseToken: formInput.offlineLicenseToken) + + // Return the fully validated Credentials object + return credentials + } + + func canClearCredentials() -> Bool { + credentialsService.activeCredentials != nil + } + + /// Handles the "Apply" action by validating and persisting the form data + func apply() throws { + do { + // Convert to Credentials + let credentials = try createCredentials() + + // Initialize Ditto + try dittoService.initializeDitto(with: credentials) + + // Clear validation errors on success + validationErrors = [] + + } catch let error as DittoServiceError { + // Handle DittoServiceError cases + switch error { + case .invalidCredentials(let message): + validationErrors = ["Invalid credentials: \(message)"] + case .initializationFailed(let reason): + validationErrors = ["Ditto initialization failed: \(reason)"] + case .syncFailed(let reason): + validationErrors = ["Failed to start the sync engine: \(reason)"] + default: + break + } + throw error + } catch { + // Handle unexpected errors + validationErrors = ["An unexpected error occurred: \(error.localizedDescription)"] + throw error + } + } + + func clearCredentials() { + dittoService.destroyDittoInstance(clearingCredentials: true) + self.formInput = FormInputData() + print("CredentialsView: Credentials cleared.") + } +} diff --git a/DittoToolsApp/DittoToolsApp/Views/Credentials View/Form/IdentityFormViewModel.swift b/DittoToolsApp/DittoToolsApp/Views/Credentials View/Form/IdentityFormViewModel.swift new file mode 100644 index 0000000..6c371b0 --- /dev/null +++ b/DittoToolsApp/DittoToolsApp/Views/Credentials View/Form/IdentityFormViewModel.swift @@ -0,0 +1,180 @@ +// +// FormViewModel.swift +// +// Copyright © 2024 DittoLive Incorporated. All rights reserved. +// + +import DittoSwift +import SwiftUI + +class FormViewModel: ObservableObject { + // Form fields grouped into a struct + @Published var formInput: IdentityFormInput + + // Validation errors for the form + @Published var validationErrors: [String] = [] + + private let credentialsService: CredentialsService + private let dittoService: DittoService + + // Initializer that automatically adopts active configuration if available + init(credentialsService: CredentialsService, dittoService: DittoService) { + self.credentialsService = credentialsService + self.dittoService = dittoService + + // Initialize formInput with default values + self.formInput = IdentityFormInput() + + // Check for an active configuration in the service + if let credentials = credentialsService.activeCredentials { + populateFromCredentials(credentials) + } + } + + /// Populate the ViewModel fields from an Credentials + private func populateFromCredentials(_ credentials: Credentials) { + formInput.identityType = credentials.identity.identityType + + switch credentials.identity { + case .onlinePlayground(let appID, let token, let enableDittoCloudSync, let customAuthURL): + formInput.appID = appID + formInput.playgroundToken = token + formInput.enableDittoCloudSync = enableDittoCloudSync + formInput.customAuthURLString = customAuthURL?.absoluteString ?? "" + + case .onlineWithAuthentication(let appID, _, let enableDittoCloudSync, let customAuthURL): + formInput.appID = appID + formInput.enableDittoCloudSync = enableDittoCloudSync + formInput.customAuthURLString = customAuthURL?.absoluteString ?? "" + formInput.authProvider = credentials.supplementaryCredentials.authProvider ?? "" + formInput.authToken = credentials.supplementaryCredentials.authToken ?? "" + + case .offlinePlayground(let appID, let siteID): + formInput.appID = appID ?? "" + formInput.siteID = siteID ?? .zero + formInput.offlineLicenseToken = credentials.supplementaryCredentials.offlineLicenseToken ?? "" + + case .sharedKey(let appID, let sharedKey, let siteID): + formInput.appID = appID + formInput.sharedKey = sharedKey + formInput.siteID = siteID ?? .zero + formInput.offlineLicenseToken = credentials.supplementaryCredentials.offlineLicenseToken ?? "" + + case .manual(let certificateConfig): + formInput.certificateConfig = certificateConfig + + @unknown default: + fatalError("Encountered an unknown DittoIdentity case.") + } + } + + /// Converts the current form data into an `Credentials` object. + /// + /// This utility method generates an `Credentials` instance based on the values + /// entered in the form. It creates the appropriate `DittoIdentity` based on the selected identity type, + /// and adds any necessary supplementary credentials such as authentication tokens or offline license tokens. + /// - Returns: A fully configured `Credentials` object. + private func createCredentials() throws -> Credentials { + + // Validate the form values before trying to create a Credentials object + let validationErrors = formInput.validate() + if let firstError = validationErrors.first { + throw DittoServiceError.invalidCredentials(firstError) + } + + let identity: DittoIdentity + + // Create the appropriate DittoIdentity based on the form data + switch formInput.identityType { + case .offlinePlayground: + identity = .offlinePlayground( + appID: formInput.appID, + siteID: formInput.siteID + ) + + case .onlineWithAuthentication: + identity = .onlineWithAuthentication( + appID: formInput.appID, + authenticationDelegate: credentialsService.authenticationDelegate, + enableDittoCloudSync: formInput.enableDittoCloudSync, + customAuthURL: URL(string: formInput.customAuthURLString) ?? nil + ) + + case .onlinePlayground: + identity = .onlinePlayground( + appID: formInput.appID, + token: formInput.playgroundToken, + enableDittoCloudSync: formInput.enableDittoCloudSync, + customAuthURL: URL(string: formInput.customAuthURLString) + ) + + case .sharedKey: + identity = .sharedKey( + appID: formInput.appID, + sharedKey: formInput.sharedKey, + siteID: formInput.siteID + ) + + case .manual: + identity = .manual(certificateConfig: formInput.certificateConfig) + + @unknown default: + throw DittoServiceError.invalidCredentials("Unsupported or unknown Ditto Identity type encountered.") + } + + // Create supplementary credentials (optional) + let supplementaryCredentials = SupplementaryCredentials( + authProvider: formInput.authProvider, + authToken: formInput.authToken, + offlineLicenseToken: formInput.offlineLicenseToken + ) + + // Create Credentials + let credentials = Credentials(identity: identity, supplementaryCredentials: supplementaryCredentials) + + // Return the fully validated Credentials object + return credentials + } + + func canClearCredentials() -> Bool { + credentialsService.activeCredentials != nil + } + + /// Handles the "Apply" action by validating and persisting the form data + func apply() throws { + do { + // Convert to Credentials + let credentials = try createCredentials() + + // Initialize Ditto + try dittoService.initializeDitto(with: credentials) + + // Clear validation errors on success + validationErrors = [] + + } catch let error as DittoServiceError { + // Handle DittoServiceError cases + switch error { + case .invalidCredentials(let message): + validationErrors = ["Invalid credentials: \(message)"] + case .initializationFailed(let reason): + validationErrors = ["Ditto initialization failed: \(reason)"] + case .syncFailed(let reason): + validationErrors = ["Failed to start the sync engine: \(reason)"] + default: + break + } + throw error + } catch { + // Handle unexpected errors + validationErrors = ["An unexpected error occurred: \(error.localizedDescription)"] + throw error + } + } + + func clearCredentials() { + dittoService.destroyDittoInstance(clearingCredentials: true) + self.formInput = IdentityFormInput() + print("CredentialsView: Credentials cleared.") + } +} diff --git a/DittoToolsApp/DittoToolsApp/Views/Menu View/MenuView.swift b/DittoToolsApp/DittoToolsApp/Views/Menu View/MenuView.swift new file mode 100644 index 0000000..57666de --- /dev/null +++ b/DittoToolsApp/DittoToolsApp/Views/Menu View/MenuView.swift @@ -0,0 +1,86 @@ +// +// MenuView.swift +// +// Copyright © 2024 DittoLive Incorporated. All rights reserved. +// + +import DittoAllToolsMenu +import DittoSwift +import SwiftUI + +struct MenuView: View { + + // Observe DittoService for changes in the Ditto instance + @ObservedObject var dittoService = DittoService.shared + + public var body: some View { + MultiPlatformLayoutView + } + + /// The main content of the view, a two column layout for tvos with an image and a menu, otherwise just the menu + @ViewBuilder + private var MultiPlatformLayoutView: some View { + #if os(tvOS) + HStack { + VStack { + LogoView + + SyncButton(dittoService: dittoService) + .buttonStyle(.plain) + + Text("SDK Version: \(dittoService.ditto?.sdkVersion ?? Ditto.version)") + .font(.subheadline) + .foregroundColor(.secondary) + } + .focusSection() + + AllToolsMenu(ditto: DittoService.shared.ditto) + .listStyle(.grouped) + } + + #else + AllToolsMenu(ditto: DittoService.shared.ditto) + .listStyle(.insetGrouped) + .toolbar { + ToolbarItemGroup(placement: .bottomBar) { + VStack(spacing: 0) { + SyncButton(dittoService: dittoService) + CopyButton + } + } + } + #endif + } + + @ViewBuilder + private var LogoView: some View { + Image("Ditto.LogoMark.Blue", bundle: .main) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: .infinity) + .foregroundColor(.dittoBlue) + .padding(180) + } + + #if !os(tvOS) + private var CopyButton: some View { + Button(action: { + // Copy SDK version to clipboard on tap + UIPasteboard.general.string = Ditto.version + }) { + HStack { + if let ditto = dittoService.ditto { + Text("SDK Version: \(ditto.sdkVersion)") + } else { + Text("SDK Version: \(Ditto.version)") + } + + Image(systemName: "doc.on.doc") + .font(.system(size: 10)) + } + .font(.caption) + .foregroundColor(.secondary) + } + } + #endif +} diff --git a/DittoToolsApp/DittoToolsApp/Views/Menu View/SyncButton.swift b/DittoToolsApp/DittoToolsApp/Views/Menu View/SyncButton.swift new file mode 100644 index 0000000..a293df5 --- /dev/null +++ b/DittoToolsApp/DittoToolsApp/Views/Menu View/SyncButton.swift @@ -0,0 +1,66 @@ +// +// SyncButton.swift +// +// Copyright © 2024 DittoLive Incorporated. All rights reserved. +// + +import DittoSwift +import SwiftUI + + +struct SyncButton: View { + var dittoService: DittoService? + + @State private var isAnimating = false + @State private var rotationAngle: Double = 0 + + var body: some View { + Button(action: { + if let dittoService, let ditto = dittoService.ditto { + if ditto.isSyncActive { + dittoService.stopSyncEngine() + isAnimating = false + rotationAngle = 0 + } else { + try? ditto.startSync() + isAnimating = true + } + } + }) { + if let ditto = dittoService?.ditto, ditto.activated { + HStack(spacing: 12) { + Text(ditto.isSyncActive ? "Ditto is active." : "Ditto is not running.") + .font(.subheadline) + + #if !os(tvOS) + // The way focus is handled on tvOS can interfere with animation updates, so omit on tvOS. + Image(systemName: "arrow.triangle.2.circlepath") + .font(.caption) + .rotationEffect(.degrees(rotationAngle)) + #endif + } + } else { + Text("No license found.") + } + } + .onAppear { + if let ditto = dittoService?.ditto { + isAnimating = ditto.isSyncActive + } + } + .onChange(of: isAnimating) { rotating in + if rotating { + startRotation() + } + } + .disabled(dittoService?.ditto == nil) + } + + private func startRotation() { + if isAnimating { + withAnimation(.linear(duration: 3.4).repeatForever(autoreverses: false)) { + rotationAngle = 360 + } + } + } +} diff --git a/DittoToolsApp/DittoToolsApp/Views/MenuListItem.swift b/DittoToolsApp/DittoToolsApp/Views/MenuListItem.swift deleted file mode 100644 index ba97742..0000000 --- a/DittoToolsApp/DittoToolsApp/Views/MenuListItem.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// MenuListItem.swift -// Pluto -// -// Created by Maximilian Alexander on 9/3/21. -// -import SwiftUI - -struct ColorfulIconLabelStyle: LabelStyle { - var color: Color - var size: CGFloat - var foregroundColor: Color = .white - - func makeBody(configuration: Configuration) -> some View { - Label { - configuration.title - } icon: { - configuration.icon - .imageScale(.small) - .foregroundColor(foregroundColor) - .background(RoundedRectangle(cornerRadius: 7 * size).frame(width: 28 * size, height: 28 * size).foregroundColor(color)) - } - } -} - -struct MenuListItem: View { - - @ScaledMetric var size: CGFloat = 1 - - var title: String - var systemImage: String - var color: Color = .accentColor - var foregroundColor: Color = .white - - var body: some View { - Label(title, systemImage: systemImage) - .labelStyle(ColorfulIconLabelStyle(color: color, size: size, foregroundColor: foregroundColor)) - } -} - -struct MenuListItem_Previews: PreviewProvider { - static var previews: some View { - NavigationView{ - List { - Section("Debug") { - MenuListItem(title: "Data Browser", systemImage: "photo", color: .orange) - MenuListItem(title: "Peers List", systemImage: "network", color: .blue) - MenuListItem(title: "Presence Viewer", systemImage: "network", color: .pink) - MenuListItem(title: "Disk Usage", systemImage: "opticaldiscdrive", color: .secondary) - } - Section("Change Identity") { - MenuListItem(title: "Change Identity", systemImage: "envelope", color: .purple) - } - Section("Exports") { - MenuListItem(title: "Export Logs", systemImage: "square.and.arrow.up", color: .green) - } - } - .listStyle(GroupedListStyle()) - .navigationTitle("DittoTools") - } - .preferredColorScheme(.dark) - } -} diff --git a/DittoToolsApp/DittoToolsApp/Views/PrimaryFormButton.swift b/DittoToolsApp/DittoToolsApp/Views/PrimaryFormButton.swift deleted file mode 100644 index f300863..0000000 --- a/DittoToolsApp/DittoToolsApp/Views/PrimaryFormButton.swift +++ /dev/null @@ -1,42 +0,0 @@ - -import SwiftUI - -struct PrimaryFormButton: View { - - var action: (() -> Void)? - var text: String - var textColor: Color = .accentColor - var isLoading: Bool = false - var isDisabled: Bool = false - - var body: some View { - HStack { - Spacer() - Button(action: { - if !isDisabled { - action?() - } - }, label: { - HStack(spacing: 12) { - if isLoading { - ProgressView() - } - Text(text) - .foregroundColor(textColor) - .fontWeight(.bold) - } - }) - Spacer() - } - } -} - -struct PrimaryFormButton_Previews: PreviewProvider { - static var previews: some View { - Form { - Section { - PrimaryFormButton(text: "Save") - } - } - } -} diff --git a/DittoToolsApp/DittoToolsApp/Views/UIScrollView+Extension.swift b/DittoToolsApp/DittoToolsApp/Views/UIScrollView+Extension.swift new file mode 100644 index 0000000..04ab32f --- /dev/null +++ b/DittoToolsApp/DittoToolsApp/Views/UIScrollView+Extension.swift @@ -0,0 +1,16 @@ +// +// UIScrollView+Extension.swift +// +// Copyright © 2024 DittoLive Incorporated. All rights reserved. +// + +#if os(tvOS) +import UIKit + +extension UIScrollView { + open override var clipsToBounds: Bool { + get { false } + set { /* Intentionally left blank */ } + } +} +#endif diff --git a/Package.swift b/Package.swift index 54de2b4..eaba5bc 100644 --- a/Package.swift +++ b/Package.swift @@ -140,6 +140,5 @@ let package = Package( "DittoPermissionsHealth" ] ) - ] ) diff --git a/README.md b/README.md index 12f2f24..dab9851 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ - # DittoSwiftTools +# DittoSwiftTools Ditto Logo

@@ -195,92 +195,74 @@ let vc = UIHostingController(rootView: DataBrowser(ditto: DittoManager.shared.di present(vc, animated: true) ``` -### 5. Logging and Export Logs +### 5. Logging and Export Logs #### Logging Level -Allows you to choose Ditto logging level at runtime. + +The LoggingDetailsView allows you to choose the Ditto logging level at runtime and toggle whether logging is enabled. Changes made through the LoggingDetailsView are automatically persisted using UserDefaults, ensuring the selected log level and enabled status are restored when the app restarts. Logging Level Image -**SwiftUI + Combine** - -In your class conforming to the `Observable Object` protocol, e.g., DittoManager, create a published -variable to store the selected logging option. The `LoggingOptions` enum is an extension on `DittoLogger`, -defined in the DittoExportLogs module. -``` -import Combine -import DittoExportLogs -import DittoSwift -import Foundation - -class DittoManager: ObservableObject { - @Published var loggingOption: DittoLogger.LoggingOptions - private var cancellables = Set() - - init() { - self.loggingOption = DittoLogger.LoggingOptions.error // initial level value - - // subscribe to loggingOption changes - // make sure log level is set _before_ starting ditto - $loggingOption - .sink { [weak self] logOption in - switch logOption { - case .disabled: - DittoLogger.enabled = false - default: - DittoLogger.enabled = true - DittoLogger.minimumLogLevel = DittoLogLevel(rawValue: logOption.rawValue)! - } - } - .store(in: &cancellables) - - ... + +#### SwiftUI + +To integrate the LoggingDetailsView into your app, simply pass your Ditto instance to the view. The picker will display the available log levels, and the toggle will allow enabling or disabling logging. + ``` -Create a SwiftUI view struct as a wrapper view to use as a subview or in a list, initializing with -your `Observable Object` class instance. In the body, include the `LoggingDetailsView`, initializing -with the published property. The `LoggingDetailsView` binds the published property to the logging -level options picker, and selection changes are reflected back to your subscriber. -``` import DittoExportLogs import DittoSwift import SwiftUI struct LoggingDetailsViewer: View { - @ObservedObject var dittoManager = DittoManager.shared - var body: some View { - LoggingDetailsView(loggingOption: $dittoManager.loggingOption) + LoggingDetailsView(ditto: ) } -} -``` +} +``` + +You can embed the LoggingDetailsView into your app’s navigation hierarchy or display it as a modal view. For example: + +``` +NavigationView { + VStack { + LoggingDetailsView() + } + .navigationTitle("Logging Settings") +} +``` + +Or present it as a sheet: + +``` +.sheet(isPresented: $isPresented) { + LoggingDetailsView() +} +``` -#### Export Logs -Allows you to export a file of the logs from your applcation as a zip file. +#### Export Logs + +The ExportLogs tool allows you to export a file of the logs from your application as a zip file. Export Logs Image -First, make sure the "DittoExportLogs" is added to your Target. Then, use `import DittoExportLogs` -to import the Export Logs. +To integrate ExportLogs, add it to your SwiftUI or UIKit app. It is recommended to call ExportLogs from within a [sheet](https://developer.apple.com/documentation/swiftui/view/sheet(ispresented:ondismiss:content:)). -**SwiftUI** - -Use `ExportLogs()` to export the logs. It is recommended to call `ExportLogs` from within a [sheet](https://developer.apple.com/documentation/swiftui/view/sheet(ispresented:ondismiss:content:)). +#### SwiftUI ``` .sheet(isPresented: $isPresented) { ExportLogs() } -``` +``` -**UIKit** +#### UIKit -Pass `ExportLogs()` to a [UIHostingController](https://sarunw.com/posts/swiftui-in-uikit/) -which will return a view controller you can use to present. +Pass `ExportLogs()` to a [UIHostingController](https://sarunw.com/posts/swiftui-in-uikit/) to present it as a view controller: ``` let vc = UIHostingController(rootView: ExportLogs()) present(vc, animated: true) -``` +``` ### 6. Export Data Directory diff --git a/Sources/DittoAllToolsMenu/AllToolsMenu.swift b/Sources/DittoAllToolsMenu/AllToolsMenu.swift deleted file mode 100644 index 4d8b235..0000000 --- a/Sources/DittoAllToolsMenu/AllToolsMenu.swift +++ /dev/null @@ -1,185 +0,0 @@ -// -// AllToolsMenu.swift -// -// Copyright © 2024 DittoLive Incorporated. All rights reserved. -// - -import SwiftUI -import DittoExportData -import DittoSwift - - -public struct AllToolsMenu: View { - - /// Initialize the view with a Ditto instance. - public init(ditto: Ditto) { - DittoManager.shared.ditto = ditto - } - - public var body: some View { -#if os(tvOS) - VStack { - ToolsList() - .listStyle(.grouped) - - Text("SDK Version: \(DittoManager.shared.ditto?.sdkVersion ?? "N/A")") - .font(.subheadline) - .foregroundColor(.secondary) - } -#else - ToolsList() - .listStyle(.insetGrouped) - .toolbar { - ToolbarItemGroup(placement: .bottomBar) { - Button(action: { - // Copy SDK version to clipboard on tap - let sdkVersion = DittoManager.shared.ditto?.sdkVersion ?? "N/A" - UIPasteboard.general.string = sdkVersion - }) { - HStack { - Text("SDK Version: \(DittoManager.shared.ditto?.sdkVersion ?? "N/A")") - Image(systemName: "doc.on.doc") - .font(.system(size: 10)) - } - .font(.subheadline) - .foregroundColor(.secondary) - } - } - } -#endif - } -} - - -/// A view that displays a list of diagnostic tools and data management options. -/// -/// `ToolsList` organizes various tools into sections, each with its own set of options. -/// The view can be conditionally configured to include or exclude specific items based on -/// the platform (e.g., excluding certain features on tvOS). -/// -/// - Note: On platforms other than tvOS, an additional section is included for exporting data, -/// which presents an alert to confirm the action. -fileprivate struct ToolsList: View { - - public var body: some View { - List { - Section(header: Text("Diagnostics")) { - -#if canImport(WebKit) - NavigationLink(destination: PresenceViewer()) { - ToolListItem(title: "Presence Viewer", systemImage: "network", color: .pink) - } -#endif - NavigationLink(destination: PeersListViewer()) { - ToolListItem(title: "Peers List", systemImage: "network", color: .blue) - } - NavigationLink(destination: DiskUsageViewer()) { - ToolListItem(title: "Disk Usage", systemImage: "opticaldiscdrive", color: .secondary) - } - NavigationLink(destination: DataBrowserView()) { - ToolListItem(title: "Data Browser", systemImage: "photo", color: .orange) - } - NavigationLink(destination: PresenceDegradationViewer()) { - ToolListItem(title: "Presence Degradation", systemImage: "network", color: .red) - } - NavigationLink(destination: HeartBeatViewer()) { - ToolListItem(title: "Heartbeat", systemImage: "heart.fill", color: .red) - } - NavigationLink(destination: PermissionsHealthViewer()) { - ToolListItem(title: "Permissions Health", systemImage: "stethoscope", color: .purple) - } - } - Section(header: Text("Data Exporting")) { - NavigationLink(destination: LoggingDetailsViewer()) { - ToolListItem(title: "Logging", systemImage: "square.split.1x2", color: .green) - } - } - -#if !os(tvOS) - // Do not show on tvOS as export is not currently supported. - Section(footer: Text("Export all Ditto data on this device as a .zip file.")) { - ExportButton() - } -#endif - } - } -} - - -/// A view that represents a single tool item in the tools list. -/// -/// `ToolListItem` displays a tool's icon and title, with customizable colors for both the icon and the text. -/// This view is typically used within a list to represent different tools or diagnostics options available in the app. -fileprivate struct ToolListItem: View { - - var title: String - var systemImage: String - var color: Color = .accentColor - var foregroundColor: Color = .white - - var body: some View { - HStack(spacing: 16) { - SettingsIcon(color: color, imageName: systemImage) - .frame(width: 29, height: 29) - Text(title) - } - } -} - - -/// A view that displays an icon inside a rounded rectangle with a customizable background color. -/// -/// `SettingsIcon` is used to render the icon associated with a tool in the `ToolListItem`. -/// The icon is centered within a rounded rectangle, and its size adjusts relative to the containing view. -fileprivate struct SettingsIcon: View { - let color: Color - let imageName: String - - var body: some View { - GeometryReader { geometry in - ZStack { - RoundedRectangle(cornerRadius: 6) - .foregroundColor(color) - - Image(systemName: imageName) - .resizable() - .aspectRatio(contentMode: .fit) - .imageScale(.small) - .foregroundColor(.white) - .frame(width: geometry.size.height * 0.7, height: geometry.size.height * 0.7) - } - } - } -} - - -#if !os(tvOS) -fileprivate struct ExportButton: View { - - // State variables to manage the presentation of alerts and sheets for exporting data - @State private var presentExportDataAlert = false - @State private var isExportDataSharePresented = false - - var body: some View { - Button(action: { - presentExportDataAlert.toggle() - }) { - Text("Export Data…") - .foregroundColor(.accentColor) - } - .alert(isPresented: $presentExportDataAlert) { - Alert(title: Text("Are you sure?"), - message: Text("Compressing the Ditto directory data may take a while."), - primaryButton: .cancel(Text("Cancel")), - secondaryButton: .default(Text("Export…")) { - isExportDataSharePresented = true - print("ok!") - }) - } - .sheet(isPresented: $isExportDataSharePresented) { - ExportData(ditto: DittoManager.shared.ditto!) - } - - } -} -#endif diff --git a/Sources/DittoAllToolsMenu/DittoManager.swift b/Sources/DittoAllToolsMenu/DittoManager.swift deleted file mode 100644 index ae421de..0000000 --- a/Sources/DittoAllToolsMenu/DittoManager.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// File.swift -// -// -// Created by Walker Erekson on 7/16/24. -// - -import Combine -import DittoExportLogs -import DittoSwift -import Foundation - -/// A singleton which manages our `Ditto` object. -class DittoManager: ObservableObject { - - // MARK: - Properties - - var ditto: Ditto? - - @Published var loggingOption: DittoLogger.LoggingOptions - private var cancellables = Set() - - // MARK: - Singleton - - /// Singleton instance. All access is via `DittoManager.shared`. - static var shared = DittoManager() - - init() { - self.loggingOption = DittoLogger.LoggingOptions.error // initial level value - - // subscribe to loggingOption changes - // make sure log level is set _before_ starting ditto - $loggingOption - .sink { logOption in - switch logOption { - case .disabled: - DittoLogger.enabled = false - default: - DittoLogger.enabled = true - DittoLogger.minimumLogLevel = DittoLogLevel(rawValue: logOption.rawValue)! - } - } - .store(in: &cancellables) - } - -} - - diff --git a/Sources/DittoAllToolsMenu/MenuListItem.swift b/Sources/DittoAllToolsMenu/MenuListItem.swift deleted file mode 100644 index dea01f7..0000000 --- a/Sources/DittoAllToolsMenu/MenuListItem.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// MenuListItem.swift -// -// - -import SwiftUI - -struct ColorfulIconLabelStyle: LabelStyle { - var color: Color - var size: CGFloat - var foregroundColor: Color = .white - - func makeBody(configuration: Configuration) -> some View { - Label { - configuration.title - } icon: { - configuration.icon - .imageScale(.small) - .foregroundColor(foregroundColor) - .background(RoundedRectangle(cornerRadius: 7 * size).frame(width: 28 * size, height: 28 * size).foregroundColor(color)) - } - } -} - -struct MenuListItem: View { - - @ScaledMetric var size: CGFloat = 1 - - var title: String - var systemImage: String - var color: Color = .accentColor - var foregroundColor: Color = .white - - var body: some View { - Label(title, systemImage: systemImage) - .labelStyle(ColorfulIconLabelStyle(color: color, size: size, foregroundColor: foregroundColor)) - } -} - diff --git a/Sources/DittoAllToolsMenu/MenuOption.swift b/Sources/DittoAllToolsMenu/MenuOption.swift new file mode 100644 index 0000000..678415d --- /dev/null +++ b/Sources/DittoAllToolsMenu/MenuOption.swift @@ -0,0 +1,137 @@ +// +// MenuOption.swift +// +// Copyright © 2024 DittoLive Incorporated. All rights reserved. +// + +import SwiftUI +import DittoSwift + +/// `MenuOption` enum defines various menu options in the tools app. +/// Each case represents a specific feature or tool that can be selected by the user. +enum MenuOption: String, CaseIterable { + case presenceViewer = "Presence Viewer" + case peersList = "Peers List" + case presenceDegradation = "Presence Degradation" + case diskUsage = "Disk Usage" + case permissionsHealth = "Permissions Health" + case heartbeat = "Heartbeat" + case dataBrowser = "Data Browser" + case logging = "Logging" + + // MARK: - Section + + /// `Section` enum is used to group related `MenuOption`s. + /// Each section represents a high-level category of tools available in the application. + enum Section: String, CaseIterable { + case networkAndPresenceTools = "Network" + case systemAndPerformanceTools = "System" + case diagnosticsAndDebuggingTools = "Debugging" + + /// Returns a list of `MenuOption`s available for each section. + /// - On tvOS, some options are excluded. + var options: [MenuOption] { + switch self { + case .networkAndPresenceTools: +#if os(tvOS) + return [.peersList, .presenceDegradation, .heartbeat] +#else + return [.presenceViewer, .peersList, .presenceDegradation, .heartbeat] + +#endif + case .systemAndPerformanceTools: + return [.permissionsHealth, .diskUsage] + case .diagnosticsAndDebuggingTools: + return [.dataBrowser, .logging] + } + } + } + + // MARK: - Icon + + /// Returns the SF Symbol icon name for each `MenuOption`. + /// - Used to visually represent each menu option in the UI. + var icon: String { + switch self { + case .presenceViewer: + return "network" + case .peersList: + return "list.bullet" + case .presenceDegradation: + return "exclamationmark.triangle" + case .diskUsage: + return "opticaldiscdrive" + case .permissionsHealth: + return "checklist" + case .heartbeat: + return "waveform.path.ecg" + case .dataBrowser: + return "folder" + case .logging: + return "list.bullet.rectangle" + } + } + + // MARK: - Color + + /// Returns the associated color for each `MenuOption`. + /// - Used to color-code menu options in the UI. + var color: Color { + switch self { + case .presenceViewer: + return .green + case .peersList: + return .blue + case .presenceDegradation: + return .red + case .diskUsage: + return .secondary + case .permissionsHealth: + return .purple + case .heartbeat: + return .pink + case .dataBrowser: + return .orange + case .logging: + return .gray + } + } + + // MARK: - Destination View + + /// Returns the appropriate destination view based on the selected `MenuOption` and the provided `ditto` instance. + /// - If `ditto` is `nil`, an empty view is returned. + /// - If `ditto` is not `nil`, a corresponding view is returned based on the selected menu option. + /// - Note: Some views require importing `WebKit`. + /// - Parameter ditto: The `Ditto` instance, which powers many of the views. + /// - Returns: A SwiftUI `View` that represents the destination for the selected menu option. + @ViewBuilder + func destinationView(ditto: Ditto?) -> some View { + if let ditto = ditto { + switch self { + case .presenceViewer: +#if canImport(WebKit) + PresenceViewer(ditto: ditto) +#else + EmptyView() +#endif + case .peersList: + PeersListViewer(ditto: ditto) + case .presenceDegradation: + PresenceDegradationViewer(ditto: ditto) + case .diskUsage: + DiskUsageViewer(ditto: ditto) + case .permissionsHealth: + PermissionsHealthViewer() + case .heartbeat: + HeartBeatViewer(ditto: ditto) + case .dataBrowser: + DataBrowserView(ditto: ditto) + case .logging: + LoggingDetailsViewer(ditto: ditto) + } + } else { + EmptyView() // Return an empty view when ditto is nil + } + } +} diff --git a/Sources/DittoAllToolsMenu/Pages/LoggingDetailsViewer.swift b/Sources/DittoAllToolsMenu/Pages/LoggingDetailsViewer.swift deleted file mode 100644 index 816c49e..0000000 --- a/Sources/DittoAllToolsMenu/Pages/LoggingDetailsViewer.swift +++ /dev/null @@ -1,25 +0,0 @@ -/// -// LoggingDetailsViewer.swift -// DittoToolsApp -// -// Created by Eric Turner on 6/1/23. -// -// Copyright © 2023 DittoLive Incorporated. All rights reserved. - -import DittoExportLogs -import DittoSwift -import SwiftUI - -struct LoggingDetailsViewer: View { - @ObservedObject var dittoManager = DittoManager.shared - - var body: some View { - LoggingDetailsView(loggingOption: $dittoManager.loggingOption) - } -} - -struct LoggingDetailsViewer_Previews: PreviewProvider { - static var previews: some View { - LoggingDetailsViewer(dittoManager: DittoManager.shared) - } -} diff --git a/Sources/DittoAllToolsMenu/Pages/PeersListViewer.swift b/Sources/DittoAllToolsMenu/Pages/PeersListViewer.swift deleted file mode 100644 index db3ce8a..0000000 --- a/Sources/DittoAllToolsMenu/Pages/PeersListViewer.swift +++ /dev/null @@ -1,18 +0,0 @@ -/// -// PeersListViewer.swift -// -// -// Created by Eric Turner on 3/17/23. -// -// Copyright © 2023 DittoLive Incorporated. All rights reserved. - -import DittoPeersList -import DittoDiskUsage -import SwiftUI - -struct PeersListViewer: View { - - var body: some View { - PeersListView(ditto: DittoManager.shared.ditto!) - } -} diff --git a/Sources/DittoAllToolsMenu/Pages/PermissionsHealthViewer.swift b/Sources/DittoAllToolsMenu/Pages/PermissionsHealthViewer.swift deleted file mode 100644 index 88c7e88..0000000 --- a/Sources/DittoAllToolsMenu/Pages/PermissionsHealthViewer.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// SwiftUIView.swift -// -// -// Created by Walker Erekson on 2/26/24. -// - -import SwiftUI -import DittoPermissionsHealth - -struct PermissionsHealthViewer: View { - var body: some View { - PermissionsHealth() - } -} - -#Preview { - PermissionsHealthViewer() -} diff --git a/Sources/DittoAllToolsMenu/Pages/PresenceDegradationViewer.swift b/Sources/DittoAllToolsMenu/Pages/PresenceDegradationViewer.swift deleted file mode 100644 index d78c68c..0000000 --- a/Sources/DittoAllToolsMenu/Pages/PresenceDegradationViewer.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// SwiftUIView.swift -// -// -// Created by Walker Erekson on 2/13/24. -// - -import SwiftUI -import DittoSwift -import DittoPresenceDegradation - -struct PresenceDegradationViewer: View { - - var body: some View { - PresenceDegradationView(ditto: DittoManager.shared.ditto!) { expectedPeers, remotePeers, settings in - print("expected Peers: \(expectedPeers)") - - if let remotePeers = remotePeers { - print("remotePeers: \(remotePeers)") - } - if let settings = settings { - print("settings: \(settings)") - } - - } - } -} - diff --git a/Sources/DittoAllToolsMenu/Pages/PresenceViewer.swift b/Sources/DittoAllToolsMenu/Pages/PresenceViewer.swift deleted file mode 100644 index c0412ea..0000000 --- a/Sources/DittoAllToolsMenu/Pages/PresenceViewer.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// Copyright © 2022 DittoLive Incorporated. All rights reserved. -// - -import SwiftUI -import UIKit -import DittoPresenceViewer - -#if canImport(WebKit) -struct PresenceViewer: View { - - var body: some View { - PresenceView(ditto: DittoManager.shared.ditto!) - } -} -#endif - diff --git a/Sources/DittoAllToolsMenu/Views/AllToolsMenu.swift b/Sources/DittoAllToolsMenu/Views/AllToolsMenu.swift new file mode 100644 index 0000000..750a756 --- /dev/null +++ b/Sources/DittoAllToolsMenu/Views/AllToolsMenu.swift @@ -0,0 +1,91 @@ +// +// AllToolsMenu.swift +// +// This file defines the `AllToolsMenu` view, which organizes and displays a list of diagnostic and data management tools. +// Each tool is grouped into relevant sections, and the view adapts its contents based on the platform (e.g., excluding export functionality on tvOS). +// +// Copyright © 2024 DittoLive Incorporated. All rights reserved. +// + +import SwiftUI +import DittoSwift +import DittoExportData + + +/// A view that displays a list of diagnostic tools and data management options. +/// +/// `AllToolsMenu` organizes various tools into sections, each with its own set of options. +/// The view can be conditionally configured to include or exclude specific items based on +/// the platform (e.g., excluding certain features on tvOS). +/// +/// - Note: On platforms other than tvOS, an additional section is included for exporting data, +/// which presents an alert to confirm the action. +public struct AllToolsMenu: View { + + var ditto: Ditto? + + public init(ditto: Ditto?) { + self.ditto = ditto + } + + public var body: some View { + List { + ForEach(MenuOption.Section.allCases, id: \.self) { section in + Section(header: Text(section.rawValue)) { + ForEach(section.options, id: \.self) { option in + MenuItem(option: option, ditto: ditto) + } + } + } + +#if !os(tvOS) + // Do not show on tvOS as export is not currently supported. + Section(footer: Text("Export all Ditto data on this device as a .zip file.")) { + ExportDataButton(ditto: ditto) + } +#endif + } + } +} + + +#if !os(tvOS) +/// A button view that triggers the export of Ditto data. +/// +/// `ExportDataButton` provides the functionality to export Ditto data as a `.zip` file. +/// It shows an alert to confirm the action and, once confirmed, presents a system sheet for sharing the exported file. +fileprivate struct ExportDataButton: View { + var ditto: Ditto? + + // State variables to manage the presentation of alerts and sheets for exporting data + @State private var presentExportDataAlert = false + @State private var isExportDataSharePresented = false + + var body: some View { + Button(action: { + presentExportDataAlert.toggle() + }) { + Text("Export Data…") + .foregroundColor(.accentColor) + } + .alert(isPresented: $presentExportDataAlert) { + Alert(title: Text("Are you sure?"), + message: Text("Compressing the Ditto directory data may take a while."), + primaryButton: .cancel(Text("Cancel")), + secondaryButton: .default(Text("Export…")) { + isExportDataSharePresented = true + print("ok!") + }) + } + .disabled(!(ditto?.activated ?? false)) + .sheet(isPresented: $isExportDataSharePresented) { + // Sheet to handle the file sharing of the exported data. + if let ditto { + ExportData(ditto: ditto) + } else { + Text("An active Ditto instance must be running in order to export data for security and privacy reasons.") + } + } + } +} +#endif diff --git a/Sources/DittoAllToolsMenu/Views/Components/MenuItem.swift b/Sources/DittoAllToolsMenu/Views/Components/MenuItem.swift new file mode 100644 index 0000000..a3e1abc --- /dev/null +++ b/Sources/DittoAllToolsMenu/Views/Components/MenuItem.swift @@ -0,0 +1,83 @@ +// +// MenuItem.swift +// +// Copyright © 2024 DittoLive Incorporated. All rights reserved. +// + +import SwiftUI +import DittoSwift + +/// A view that represents a single menu option in the tools list. +/// +/// `MenuItem` renders a `ToolListItem` representing a tool option. If the `ditto` instance is available, +/// the item becomes an interactive navigation link that takes the user to the tool's destination view. +/// Otherwise, the item is displayed in a disabled state. +struct MenuItem: View { + let option: MenuOption + var ditto: Ditto? + + var body: some View { + if let ditto, ditto.activated { + NavigationLink(destination: option.destinationView(ditto: ditto)) { + ToolListItem(title: option.rawValue, systemImageName: option.icon, color: option.color) + } + } else { + ToolListItem(title: option.rawValue, systemImageName: option.icon, color: .secondary) + .foregroundColor(.secondary) + .disabled(true) + } + } +} + + +/// A view that represents a single tool item in the tools list. +/// +/// `ToolListItem` displays a tool's icon and title, with customizable colors for both the icon and the text. +/// This view is typically used within a list to represent different tools or diagnostics options available in the app. +fileprivate struct ToolListItem: View { + + var title: String + var systemImageName: String + var color: Color = .accentColor + var foregroundColor: Color = .white + + var body: some View { + HStack(spacing: 16) { + SettingsIcon(backgroundColor: color, systemImageName: systemImageName) +#if os(tvOS) + .frame(width: 48, height: 48) +#else + .frame(width: 29, height: 29) +#endif + Text(title) + } + } +} + + +/// A view that displays an icon inside a rounded rectangle with a customizable background color. +/// +/// `SettingsIcon` is used to render the icon associated with a tool in the `ToolListItem`. +/// The icon is centered within a rounded rectangle, and its size adjusts relative to the containing view. +fileprivate struct SettingsIcon: View { + let backgroundColor: Color + let systemImageName: String + + var body: some View { + GeometryReader { geometry in + ZStack { + // A rounded rectangle with a corner radius that scales based on the geometry's height + RoundedRectangle(cornerRadius: geometry.size.height * 0.26) + .foregroundColor(backgroundColor) + + // The tool icon, sized and centered within the rounded rectangle + Image(systemName: systemImageName) + .resizable() + .aspectRatio(contentMode: .fit) + .imageScale(.small) + .foregroundColor(.white) + .frame(width: geometry.size.height * 0.7, height: geometry.size.height * 0.7) + } + } + } +} diff --git a/Sources/DittoAllToolsMenu/Pages/DataBrowser.swift b/Sources/DittoAllToolsMenu/Views/Tool Container Views/DataBrowser.swift similarity index 54% rename from Sources/DittoAllToolsMenu/Pages/DataBrowser.swift rename to Sources/DittoAllToolsMenu/Views/Tool Container Views/DataBrowser.swift index feda2c7..3a8ed11 100644 --- a/Sources/DittoAllToolsMenu/Pages/DataBrowser.swift +++ b/Sources/DittoAllToolsMenu/Views/Tool Container Views/DataBrowser.swift @@ -12,14 +12,16 @@ import SwiftUI struct DataBrowserView: View { + var ditto: Ditto + var body: some View { - DataBrowser(ditto: DittoManager.shared.ditto!) + DataBrowser(ditto: ditto) } } -struct DataBrowserView_Previews: PreviewProvider { - static var previews: some View { - DataBrowserView() - } -} +//struct DataBrowserView_Previews: PreviewProvider { +// static var previews: some View { +// DataBrowserView() +// } +//} diff --git a/Sources/DittoAllToolsMenu/Pages/DiskUsageViewer.swift b/Sources/DittoAllToolsMenu/Views/Tool Container Views/DiskUsageViewer.swift similarity index 73% rename from Sources/DittoAllToolsMenu/Pages/DiskUsageViewer.swift rename to Sources/DittoAllToolsMenu/Views/Tool Container Views/DiskUsageViewer.swift index cb77e14..7230fc5 100644 --- a/Sources/DittoAllToolsMenu/Pages/DiskUsageViewer.swift +++ b/Sources/DittoAllToolsMenu/Views/Tool Container Views/DiskUsageViewer.swift @@ -5,13 +5,16 @@ // Created by Ben Chatelain on 2023-01-30. // +import DittoSwift import DittoDiskUsage import SwiftUI struct DiskUsageViewer: View { + var ditto: Ditto + var body: some View { - DittoDiskUsageView(ditto: DittoManager.shared.ditto!) + DittoDiskUsageView(ditto: ditto) EmptyView() } } diff --git a/Sources/DittoAllToolsMenu/Pages/HeartBeatViewer.swift b/Sources/DittoAllToolsMenu/Views/Tool Container Views/HeartBeatViewer.swift similarity index 69% rename from Sources/DittoAllToolsMenu/Pages/HeartBeatViewer.swift rename to Sources/DittoAllToolsMenu/Views/Tool Container Views/HeartBeatViewer.swift index 3bb4505..eadc194 100644 --- a/Sources/DittoAllToolsMenu/Pages/HeartBeatViewer.swift +++ b/Sources/DittoAllToolsMenu/Views/Tool Container Views/HeartBeatViewer.swift @@ -7,10 +7,14 @@ import SwiftUI import DittoHeartbeat +import DittoSwift struct HeartBeatViewer: View { + + var ditto: Ditto + var body: some View { - HeartbeatView(ditto: DittoManager.shared.ditto!) + HeartbeatView(ditto: ditto) } } diff --git a/Sources/DittoAllToolsMenu/Views/Tool Container Views/LoggingDetailsViewer.swift b/Sources/DittoAllToolsMenu/Views/Tool Container Views/LoggingDetailsViewer.swift new file mode 100644 index 0000000..57f63ef --- /dev/null +++ b/Sources/DittoAllToolsMenu/Views/Tool Container Views/LoggingDetailsViewer.swift @@ -0,0 +1,25 @@ +// +// LoggingDetailsViewer.swift +// +// Copyright © 2024 DittoLive Incorporated. All rights reserved. +// + +import DittoExportLogs +import DittoSwift +import SwiftUI + + +struct LoggingDetailsViewer: View { + + var ditto: Ditto + + var body: some View { + LoggingDetailsView(ditto: ditto) + } +} + +//struct LoggingDetailsViewer_Previews: PreviewProvider { +// static var previews: some View { +// LoggingDetailsViewer(dittoManager: DittoManager.shared) +// } +//} diff --git a/DittoToolsApp/DittoToolsApp/Pages/PeersListViewer.swift b/Sources/DittoAllToolsMenu/Views/Tool Container Views/PeersListViewer.swift similarity index 78% rename from DittoToolsApp/DittoToolsApp/Pages/PeersListViewer.swift rename to Sources/DittoAllToolsMenu/Views/Tool Container Views/PeersListViewer.swift index db3ce8a..339c452 100644 --- a/DittoToolsApp/DittoToolsApp/Pages/PeersListViewer.swift +++ b/Sources/DittoAllToolsMenu/Views/Tool Container Views/PeersListViewer.swift @@ -9,10 +9,13 @@ import DittoPeersList import DittoDiskUsage import SwiftUI +import DittoSwift struct PeersListViewer: View { + var ditto: Ditto + var body: some View { - PeersListView(ditto: DittoManager.shared.ditto!) + PeersListView(ditto: ditto) } } diff --git a/DittoToolsApp/DittoToolsApp/Pages/PermissionsHealthViewer.swift b/Sources/DittoAllToolsMenu/Views/Tool Container Views/PermissionsHealthViewer.swift similarity index 100% rename from DittoToolsApp/DittoToolsApp/Pages/PermissionsHealthViewer.swift rename to Sources/DittoAllToolsMenu/Views/Tool Container Views/PermissionsHealthViewer.swift diff --git a/DittoToolsApp/DittoToolsApp/Pages/PresenceDegradationViewer.swift b/Sources/DittoAllToolsMenu/Views/Tool Container Views/PresenceDegradationViewer.swift similarity index 82% rename from DittoToolsApp/DittoToolsApp/Pages/PresenceDegradationViewer.swift rename to Sources/DittoAllToolsMenu/Views/Tool Container Views/PresenceDegradationViewer.swift index d78c68c..752fc1c 100644 --- a/DittoToolsApp/DittoToolsApp/Pages/PresenceDegradationViewer.swift +++ b/Sources/DittoAllToolsMenu/Views/Tool Container Views/PresenceDegradationViewer.swift @@ -11,8 +11,10 @@ import DittoPresenceDegradation struct PresenceDegradationViewer: View { + var ditto: Ditto + var body: some View { - PresenceDegradationView(ditto: DittoManager.shared.ditto!) { expectedPeers, remotePeers, settings in + PresenceDegradationView(ditto: ditto) { expectedPeers, remotePeers, settings in print("expected Peers: \(expectedPeers)") if let remotePeers = remotePeers { diff --git a/DittoToolsApp/DittoToolsApp/Pages/PresenceViewer.swift b/Sources/DittoAllToolsMenu/Views/Tool Container Views/PresenceViewer.swift similarity index 73% rename from DittoToolsApp/DittoToolsApp/Pages/PresenceViewer.swift rename to Sources/DittoAllToolsMenu/Views/Tool Container Views/PresenceViewer.swift index bb702b2..a37a512 100644 --- a/DittoToolsApp/DittoToolsApp/Pages/PresenceViewer.swift +++ b/Sources/DittoAllToolsMenu/Views/Tool Container Views/PresenceViewer.swift @@ -5,12 +5,15 @@ import SwiftUI import UIKit import DittoPresenceViewer +import DittoSwift #if canImport(WebKit) struct PresenceViewer: View { + var ditto: Ditto + var body: some View { - PresenceView(ditto: DittoManager.shared.ditto!) + PresenceView(ditto: ditto) } } #endif diff --git a/Sources/DittoExportData/ExportData.swift b/Sources/DittoExportData/ExportData.swift index 92ea589..42bd1db 100644 --- a/Sources/DittoExportData/ExportData.swift +++ b/Sources/DittoExportData/ExportData.swift @@ -24,10 +24,10 @@ public struct ExportData: UIViewControllerRepresentable { let zippedURL = zipDittoDirectory() - let avc = UIActivityViewController(activityItems: [zippedURL as Any], applicationActivities: nil) - avc.excludedActivityTypes = [.postToVimeo, .postToWeibo, .postToFlickr, .postToTwitter, .postToFacebook, .postToTencentWeibo, .addToReadingList, .assignToContact, .openInIBooks] + let activityViewController = UIActivityViewController(activityItems: [zippedURL as Any], applicationActivities: nil) + activityViewController.excludedActivityTypes = [.postToVimeo, .postToWeibo, .postToFlickr, .postToTwitter, .postToFacebook, .postToTencentWeibo, .addToReadingList, .assignToContact, .openInIBooks] - return avc + return activityViewController } private func zipDittoDirectory() -> URL? { diff --git a/Sources/DittoExportLogs/DittoLogger+LoggingOptions.swift b/Sources/DittoExportLogs/DittoLogger+LoggingOptions.swift deleted file mode 100644 index adbb87e..0000000 --- a/Sources/DittoExportLogs/DittoLogger+LoggingOptions.swift +++ /dev/null @@ -1,37 +0,0 @@ -/// -// DittoLogger+LoggingOptions.swift -// -// -// Created by Eric Turner on 6/1/23. -// -// Copyright © 2023 DittoLive Incorporated. All rights reserved. - -import DittoSwift - -public extension DittoLogger { - enum LoggingOptions:Int, CustomStringConvertible, CaseIterable, Identifiable { - case disabled = 0, error, warning, info, debug//, verbose - - public var id: Self { self } - - public var description: String { - switch self { - case .disabled: - return "disabled" - case .error: - return "error" - case .warning: - return "warning" - case .info: - return "info" - case .debug: - return "debug" -// XXX(rae): Hiding verbose from the UI because people are tempted to use it, -// but performance can be impacted by this level and it doesn't add enough -// extra value for debugging compared to the debug level. -// case .verbose: -// return "verbose" - } - } - } -} diff --git a/Sources/DittoExportLogs/LoggingDetailsView.swift b/Sources/DittoExportLogs/LoggingDetailsView.swift deleted file mode 100644 index 038267b..0000000 --- a/Sources/DittoExportLogs/LoggingDetailsView.swift +++ /dev/null @@ -1,112 +0,0 @@ -/// -// LoggingDetailsView.swift -// DittoToolsApp -// -// Created by Eric Turner on 5/30/23. -// -// Copyright © 2023 DittoLive Incorporated. All rights reserved. - -import Combine -import DittoSwift -import SwiftUI -import UIKit - - -public struct LoggingDetailsView: View { - @Environment(\.colorScheme) private var colorScheme - @State private var presentExportLogsShare: Bool = false - @State private var presentExportLogsAlert: Bool = false - @Binding var selectedLoggingOption: DittoLogger.LoggingOptions - - @State private var activityViewController: UIActivityViewController? - - public init(loggingOption: Binding) { - self._selectedLoggingOption = loggingOption - } - - private var textColor: Color { - colorScheme == .dark ? .white : .black - } - - public var body: some View { - List { - Section { - Text("Ditto Logging") - .frame(alignment: .center) - .font(.title) - } - Section { - Picker("Logging Level", selection: $selectedLoggingOption) { - ForEach(DittoLogger.LoggingOptions.allCases) { option in - Text(option.description) - } - } - } - Section { - // Export Logs - Button(action: { - self.presentExportLogsAlert.toggle() - print(self.presentExportLogsAlert) - }) { - HStack { - Text("Export Logs") - Spacer() - Image(systemName: "square.and.arrow.up") - } - } - .foregroundColor(textColor) - .frame(maxWidth: .infinity, maxHeight: .infinity) -#if !os(tvOS) - .sheet(isPresented: $presentExportLogsShare) { - if let activityVC = activityViewController { - // Use a wrapper UIViewController to present the activity controller - ActivityViewControllerWrapper(activityViewController: activityVC) - } else { - // Pass the binding for the `UIActivityViewController?` - ExportLogs(activityViewController: $activityViewController) - } - } -#endif - } - .alert(isPresented: $presentExportLogsAlert) { - #if os(tvOS) - Alert(title: Text("Export Logs"), - message: Text("Exporting logs is not supported on tvOS at this time."), - dismissButton: .cancel() - ) - #else - Alert(title: Text("Export Logs"), - message: Text("Compressing the logs may take a few seconds."), - primaryButton: .default( - Text("Export"), - action: { - presentExportLogsShare = true - }), - secondaryButton: .cancel() - ) - #endif - } - } -#if os(tvOS) - .listStyle(GroupedListStyle()) -#else - .listStyle(InsetGroupedListStyle()) -#endif - } -} - -struct ActivityViewControllerWrapper: UIViewControllerRepresentable { - let activityViewController: UIActivityViewController - - func makeUIViewController(context: Context) -> UIViewController { - let viewController = UIViewController() - DispatchQueue.main.async { - viewController.present(activityViewController, animated: true) - } - return viewController - } - - func updateUIViewController(_ uiViewController: UIViewController, context: Context) { - // No need to update the view controller here - } -} diff --git a/Sources/DittoExportLogs/Model/DittoLogLevel+CaseIterable.swift b/Sources/DittoExportLogs/Model/DittoLogLevel+CaseIterable.swift new file mode 100644 index 0000000..cd4d240 --- /dev/null +++ b/Sources/DittoExportLogs/Model/DittoLogLevel+CaseIterable.swift @@ -0,0 +1,44 @@ +// +// DittoLogLevel+CaseIterable.swift +// +// Copyright © 2024 DittoLive Incorporated. All rights reserved. +// + +import DittoSwift + + +extension DittoLogLevel: @retroactive CaseIterable { + /// Provides an array of all cases in the enum for iteration. + public static var allCases: [DittoLogLevel] { + return [.error, .warning, .info, .debug, .verbose] + } + + /// A list of log levels suitable for display in the user interface. + /// + /// This excludes `.verbose` to discourage its use, as it can significantly impact + /// performance without providing substantial additional debugging value compared + /// to `.debug`. + public static var displayableCases: [DittoLogLevel] { + return allCases.filter { $0 != .verbose } + } + + /// Returns a user-friendly display name for each log level. + var displayName: String { + switch self { + case .error: + return "Error" + case .warning: + return "Warning" + case .info: + return "Info" + case .debug: + return "Debug" + case .verbose: + return "Verbose" + @unknown default: + fatalError("Unknown DittoLogLevel") + } + } +} + + diff --git a/Sources/DittoExportLogs/Model/DittoLogLevel+UserDefaults.swift b/Sources/DittoExportLogs/Model/DittoLogLevel+UserDefaults.swift new file mode 100644 index 0000000..c75e9e3 --- /dev/null +++ b/Sources/DittoExportLogs/Model/DittoLogLevel+UserDefaults.swift @@ -0,0 +1,24 @@ +// +// DittoLogLevel+UserDefaults.swift +// +// Copyright © 2024 DittoLive Incorporated. All rights reserved. +// + +import DittoSwift + + +public extension DittoLogLevel { + /// The raw value key used for storing and retrieving the log level. + private static let storageKey = "DittoLogger.minimumLogLevel" + + /// Saves the current log level to UserDefaults. + func saveToStorage() { + UserDefaults.standard.set(self.rawValue, forKey: DittoLogLevel.storageKey) + } + + /// Restores the log level from UserDefaults, defaulting to `.info` if no value is found. + static func restoreFromStorage() -> DittoLogLevel { + let rawValue = UserDefaults.standard.integer(forKey: storageKey) + return DittoLogLevel(rawValue: rawValue) ?? .error + } +} diff --git a/Sources/DittoExportLogs/Views/LoggingDetailsView.swift b/Sources/DittoExportLogs/Views/LoggingDetailsView.swift new file mode 100644 index 0000000..dbb9329 --- /dev/null +++ b/Sources/DittoExportLogs/Views/LoggingDetailsView.swift @@ -0,0 +1,108 @@ +// +// LoggingDetailsView.swift +// +// Copyright © 2024 DittoLive Incorporated. All rights reserved. +// + +import Combine +import DittoSwift +import SwiftUI +import UIKit + + +public struct LoggingDetailsView: View { + + @State var selectedLogLevel = DittoLogger.minimumLogLevel + + @State var isLoggingEnabled = DittoLogger.enabled + + @State private var presentExportLogsShare: Bool = false + @State private var presentExportLogsAlert: Bool = false + +#if !os(tvOS) + @State private var activityViewController: UIActivityViewController? +#endif + + private let ditto: Ditto + + public init(ditto: Ditto) { + self.ditto = ditto + } + + public var body: some View { + List { + Section(header: Text("Settings"), + footer: Text("Changes will be applied immediately.") + ) { + Picker("Log Level", selection: $selectedLogLevel) { + ForEach(DittoLogLevel.displayableCases, id: \.self) { level in + Text(level.displayName).tag(level) + } + } + .onChange(of: selectedLogLevel) { newValue in + DittoLogger.minimumLogLevel = newValue + DittoLogger.minimumLogLevel.saveToStorage() + } + Toggle("Enable Logging", isOn: $isLoggingEnabled) + .onChange(of: isLoggingEnabled) { newValue in + DittoLogger.enabled = newValue + } + } +#if !os(tvOS) + Section { + // Export Logs + Button { + presentExportLogsAlert.toggle() + } label: { + Text("Export Logs…") + } + .sheet(isPresented: $presentExportLogsShare) { + if let activityVC = activityViewController { + // Use a wrapper UIViewController to present the activity controller + ActivityViewControllerWrapper(activityViewController: activityVC) + } else { + // Pass the binding for the `UIActivityViewController?` + ExportLogs(activityViewController: $activityViewController) + } + } + } + .alert(isPresented: $presentExportLogsAlert) { + Alert(title: Text("Export Logs"), + message: Text("Compressing the logs may take a few seconds."), + primaryButton: .default( + Text("Export"), + action: { + presentExportLogsShare = true + }), + secondaryButton: .cancel() + ) + } +#endif + } + #if os(tvOS) + .listStyle(GroupedListStyle()) + #else + .listStyle(InsetGroupedListStyle()) + .navigationBarTitleDisplayMode(.inline) + #endif + + .navigationTitle("Logging") + } +} + +@available(tvOS, unavailable) +struct ActivityViewControllerWrapper: UIViewControllerRepresentable { + let activityViewController: UIActivityViewController + + func makeUIViewController(context: Context) -> UIViewController { + let viewController = UIViewController() + DispatchQueue.main.async { + viewController.present(activityViewController, animated: true) + } + return viewController + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) { + // No need to update the view controller here + } +}