diff --git a/Tophat.xcodeproj/project.pbxproj b/Tophat.xcodeproj/project.pbxproj index 5140af4..b467f1a 100644 --- a/Tophat.xcodeproj/project.pbxproj +++ b/Tophat.xcodeproj/project.pbxproj @@ -25,7 +25,7 @@ 8025A5B429845EB5007B1BA0 /* Apps.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8025A5B329845EB5007B1BA0 /* Apps.swift */; }; 802671452947C297001A804D /* MenuHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 802671442947C297001A804D /* MenuHeader.swift */; }; 802671472947C33C001A804D /* Bundle+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 802671462947C33C001A804D /* Bundle+Extensions.swift */; }; - 802671492947C72C001A804D /* QuickLaunchAppView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 802671482947C72C001A804D /* QuickLaunchAppView.swift */; }; + 802671492947C72C001A804D /* QuickLaunchEntryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 802671482947C72C001A804D /* QuickLaunchEntryView.swift */; }; 8026714B2947C770001A804D /* QuickLaunchPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8026714A2947C770001A804D /* QuickLaunchPanel.swift */; }; 8029A081298AF1E90002C579 /* ApplicationIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8029A080298AF1E90002C579 /* ApplicationIcon.swift */; }; 8029B6A52AC239E000BD1D30 /* DeviceIsLockedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8029B6A42AC239E000BD1D30 /* DeviceIsLockedView.swift */; }; @@ -42,6 +42,7 @@ 80462F942CEFEF1A002F6E8F /* TophatExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80462F932CEFEF17002F6E8F /* TophatExtension.swift */; }; 80462F972CEFEF73002F6E8F /* ExtensionHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80462F962CEFEF70002F6E8F /* ExtensionHost.swift */; }; 80462F992CEFF04F002F6E8F /* AppExtensionIdentity+WithXPCSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80462F982CEFF04A002F6E8F /* AppExtensionIdentity+WithXPCSession.swift */; }; + 8046321F2CF106D5002F6E8F /* QuickLaunchEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8046321E2CF106C9002F6E8F /* QuickLaunchEntry.swift */; }; 804ECB7C2975C15300DE78F4 /* DevicePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 804ECB7B2975C15300DE78F4 /* DevicePicker.swift */; }; 804ECB7E2975C18300DE78F4 /* DeviceMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 804ECB7D2975C18300DE78F4 /* DeviceMenu.swift */; }; 804ECB802975C68400DE78F4 /* VisibleWhenButtonHoveredViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 804ECB7F2975C68400DE78F4 /* VisibleWhenButtonHoveredViewModifier.swift */; }; @@ -62,13 +63,12 @@ 8058B48C2CA630620075D38D /* URLReaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8058B48B2CA630620075D38D /* URLReaderTests.swift */; }; 805FC43229E9BE0A00A78208 /* BuildDownloaderError+LocalizedError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 805FC43129E9BE0A00A78208 /* BuildDownloaderError+LocalizedError.swift */; }; 80629BF12939818C0077960E /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80629BE92939818C0077960E /* SettingsView.swift */; }; - 80629BF22939818C0077960E /* PinnedApplicationRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80629BEC2939818C0077960E /* PinnedApplicationRow.swift */; }; + 80629BF22939818C0077960E /* QuickLaunchEntryRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80629BEC2939818C0077960E /* QuickLaunchEntryRow.swift */; }; 80629BF32939818C0077960E /* List+GradientButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80629BED2939818C0077960E /* List+GradientButtons.swift */; }; - 80629BF42939818C0077960E /* AddPinnedApplicationSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80629BEE2939818C0077960E /* AddPinnedApplicationSheet.swift */; }; + 80629BF42939818C0077960E /* QuickLaunchEntrySheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80629BEE2939818C0077960E /* QuickLaunchEntrySheet.swift */; }; 80629BF52939818C0077960E /* AppsTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80629BEF2939818C0077960E /* AppsTab.swift */; }; 80629BF62939818C0077960E /* GradientButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80629BF02939818C0077960E /* GradientButton.swift */; }; 80629BF82939819F0077960E /* String+IsValidURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80629BF72939819F0077960E /* String+IsValidURL.swift */; }; - 80629BFA293981A80077960E /* PinnedApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80629BF9293981A80077960E /* PinnedApplication.swift */; }; 80629BFC293981B10077960E /* CodableAppStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80629BFB293981B10077960E /* CodableAppStorage.swift */; }; 80629C22293A8D270077960E /* ProvisioningProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80629C21293A8D270077960E /* ProvisioningProfile.swift */; }; 80691D282CDA9AE9006572CD /* ArtifactDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80691D272CDA9ADE006572CD /* ArtifactDownloader.swift */; }; @@ -104,6 +104,9 @@ 80A91A132981BA1300D8A8B9 /* FloatingPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80A91A102981BA1300D8A8B9 /* FloatingPanel.swift */; }; 80A91A142981BA1300D8A8B9 /* FloatingPanelViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80A91A112981BA1300D8A8B9 /* FloatingPanelViewModifier.swift */; }; 80A91A162981BA2D00D8A8B9 /* LaunchFromURLPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80A91A152981BA2D00D8A8B9 /* LaunchFromURLPanel.swift */; }; + 80AE75E42CF4F467000923E3 /* QuickLaunchEntrySource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80AE75E32CF4F459000923E3 /* QuickLaunchEntrySource.swift */; }; + 80AE75E62CF4F660000923E3 /* QuickLaunchEntrySourceSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80AE75E52CF4F657000923E3 /* QuickLaunchEntrySourceSheet.swift */; }; + 80AE75E82CF50ABF000923E3 /* FormFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80AE75E72CF50ABD000923E3 /* FormFooterView.swift */; }; 80B48DE72C8BCBA300897317 /* com.shopify.Tophat.extension.appextensionpoint in Resources */ = {isa = PBXBuildFile; fileRef = 80B48DE62C8BCBA300897317 /* com.shopify.Tophat.extension.appextensionpoint */; }; 80B48DE92C8BCBC400897317 /* com.shopify.Tophat.extension.appextensionpoint in CopyFiles */ = {isa = PBXBuildFile; fileRef = 80B48DE62C8BCBA300897317 /* com.shopify.Tophat.extension.appextensionpoint */; }; 80B48E132C8BD1D500897317 /* ArtifactRetrievalCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80B48E122C8BD1D500897317 /* ArtifactRetrievalCoordinator.swift */; }; @@ -149,7 +152,6 @@ 80EB5D55297095890011DE5F /* TaskStatusMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80EB5D54297095890011DE5F /* TaskStatusMetadata.swift */; }; 80EB5D57297096FB0011DE5F /* InstallStatusMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80EB5D56297096FB0011DE5F /* InstallStatusMetadata.swift */; }; 80EB5D5D2970A25D0011DE5F /* UpdateIconTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80EB5D5C2970A25D0011DE5F /* UpdateIconTask.swift */; }; - 80EB5D5F2970A7940011DE5F /* PinnedApplicationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80EB5D5E2970A7940011DE5F /* PinnedApplicationState.swift */; }; 80ED3E5E29835AAF00A734B7 /* URL+ExpressibleByArgument.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80ED3E5C29835A9900A734B7 /* URL+ExpressibleByArgument.swift */; }; 80ED55462971CB3200B3AEBA /* MirrorDeviceDisplayTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80ED55452971CB3200B3AEBA /* MirrorDeviceDisplayTask.swift */; }; 80F380432984226800A9350F /* NotificationHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80F380422984226800A9350F /* NotificationHandler.swift */; }; @@ -253,7 +255,7 @@ 8025A5B329845EB5007B1BA0 /* Apps.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Apps.swift; sourceTree = ""; }; 802671442947C297001A804D /* MenuHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuHeader.swift; sourceTree = ""; }; 802671462947C33C001A804D /* Bundle+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+Extensions.swift"; sourceTree = ""; }; - 802671482947C72C001A804D /* QuickLaunchAppView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickLaunchAppView.swift; sourceTree = ""; }; + 802671482947C72C001A804D /* QuickLaunchEntryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickLaunchEntryView.swift; sourceTree = ""; }; 8026714A2947C770001A804D /* QuickLaunchPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickLaunchPanel.swift; sourceTree = ""; }; 8029A080298AF1E90002C579 /* ApplicationIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationIcon.swift; sourceTree = ""; }; 8029B6A42AC239E000BD1D30 /* DeviceIsLockedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceIsLockedView.swift; sourceTree = ""; }; @@ -268,6 +270,7 @@ 80462F932CEFEF17002F6E8F /* TophatExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TophatExtension.swift; sourceTree = ""; }; 80462F962CEFEF70002F6E8F /* ExtensionHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionHost.swift; sourceTree = ""; }; 80462F982CEFF04A002F6E8F /* AppExtensionIdentity+WithXPCSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppExtensionIdentity+WithXPCSession.swift"; sourceTree = ""; }; + 8046321E2CF106C9002F6E8F /* QuickLaunchEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickLaunchEntry.swift; sourceTree = ""; }; 804ECB7B2975C15300DE78F4 /* DevicePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicePicker.swift; sourceTree = ""; }; 804ECB7D2975C18300DE78F4 /* DeviceMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceMenu.swift; sourceTree = ""; }; 804ECB7F2975C68400DE78F4 /* VisibleWhenButtonHoveredViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisibleWhenButtonHoveredViewModifier.swift; sourceTree = ""; }; @@ -286,13 +289,12 @@ 8058B48B2CA630620075D38D /* URLReaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLReaderTests.swift; sourceTree = ""; }; 805FC43129E9BE0A00A78208 /* BuildDownloaderError+LocalizedError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BuildDownloaderError+LocalizedError.swift"; sourceTree = ""; }; 80629BE92939818C0077960E /* SettingsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; - 80629BEC2939818C0077960E /* PinnedApplicationRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PinnedApplicationRow.swift; sourceTree = ""; }; + 80629BEC2939818C0077960E /* QuickLaunchEntryRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QuickLaunchEntryRow.swift; sourceTree = ""; }; 80629BED2939818C0077960E /* List+GradientButtons.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "List+GradientButtons.swift"; sourceTree = ""; }; - 80629BEE2939818C0077960E /* AddPinnedApplicationSheet.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddPinnedApplicationSheet.swift; sourceTree = ""; }; + 80629BEE2939818C0077960E /* QuickLaunchEntrySheet.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QuickLaunchEntrySheet.swift; sourceTree = ""; }; 80629BEF2939818C0077960E /* AppsTab.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppsTab.swift; sourceTree = ""; }; 80629BF02939818C0077960E /* GradientButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GradientButton.swift; sourceTree = ""; }; 80629BF72939819F0077960E /* String+IsValidURL.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+IsValidURL.swift"; sourceTree = ""; }; - 80629BF9293981A80077960E /* PinnedApplication.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PinnedApplication.swift; sourceTree = ""; }; 80629BFB293981B10077960E /* CodableAppStorage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CodableAppStorage.swift; sourceTree = ""; }; 80629C21293A8D270077960E /* ProvisioningProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProvisioningProfile.swift; sourceTree = ""; }; 80691D272CDA9ADE006572CD /* ArtifactDownloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArtifactDownloader.swift; sourceTree = ""; wrapsLines = 0; }; @@ -325,6 +327,9 @@ 80A91A102981BA1300D8A8B9 /* FloatingPanel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FloatingPanel.swift; sourceTree = ""; }; 80A91A112981BA1300D8A8B9 /* FloatingPanelViewModifier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FloatingPanelViewModifier.swift; sourceTree = ""; }; 80A91A152981BA2D00D8A8B9 /* LaunchFromURLPanel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LaunchFromURLPanel.swift; sourceTree = ""; }; + 80AE75E32CF4F459000923E3 /* QuickLaunchEntrySource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickLaunchEntrySource.swift; sourceTree = ""; }; + 80AE75E52CF4F657000923E3 /* QuickLaunchEntrySourceSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickLaunchEntrySourceSheet.swift; sourceTree = ""; }; + 80AE75E72CF50ABD000923E3 /* FormFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormFooterView.swift; sourceTree = ""; }; 80B48DE62C8BCBA300897317 /* com.shopify.Tophat.extension.appextensionpoint */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = com.shopify.Tophat.extension.appextensionpoint; sourceTree = ""; }; 80B48E122C8BD1D500897317 /* ArtifactRetrievalCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArtifactRetrievalCoordinator.swift; sourceTree = ""; }; 80B536042AB5407700EEB2EF /* SettingsLinkAdditionalActionButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsLinkAdditionalActionButtonStyle.swift; sourceTree = ""; }; @@ -367,7 +372,6 @@ 80EB5D54297095890011DE5F /* TaskStatusMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStatusMetadata.swift; sourceTree = ""; }; 80EB5D56297096FB0011DE5F /* InstallStatusMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstallStatusMetadata.swift; sourceTree = ""; }; 80EB5D5C2970A25D0011DE5F /* UpdateIconTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateIconTask.swift; sourceTree = ""; }; - 80EB5D5E2970A7940011DE5F /* PinnedApplicationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedApplicationState.swift; sourceTree = ""; }; 80ED3E5C29835A9900A734B7 /* URL+ExpressibleByArgument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+ExpressibleByArgument.swift"; sourceTree = ""; }; 80ED55452971CB3200B3AEBA /* MirrorDeviceDisplayTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MirrorDeviceDisplayTask.swift; sourceTree = ""; }; 80F380422984226800A9350F /* NotificationHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationHandler.swift; sourceTree = ""; }; @@ -508,8 +512,9 @@ 8029A080298AF1E90002C579 /* ApplicationIcon.swift */, 80EB5D56297096FB0011DE5F /* InstallStatusMetadata.swift */, 80EB5D50296F68CD0011DE5F /* LaunchContext.swift */, - 80629BF9293981A80077960E /* PinnedApplication.swift */, 80629C21293A8D270077960E /* ProvisioningProfile.swift */, + 8046321E2CF106C9002F6E8F /* QuickLaunchEntry.swift */, + 80AE75E32CF4F459000923E3 /* QuickLaunchEntrySource.swift */, ); path = Models; sourceTree = ""; @@ -581,7 +586,8 @@ 80629BEB2939818C0077960E /* Settings */ = { isa = PBXGroup; children = ( - 80629BEE2939818C0077960E /* AddPinnedApplicationSheet.swift */, + 80629BEE2939818C0077960E /* QuickLaunchEntrySheet.swift */, + 80AE75E52CF4F657000923E3 /* QuickLaunchEntrySourceSheet.swift */, 80629BEF2939818C0077960E /* AppsTab.swift */, 809874AB294BB37A00EC541E /* DevicesTab.swift */, 80343E462CA39A1A00642D54 /* ExtensionsTab.swift */, @@ -590,7 +596,7 @@ 80B7BAE229773AFA00267C3C /* Locations */, 80B7BAE029770C5800267C3C /* LocationsTab.swift */, 80462F902CEFA7EF002F6E8F /* ParameterTextField.swift */, - 80629BEC2939818C0077960E /* PinnedApplicationRow.swift */, + 80629BEC2939818C0077960E /* QuickLaunchEntryRow.swift */, ); path = Settings; sourceTree = ""; @@ -598,6 +604,7 @@ 80629BFF293989600077960E /* Generic */ = { isa = PBXGroup; children = ( + 80AE75E72CF50ABD000923E3 /* FormFooterView.swift */, 8090E2022950C1CC003106B9 /* CollapsibleSection.swift */, 80629BF02939818C0077960E /* GradientButton.swift */, 80B7BAD229762C0800267C3C /* InlineButtonStyle.swift */, @@ -744,7 +751,7 @@ 80FDFDB62947DA61000606AC /* Quick Launch */ = { isa = PBXGroup; children = ( - 802671482947C72C001A804D /* QuickLaunchAppView.swift */, + 802671482947C72C001A804D /* QuickLaunchEntryView.swift */, 80B7BAD429762CBB00267C3C /* QuickLaunchEmptyState.swift */, 8026714A2947C770001A804D /* QuickLaunchPanel.swift */, ); @@ -772,7 +779,6 @@ 80A66D6B2981BC9900ECBCB6 /* MirrorDeviceDisplayAction.swift */, 80F380422984226800A9350F /* NotificationHandler.swift */, 6038B795B47D50A397AF03DB /* Notifications.swift */, - 80EB5D5E2970A7940011DE5F /* PinnedApplicationState.swift */, 80A66D692981BC9900ECBCB6 /* PrepareDeviceAction.swift */, 80CBACFE2989B8B100F778DD /* ShowOnboardingWindowAction.swift */, 80518F562984804C00FB8803 /* TophatCtlSymbolicLinkManager.swift */, @@ -997,6 +1003,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 80AE75E82CF50ABF000923E3 /* FormFooterView.swift in Sources */, 80629BF12939818C0077960E /* SettingsView.swift in Sources */, 809C8573297B0625004CE6A2 /* LaunchFromLocationMenuItem.swift in Sources */, 80F74E2A2909FAC80040F026 /* MainMenu.swift in Sources */, @@ -1020,6 +1027,8 @@ 804F37FD2C7CEFB00005A869 /* TrustedHostAlert.swift in Sources */, 80CBACF229898FFE00F778DD /* AboutView.swift in Sources */, 80629BF52939818C0077960E /* AppsTab.swift in Sources */, + 8046321F2CF106D5002F6E8F /* QuickLaunchEntry.swift in Sources */, + 80AE75E62CF4F660000923E3 /* QuickLaunchEntrySourceSheet.swift in Sources */, 80D71F262984CF100006E1BF /* OnboardingItemLayout.swift in Sources */, 8090E25B2967741F003106B9 /* TaskState.swift in Sources */, 80CBACEE298988B700F778DD /* LaunchAtLoginController.swift in Sources */, @@ -1041,13 +1050,11 @@ 80A91A0D2981B9F900D8A8B9 /* ShowingAlternateItemsViewModifier.swift in Sources */, 80A91A162981BA2D00D8A8B9 /* LaunchFromURLPanel.swift in Sources */, 8090E2032950C1CC003106B9 /* CollapsibleSection.swift in Sources */, - 80EB5D5F2970A7940011DE5F /* PinnedApplicationState.swift in Sources */, 80EB5D5D2970A25D0011DE5F /* UpdateIconTask.swift in Sources */, 80518F632984A64F00FB8803 /* OnboardingWindow.swift in Sources */, 80EB5D55297095890011DE5F /* TaskStatusMetadata.swift in Sources */, 80DC0FDA2C822E7F00E5C9EE /* UpdateController.swift in Sources */, 80D71F2E2985D11A0006E1BF /* CustomizeLocationsButton.swift in Sources */, - 80629BFA293981A80077960E /* PinnedApplication.swift in Sources */, 80EB5D57297096FB0011DE5F /* InstallStatusMetadata.swift in Sources */, 8020A6DE297F301700FEA490 /* URLReader.swift in Sources */, 80B7BAEC297744B500267C3C /* LocationPicker.swift in Sources */, @@ -1067,7 +1074,7 @@ 8090E25A2967741F003106B9 /* TaskProgress.swift in Sources */, 80A91A142981BA1300D8A8B9 /* FloatingPanelViewModifier.swift in Sources */, 80A66D6C2981BC9900ECBCB6 /* PrepareDeviceAction.swift in Sources */, - 80629BF22939818C0077960E /* PinnedApplicationRow.swift in Sources */, + 80629BF22939818C0077960E /* QuickLaunchEntryRow.swift in Sources */, 8090E265296774D2003106B9 /* StatusPopover.swift in Sources */, 8029B6A52AC239E000BD1D30 /* DeviceIsLockedView.swift in Sources */, 80B7BAEA29773EA600267C3C /* ScreenCopyPicker.swift in Sources */, @@ -1084,7 +1091,7 @@ 80564B5229834137002DC136 /* TaskStatusReporterDelegate.swift in Sources */, 8090E25D2967741F003106B9 /* TaskStatus.swift in Sources */, 80691D282CDA9AE9006572CD /* ArtifactDownloader.swift in Sources */, - 80629BF42939818C0077960E /* AddPinnedApplicationSheet.swift in Sources */, + 80629BF42939818C0077960E /* QuickLaunchEntrySheet.swift in Sources */, 804ECB7C2975C15300DE78F4 /* DevicePicker.swift in Sources */, 805FC43229E9BE0A00A78208 /* BuildDownloaderError+LocalizedError.swift in Sources */, 80462F992CEFF04F002F6E8F /* AppExtensionIdentity+WithXPCSession.swift in Sources */, @@ -1108,6 +1115,7 @@ 80A66D6D2981BC9900ECBCB6 /* ErrorNotifier.swift in Sources */, 80FF03EF29087473008509E0 /* InstallCoordinator.swift in Sources */, B6AA44DD296F78670017321C /* GeneralTab.swift in Sources */, + 80AE75E42CF4F467000923E3 /* QuickLaunchEntrySource.swift in Sources */, 8029A081298AF1E90002C579 /* ApplicationIcon.swift in Sources */, 80EB5D4D296F64F50011DE5F /* ApplicationError+LocalizedError.swift in Sources */, 80629BF62939818C0077960E /* GradientButton.swift in Sources */, @@ -1115,7 +1123,7 @@ 80D71F1E2984CE850006E1BF /* AndroidStudioOnboardingItem.swift in Sources */, 80462F8F2CEFA780002F6E8F /* InfoButton.swift in Sources */, 80301626292C17560016F25E /* AppleApplication.swift in Sources */, - 802671492947C72C001A804D /* QuickLaunchAppView.swift in Sources */, + 802671492947C72C001A804D /* QuickLaunchEntryView.swift in Sources */, 80EB5D45296F59100011DE5F /* FetchArtifactTask.swift in Sources */, 8090E25C2967741F003106B9 /* TaskStatusReporter.swift in Sources */, 804FF6592914239800147652 /* Collection+Filter.swift in Sources */, diff --git a/Tophat/Models/LaunchContext.swift b/Tophat/Models/LaunchContext.swift index 633e8aa..2647c6c 100644 --- a/Tophat/Models/LaunchContext.swift +++ b/Tophat/Models/LaunchContext.swift @@ -8,10 +8,10 @@ struct LaunchContext { let appName: String? - let pinnedApplicationId: PinnedApplication.ID? + let quickLaunchEntryID: QuickLaunchEntry.ID? - init(appName: String? = nil, pinnedApplicationId: PinnedApplication.ID? = nil) { + init(appName: String? = nil, quickLaunchEntryID: QuickLaunchEntry.ID? = nil) { self.appName = appName - self.pinnedApplicationId = pinnedApplicationId + self.quickLaunchEntryID = quickLaunchEntryID } } diff --git a/Tophat/Models/PinnedApplication.swift b/Tophat/Models/PinnedApplication.swift deleted file mode 100644 index 6dd0f9e..0000000 --- a/Tophat/Models/PinnedApplication.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// PinnedApplication.swift -// Tophat -// -// Created by Lukas Romsicki on 2022-11-30. -// Copyright © 2022 Shopify. All rights reserved. -// - -import Foundation -import TophatFoundation - -struct PinnedApplication: Identifiable, Codable { - let id: String - let name: String - let recipes: [InstallRecipe] - var icon: ApplicationIcon? = nil - - /// Creates a new pinned application. - /// - /// Do not specify an `id` unless you are using this initializer to update an existing item via replace. - /// User-created entries should only use auto-generated identifiers. - /// - /// - Parameters: - /// - id: The identifier of the pinned application, if used for updating purposes. - /// - name: The name of the pinned application. - /// - platform: The platform of the pinned application. - /// - recipes: The set of recipes at which this pinned application can be found. - init(id: String? = nil, name: String, recipes: [InstallRecipe] = []) { - self.id = id ?? UUID().uuidString - self.name = name - self.recipes = recipes - } - - var platform: Platform { - recipes.first?.platformHint ?? .unknown - } -} - -extension ApplicationIcon: Codable { - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - try container.encode(url) - } - - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - self.init(url: try container.decode(URL.self)) - } -} diff --git a/Tophat/Models/QuickLaunchEntry.swift b/Tophat/Models/QuickLaunchEntry.swift new file mode 100644 index 0000000..8e4c017 --- /dev/null +++ b/Tophat/Models/QuickLaunchEntry.swift @@ -0,0 +1,42 @@ +// +// QuickLaunchEntry.swift +// Tophat +// +// Created by Lukas Romsicki on 2024-11-22. +// Copyright © 2024 Shopify. All rights reserved. +// + +import Foundation +import TophatFoundation +import SwiftData + +@Model +final class QuickLaunchEntry: Identifiable, Hashable { + typealias Source = InstallRecipe + + @Attribute(.unique) + var id: String + + var name: String + + var iconURL: URL? + + @Relationship(deleteRule: .cascade, minimumModelCount: 1) + var sources: [QuickLaunchEntrySource] + + var order: Int = 0 + + init(id: String? = nil, name: String, iconURL: URL? = nil, sources: [QuickLaunchEntrySource], order: Int = 0) { + self.id = id ?? UUID().uuidString + self.name = name + self.iconURL = iconURL + self.sources = sources + self.order = order + } +} + +extension QuickLaunchEntry { + var platforms: Set { + Set(sources.compactMap { $0.platformHint }) + } +} diff --git a/Tophat/Models/QuickLaunchEntrySource.swift b/Tophat/Models/QuickLaunchEntrySource.swift new file mode 100644 index 0000000..2e85814 --- /dev/null +++ b/Tophat/Models/QuickLaunchEntrySource.swift @@ -0,0 +1,34 @@ +// +// QuickLaunchEntrySource.swift +// Tophat +// +// Created by Lukas Romsicki on 2024-11-25. +// Copyright © 2024 Shopify. All rights reserved. +// + +import Foundation +import TophatFoundation +import SwiftData + +@Model +final class QuickLaunchEntrySource: Hashable { + var artifactProviderID: String + var artifactProviderParameters: [String: String] + var launchArguments: [String] + var platformHint: Platform + var destinationHint: DeviceType? + + init( + artifactProviderID: String, + artifactProviderParameters: [String: String], + launchArguments: [String], + platformHint: Platform, + destinationHint: DeviceType? = nil + ) { + self.artifactProviderID = artifactProviderID + self.artifactProviderParameters = artifactProviderParameters + self.launchArguments = launchArguments + self.platformHint = platformHint + self.destinationHint = destinationHint + } +} diff --git a/Tophat/TophatApp.swift b/Tophat/TophatApp.swift index 2fbc018..e6218ce 100644 --- a/Tophat/TophatApp.swift +++ b/Tophat/TophatApp.swift @@ -18,6 +18,7 @@ import AndroidDeviceKit import AppleDeviceKit import FluidMenuBarExtra import TophatFoundation +import SwiftData let log = Logger(label: Bundle.main.bundleIdentifier!) @@ -43,11 +44,11 @@ struct TophatApp: App { .environment(appDelegate.updateController) .environment(appDelegate.extensionHost) .environmentObject(appDelegate.deviceManager) - .environmentObject(appDelegate.pinnedApplicationState) .environmentObject(appDelegate.utilityPathPreferences) .environmentObject(appDelegate.launchAtLoginController) .environmentObject(appDelegate.symbolicLinkManager) } + .modelContainer(appDelegate.modelContainer) } } @@ -56,6 +57,8 @@ private final class AppDelegate: NSObject, NSApplicationDelegate { @AppStorage("ListenPort") private var listenPort: Int = 29070 @AppStorage("HasCompletedFirstLaunch") private var hasCompletedFirstLaunch = false + let modelContainer = try! ModelContainer(for: QuickLaunchEntry.self) + private var menuBarExtra: FluidMenuBarExtra? private let sparkleUpdaterController = SPUStandardUpdaterController( @@ -69,7 +72,6 @@ private final class AppDelegate: NSObject, NSApplicationDelegate { private let notificationHandler = NotificationHandler() let deviceManager: DeviceManager - let pinnedApplicationState: PinnedApplicationState let utilityPathPreferences: UtilityPathPreferences let symbolicLinkManager = TophatCtlSymbolicLinkManager() let launchAtLoginController = LaunchAtLoginController() @@ -99,12 +101,10 @@ private final class AppDelegate: NSObject, NSApplicationDelegate { self.deviceSelectionManager = DeviceSelectionManager(deviceManager: deviceManager) self.taskStatusReporter = TaskStatusReporter() - self.pinnedApplicationState = PinnedApplicationState() self.installCoordinator = InstallCoordinator( deviceManager: deviceManager, deviceSelectionManager: deviceSelectionManager, - pinnedApplicationState: pinnedApplicationState, taskStatusReporter: taskStatusReporter, extensionHost: extensionHost ) @@ -164,12 +164,12 @@ private final class AppDelegate: NSObject, NSApplicationDelegate { .environmentObject(self.deviceManager) .environmentObject(self.deviceSelectionManager) .environmentObject(self.taskStatusReporter) - .environmentObject(self.pinnedApplicationState) .environment(self.updateController) .environment(\.launchApp, self.launchApp) .environment(\.prepareDevice, self.prepareDevice) .environment(\.mirrorDeviceDisplay, self.mirrorDeviceDisplay) .environment(\.showOnboardingWindow, self.showOnboardingWindow) + .modelContainer(self.modelContainer) } performFirstLaunchTasks() @@ -262,21 +262,47 @@ extension AppDelegate: TophatServerDelegate { // MARK: - NotificationHandlerDelegate extension AppDelegate: NotificationHandlerDelegate { - func notificationHandler(didReceiveRequestToAddPinnedApplication pinnedApplication: PinnedApplication) { - if let existingIndex = pinnedApplicationState.pinnedApplications.firstIndex(where: { $0.id == pinnedApplication.id }) { - let existingItem = pinnedApplicationState.pinnedApplications[existingIndex] + func notificationHandler(didReceiveRequestToAddQuickLaunchEntry quickLaunchEntry: QuickLaunchEntry) { + let context = ModelContext(modelContainer) + + let existingID = quickLaunchEntry.id + let existingEntryFetchDescriptor = FetchDescriptor( + predicate: #Predicate { $0.id == existingID } + ) + + do { + if let existingEntry = try context.fetch(existingEntryFetchDescriptor).first { + existingEntry.name = quickLaunchEntry.name + existingEntry.sources = quickLaunchEntry.sources + } else { + var fetchDescriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.order, order: .reverse)] + ) + fetchDescriptor.fetchLimit = 1 + + let existingEntries = try? context.fetch(fetchDescriptor) + let lastOrder = existingEntries?.first?.order ?? 0 + quickLaunchEntry.order = lastOrder + 1 - var newPinnedApplication = pinnedApplication - newPinnedApplication.icon = existingItem.icon - pinnedApplicationState.pinnedApplications[existingIndex] = newPinnedApplication + context.insert(quickLaunchEntry) + } - } else { - pinnedApplicationState.pinnedApplications.append(pinnedApplication) + } catch { + log.error("Failed to update Quick Launch entry!") } } - func notificationHandler(didReceiveRequestToRemovePinnedApplicationWithIdentifier pinnedApplicationIdentifier: PinnedApplication.ID) { - pinnedApplicationState.pinnedApplications.removeAll { $0.id == pinnedApplicationIdentifier } + func notificationHandler(didReceiveRequestToRemoveQuickLaunchEntryWithIdentifier quickLaunchEntryIdentifier: QuickLaunchEntry.ID) { + let context = ModelContext(modelContainer) + + do { + try context.delete( + model: QuickLaunchEntry.self, + where: #Predicate { $0.id == quickLaunchEntryIdentifier } + ) + } catch { + log.error("Failed to delete Quick Launch entry.") + } } func notificationHandler(didOpenURL url: URL, launchArguments: [String]) { diff --git a/Tophat/Utilities/InstallCoordinator.swift b/Tophat/Utilities/InstallCoordinator.swift index 2644f5e..b5bfd03 100644 --- a/Tophat/Utilities/InstallCoordinator.swift +++ b/Tophat/Utilities/InstallCoordinator.swift @@ -8,10 +8,10 @@ import Foundation import TophatFoundation +import SwiftData final class InstallCoordinator { private unowned let deviceManager: DeviceManager - private unowned let pinnedApplicationState: PinnedApplicationState private unowned let taskStatusReporter: TaskStatusReporter private let deviceSelectionManager: DeviceSelectionManager @@ -20,12 +20,10 @@ final class InstallCoordinator { init( deviceManager: DeviceManager, deviceSelectionManager: DeviceSelectionManager, - pinnedApplicationState: PinnedApplicationState, taskStatusReporter: TaskStatusReporter, extensionHost: ExtensionHost ) { self.deviceManager = deviceManager - self.pinnedApplicationState = pinnedApplicationState self.deviceSelectionManager = deviceSelectionManager self.taskStatusReporter = taskStatusReporter @@ -45,7 +43,6 @@ final class InstallCoordinator { let fetchArtifact = FetchArtifactTask( taskStatusReporter: taskStatusReporter, - pinnedApplicationState: pinnedApplicationState, artifactDownloader: artifactDownloader, context: context ) @@ -77,7 +74,6 @@ final class InstallCoordinator { let fetchArtifact = FetchArtifactTask( taskStatusReporter: taskStatusReporter, - pinnedApplicationState: pinnedApplicationState, artifactDownloader: artifactDownloader, context: context ) @@ -108,7 +104,6 @@ final class InstallCoordinator { private func install(ticket: InstallationTicketMachine.Ticket, context: LaunchContext? = nil) async throws { let fetchArtifact = FetchArtifactTask( taskStatusReporter: taskStatusReporter, - pinnedApplicationState: pinnedApplicationState, artifactDownloader: artifactDownloader, context: context ) diff --git a/Tophat/Utilities/NotificationHandler.swift b/Tophat/Utilities/NotificationHandler.swift index b4c29dc..fefa962 100644 --- a/Tophat/Utilities/NotificationHandler.swift +++ b/Tophat/Utilities/NotificationHandler.swift @@ -12,8 +12,8 @@ import TophatFoundation import TophatUtilities protocol NotificationHandlerDelegate: AnyObject { - func notificationHandler(didReceiveRequestToAddPinnedApplication pinnedApplication: PinnedApplication) - func notificationHandler(didReceiveRequestToRemovePinnedApplicationWithIdentifier pinnedApplicationIdentifier: PinnedApplication.ID) + func notificationHandler(didReceiveRequestToAddQuickLaunchEntry quickLaunchEntry: QuickLaunchEntry) + func notificationHandler(didReceiveRequestToRemoveQuickLaunchEntryWithIdentifier quickLaunchEntryIdentifier: QuickLaunchEntry.ID) func notificationHandler(didReceiveRequestToLaunchApplicationWithRecipes recipes: [InstallRecipe]) func notificationHandler(didOpenURL url: URL, launchArguments: [String]) } @@ -54,21 +54,22 @@ final class NotificationHandler { .store(in: &cancellables) notifier - .publisher(for: TophatAddPinnedApplicationNotification.self) + .publisher(for: TophatAddQuickLaunchEntryNotification.self) .sink { [weak self] payload in let configuration = payload.configuration - let pinnedApplication = PinnedApplication( + let quickLaunchEntry = QuickLaunchEntry( id: configuration.id, name: configuration.name, - recipes: configuration.sources.map { source in + sources: configuration.sources.map { source in let artifactProviderMetadata = ArtifactProviderMetadata( id: source.artifactProviderID, parameters: source.artifactProviderParameters ) - return InstallRecipe( - source: .artifactProvider(metadata: artifactProviderMetadata), + return QuickLaunchEntrySource( + artifactProviderID: artifactProviderMetadata.id, + artifactProviderParameters: artifactProviderMetadata.parameters, launchArguments: source.launchArguments, platformHint: source.platformHint, destinationHint: source.destinationHint @@ -76,14 +77,14 @@ final class NotificationHandler { } ) - self?.delegate?.notificationHandler(didReceiveRequestToAddPinnedApplication: pinnedApplication) + self?.delegate?.notificationHandler(didReceiveRequestToAddQuickLaunchEntry: quickLaunchEntry) } .store(in: &cancellables) notifier - .publisher(for: TophatRemovePinnedApplicationNotification.self) + .publisher(for: TophatRemoveQuickLaunchEntryNotification.self) .sink { [weak self] payload in - self?.delegate?.notificationHandler(didReceiveRequestToRemovePinnedApplicationWithIdentifier: payload.id) + self?.delegate?.notificationHandler(didReceiveRequestToRemoveQuickLaunchEntryWithIdentifier: payload.id) } .store(in: &cancellables) } diff --git a/Tophat/Utilities/PinnedApplicationState.swift b/Tophat/Utilities/PinnedApplicationState.swift deleted file mode 100644 index 579db9c..0000000 --- a/Tophat/Utilities/PinnedApplicationState.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// PinnedApplicationState.swift -// Tophat -// -// Created by Lukas Romsicki on 2023-01-12. -// Copyright © 2023 Shopify. All rights reserved. -// - -import Foundation - -final class PinnedApplicationState: ObservableObject { - @CodableAppStorage("PinnedApplications") var pinnedApplications: [PinnedApplication] = [] { - willSet { - DispatchQueue.main.async { - // Temporary workaround since the current implementation of CodableAppStorage - // isn't yet able to forward objectWillChange to its parent like AppStorage does. - self.objectWillChange.send() - } - } - } -} diff --git a/Tophat/Utilities/Tasks/FetchArtifactTask.swift b/Tophat/Utilities/Tasks/FetchArtifactTask.swift index 843a863..577acba 100644 --- a/Tophat/Utilities/Tasks/FetchArtifactTask.swift +++ b/Tophat/Utilities/Tasks/FetchArtifactTask.swift @@ -21,18 +21,15 @@ struct FetchArtifactTask { } let taskStatusReporter: TaskStatusReporter - let pinnedApplicationState: PinnedApplicationState let buildDownloader: ArtifactDownloader let context: LaunchContext? init( taskStatusReporter: TaskStatusReporter, - pinnedApplicationState: PinnedApplicationState, artifactDownloader: ArtifactDownloader, context: LaunchContext? ) { self.taskStatusReporter = taskStatusReporter - self.pinnedApplicationState = pinnedApplicationState self.buildDownloader = artifactDownloader self.context = context } @@ -57,7 +54,6 @@ struct FetchArtifactTask { Task { let updateIcon = UpdateIconTask( taskStatusReporter: taskStatusReporter, - pinnedApplicationState: pinnedApplicationState, context: context ) diff --git a/Tophat/Utilities/Tasks/UpdateIconTask.swift b/Tophat/Utilities/Tasks/UpdateIconTask.swift index d4e4a22..e212d83 100644 --- a/Tophat/Utilities/Tasks/UpdateIconTask.swift +++ b/Tophat/Utilities/Tasks/UpdateIconTask.swift @@ -8,15 +8,15 @@ import Foundation import TophatFoundation +import SwiftData struct UpdateIconTask { let taskStatusReporter: TaskStatusReporter - let pinnedApplicationState: PinnedApplicationState let context: LaunchContext? @MainActor func callAsFunction(application: Application) async throws { - guard let pinnedApplicationId = context?.pinnedApplicationId else { + guard let quickLaunchEntryID = context?.quickLaunchEntryID else { return } @@ -29,8 +29,19 @@ struct UpdateIconTask { } } - if let iconURL = application.icon, let persistedIcon = try? store(icon: iconURL, for: pinnedApplicationId) { - pinnedApplicationState.update(icon: persistedIcon, for: pinnedApplicationId) + if let iconURL = application.icon, let persistedIcon = try? store(icon: iconURL, for: quickLaunchEntryID) { + let container = try ModelContainer(for: QuickLaunchEntry.self) + let modelContext = ModelContext(container) + + let existingQuickLaunchEntryFetchDescriptor = FetchDescriptor( + predicate: #Predicate { $0.id == quickLaunchEntryID } + ) + + if let existingQuickLaunchEntry = try modelContext.fetch(existingQuickLaunchEntryFetchDescriptor).first { + existingQuickLaunchEntry.iconURL = persistedIcon.url + } + + try modelContext.save() } } @@ -43,19 +54,3 @@ struct UpdateIconTask { } } } - -private extension PinnedApplicationState { - func update(icon: ApplicationIcon, for pinnedApplicationId: PinnedApplication.ID) { - guard let index = index(pinnedApplicationId: pinnedApplicationId) else { - return - } - - var modifiedElement = pinnedApplications[index] - modifiedElement.icon = icon - pinnedApplications[index] = modifiedElement - } - - private func index(pinnedApplicationId: PinnedApplication.ID) -> Int? { - pinnedApplications.firstIndex { $0.id == pinnedApplicationId } - } -} diff --git a/Tophat/Views/Generic/FormFooterView.swift b/Tophat/Views/Generic/FormFooterView.swift new file mode 100644 index 0000000..4cc4c61 --- /dev/null +++ b/Tophat/Views/Generic/FormFooterView.swift @@ -0,0 +1,30 @@ +// +// FormFooterView.swift +// Tophat +// +// Created by Lukas Romsicki on 2024-11-25. +// Copyright © 2024 Shopify. All rights reserved. +// + +import SwiftUI + +struct FormFooterView: View { + var defaultActionTitleKey: LocalizedStringKey + var defaultActionDisabled: Bool + var defaultAction: () -> Void + var cancelAction: () -> Void + + var body: some View { + HStack { + Spacer() + + Button("Cancel", action: cancelAction) + .keyboardShortcut(.cancelAction) + + Button(defaultActionTitleKey, action: defaultAction) + .keyboardShortcut(.defaultAction) + .disabled(defaultActionDisabled) + } + .padding(20) + } +} diff --git a/Tophat/Views/Quick Launch/QuickLaunchAppView.swift b/Tophat/Views/Quick Launch/QuickLaunchEntryView.swift similarity index 52% rename from Tophat/Views/Quick Launch/QuickLaunchAppView.swift rename to Tophat/Views/Quick Launch/QuickLaunchEntryView.swift index 7ddfc34..75dfd9b 100644 --- a/Tophat/Views/Quick Launch/QuickLaunchAppView.swift +++ b/Tophat/Views/Quick Launch/QuickLaunchEntryView.swift @@ -1,5 +1,5 @@ // -// QuickLaunchAppView.swift +// QuickLaunchEntryView.swift // Tophat // // Created by Lukas Romsicki on 2022-12-12. @@ -7,38 +7,50 @@ // import SwiftUI +import TophatFoundation -struct QuickLaunchAppView: View { - let app: PinnedApplication +struct QuickLaunchEntryView: View { + let entry: QuickLaunchEntry var body: some View { VStack(spacing: 4) { - AsyncImage(url: app.icon?.url) { image in + AsyncImage(url: entry.iconURL) { image in image - .pinnedApplicationImageStyle() + .quickLaunchEntryImageStyle() } placeholder: { Image(.appIconPlaceholder) - .pinnedApplicationImageStyle() + .quickLaunchEntryImageStyle() } VStack(spacing: 0) { - Text(app.name) + Text(entry.name) .font(.caption) .lineLimit(1) .truncationMode(.tail) - Text(String(describing: app.platform)) + Text(platformDescription) .font(.system(size: 8).weight(.medium)) .opacity(0.8) .foregroundColor(.secondary) } + } + } + private var platformDescription: String { + if entry.platforms.count > 1 { + return "Multiple" } + + if let firstPlatform = entry.platforms.first { + return String(describing: firstPlatform) + } + + return String(describing: Platform.unknown) } } private extension Image { - func pinnedApplicationImageStyle() -> some View { + func quickLaunchEntryImageStyle() -> some View { self .resizable() .scaledToFit() diff --git a/Tophat/Views/Quick Launch/QuickLaunchPanel.swift b/Tophat/Views/Quick Launch/QuickLaunchPanel.swift index 88ce725..dcbd799 100644 --- a/Tophat/Views/Quick Launch/QuickLaunchPanel.swift +++ b/Tophat/Views/Quick Launch/QuickLaunchPanel.swift @@ -7,27 +7,30 @@ // import SwiftUI +import SwiftData import TophatFoundation struct QuickLaunchPanel: View { @Environment(\.launchApp) private var launchApp - @EnvironmentObject private var pinnedApplicationState: PinnedApplicationState + + @Query(sort: \QuickLaunchEntry.order) + var entries: [QuickLaunchEntry] private let columns = Array(repeating: GridItem(.fixed(44), spacing: 14), count: 5) var body: some View { Panel { Group { - if pinnedApplicationState.pinnedApplications.isEmpty { + if entries.isEmpty { QuickLaunchEmptyState() .frame(minWidth: 0, maxWidth: .infinity) } else { LazyVGrid(columns: columns, alignment: .leading, spacing: 14) { - ForEach(pinnedApplicationState.pinnedApplications) { app in + ForEach(entries) { entry in Button { - didSelect(app: app) + didSelect(entry: entry) } label: { - QuickLaunchAppView(app: app) + QuickLaunchEntryView(entry: entry) } .buttonStyle(.plain) } @@ -40,11 +43,24 @@ struct QuickLaunchPanel: View { } } - private func didSelect(app: PinnedApplication) { - let launchContext = LaunchContext(appName: app.name, pinnedApplicationId: app.id) + private func didSelect(entry: QuickLaunchEntry) { + let launchContext = LaunchContext(appName: entry.name, quickLaunchEntryID: entry.id) + let recipes = entry.sources.map { source in + InstallRecipe( + source: .artifactProvider( + metadata: ArtifactProviderMetadata( + id: source.artifactProviderID, + parameters: source.artifactProviderParameters + ) + ), + launchArguments: source.launchArguments, + platformHint: source.platformHint, + destinationHint: source.destinationHint + ) + } Task { - await launchApp?(recipes: app.recipes, context: launchContext) + await launchApp?(recipes: recipes, context: launchContext) } } } diff --git a/Tophat/Views/Settings/AddPinnedApplicationSheet.swift b/Tophat/Views/Settings/AddPinnedApplicationSheet.swift deleted file mode 100644 index 504fd09..0000000 --- a/Tophat/Views/Settings/AddPinnedApplicationSheet.swift +++ /dev/null @@ -1,310 +0,0 @@ -// -// AddPinnedApplicationSheet.swift -// Tophat -// -// Created by Lukas Romsicki on 2022-11-30. -// Copyright © 2022 Shopify. All rights reserved. -// - -import SwiftUI -import TophatFoundation -@_spi(TophatKitInternal) import TophatKit - -struct AddPinnedApplicationSheet: View { - @Environment(\.presentationMode) private var presentationMode - @Environment(ExtensionHost.self) private var extensionHost - @EnvironmentObject private var pinnedApplicationState: PinnedApplicationState - - private var editingApplicationID: String? - - @State private var name: String = "" - @State private var platform: Platform = .iOS - - @State private var destinationPreset: DestinationPreset = .any - - @State private var artifactProviderID: String? - @State private var simulatorArtifactProviderParameters: [String: String] = [:] - @State private var deviceArtifactProviderParameters: [String: String] = [:] - - private var addOrUpdateText: String { - editingApplicationID != nil ? "Update Quick Launch App" : "Add App to Quick Launch" - } - private var addOrUpdateButtonText: String { - editingApplicationID != nil ? "Update App" : "Add App" - } - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - Form { - Section(addOrUpdateText) { - TextField("Name", text: $name, prompt: Text("Name")) - - Picker("Platform", selection: $platform) { - ForEach([Platform.iOS, Platform.android], id: \.self) { platform in - Text(platform.description) - .tag(platform) - } - } - - Picker("Source", selection: $artifactProviderID) { - ForEach(artifactProviders) { artifactProvider in - Text(artifactProvider.title) - .tag(artifactProvider.id) - } - } - } - - Section { - Picker(selection: $destinationPreset) { - ForEach(DestinationPreset.allCases, id: \.self) { type in - Text(type.description) - } - } label: { - Text("Destination") - Text(destinationPreset.helpText) - } - } - - if let selectedArtifactProvider { - if destinationPreset == .all || destinationPreset == .simulatorOnly || destinationPreset == .any { - Section(destinationPreset == .all ? "Simulator Parameters" : "Parameters") { - ForEach(selectedArtifactProvider.parameters, id: \.key) { parameter in - ParameterTextField( - parameter: parameter, - text: simulatorArtifactProviderParameter(key: parameter.key) - ) - } - } - } - - if destinationPreset == .all || destinationPreset == .deviceOnly { - Section(destinationPreset == .all ? "Device Parameters" : "Parameters") { - ForEach(selectedArtifactProvider.parameters, id: \.key) { parameter in - ParameterTextField( - parameter: parameter, - text: deviceArtifactProviderParameter(key: parameter.key) - ) - } - } - } - } - } - .formStyle(.grouped) - - Divider() - - HStack { - Spacer() - - Button("Cancel", action: performCancelAction) - .keyboardShortcut(.cancelAction) - - Button(addOrUpdateButtonText, action: performDefaultAction) - .keyboardShortcut(.defaultAction) - .disabled(primaryActionDisabled) - } - .padding(20) - } - .frame(width: 500) - .fixedSize() - .scrollDisabled(true) - .onAppear { - if editingApplicationID == nil { - artifactProviderID = artifactProviders.first?.id - } - } - .onChange(of: artifactProviderID) { oldValue, newValue in - simulatorArtifactProviderParameters.removeAll() - deviceArtifactProviderParameters.removeAll() - } - } - - private var artifactProviders: [ArtifactProviderSpecification] { - extensionHost.availableExtensions.flatMap(\.specification.artifactProviders) - } - - private var selectedArtifactProvider: ArtifactProviderSpecification? { - artifactProviders.first { $0.id == artifactProviderID } - } - - private func simulatorArtifactProviderParameter(key: String) -> Binding { - .init( - get: { simulatorArtifactProviderParameters[key, default: ""] }, - set: { simulatorArtifactProviderParameters[key] = $0 } - ) - } - - private func deviceArtifactProviderParameter(key: String) -> Binding { - .init( - get: { deviceArtifactProviderParameters[key, default: ""] }, - set: { deviceArtifactProviderParameters[key] = $0 } - ) - } - - private var primaryActionDisabled: Bool { - name.isEmpty || installRecipes.isEmpty - } - - private func performCancelAction() { - presentationMode.wrappedValue.dismiss() - } - - private func performDefaultAction() { - if let editingApplicationID, - let existingIndex = pinnedApplicationState.pinnedApplications.firstIndex(where: { $0.id == editingApplicationID }) { - let existingItem = pinnedApplicationState.pinnedApplications[existingIndex] - - var newPinnedApplication = PinnedApplication( - id: editingApplicationID, - name: name, - recipes: installRecipes - ) - newPinnedApplication.icon = existingItem.icon - pinnedApplicationState.pinnedApplications[existingIndex] = newPinnedApplication - - } else { - let newPinnedApplication = PinnedApplication( - name: name, - recipes: installRecipes - ) - pinnedApplicationState.pinnedApplications.append(newPinnedApplication) - } - - presentationMode.wrappedValue.dismiss() - } - - private var installRecipes: [InstallRecipe] { - guard let selectedArtifactProvider else { - return [] - } - - let simulatorArtifactProviderMetadata = ArtifactProviderMetadata( - id: selectedArtifactProvider.id, - parameters: simulatorArtifactProviderParameters - ) - - let artifactProviderMetadata = ArtifactProviderMetadata( - id: selectedArtifactProvider.id, - parameters: deviceArtifactProviderParameters - ) - return switch destinationPreset { - case .any: - [ - .init( - source: .artifactProvider(metadata: simulatorArtifactProviderMetadata), - launchArguments: [], - platformHint: platform - ) - ] - case .all: - [ - .init( - source: .artifactProvider(metadata: simulatorArtifactProviderMetadata), - launchArguments: [], - platformHint: platform, - destinationHint: .simulator - ), - .init( - source: .artifactProvider(metadata: artifactProviderMetadata), - launchArguments: [], - platformHint: platform, - destinationHint: .device - ) - ] - case .simulatorOnly: - [ - .init( - source: .artifactProvider(metadata: simulatorArtifactProviderMetadata), - launchArguments: [], - platformHint: platform, - destinationHint: .simulator - ) - ] - case .deviceOnly: - [ - .init( - source: .artifactProvider(metadata: artifactProviderMetadata), - launchArguments: [], - platformHint: platform, - destinationHint: .device - ) - ] - } - } -} - -private enum DestinationPreset { - case any - case all - case simulatorOnly - case deviceOnly -} - -extension DestinationPreset { - var helpText: LocalizedStringResource { - switch self { - case .any: - return "This build can run on both simulators and devices." - case .all: - return "Simulators and devices require separate builds." - case .simulatorOnly: - return "This build can only run on simulators." - case .deviceOnly: - return "This build can only run on devices." - } - } -} - -extension DestinationPreset: CaseIterable {} -extension DestinationPreset: CustomStringConvertible { - var description: String { - switch self { - case .any: - return "Any" - case .all: - return "All" - case .simulatorOnly: - return "Simulator" - case .deviceOnly: - return "Device" - } - } -} - -extension AddPinnedApplicationSheet { - init(applicationToEdit: PinnedApplication) { - self.editingApplicationID = applicationToEdit.id - _name = State(initialValue: applicationToEdit.name) - _platform = State(initialValue: applicationToEdit.platform) - - let recipes = applicationToEdit.recipes - - _artifactProviderID = State(initialValue: recipes.first?.artifactProviderMetadata.id) - - if let virtualRecipe = recipes.first(where: { $0.destinationHint == .simulator }), - let physicalRecipe = recipes.first(where: { $0.destinationHint == .device }) { - _destinationPreset = State(initialValue: .all) - _simulatorArtifactProviderParameters = State(initialValue: virtualRecipe.artifactProviderMetadata.parameters) - _deviceArtifactProviderParameters = State(initialValue: physicalRecipe.artifactProviderMetadata.parameters) - } else if let physicalRecipe = recipes.first(where: { $0.destinationHint == .device }) { - _destinationPreset = State(initialValue: .deviceOnly) - _deviceArtifactProviderParameters = State(initialValue: physicalRecipe.artifactProviderMetadata.parameters) - } else if let virtualRecipe = recipes.first(where: { $0.destinationHint == .simulator }) { - _destinationPreset = State(initialValue: .simulatorOnly) - _simulatorArtifactProviderParameters = State(initialValue: virtualRecipe.artifactProviderMetadata.parameters) - } else if let firstRecipe = recipes.first { - _destinationPreset = State(initialValue: .any) - _simulatorArtifactProviderParameters = State(initialValue: firstRecipe.artifactProviderMetadata.parameters) - } - } -} - -private extension InstallRecipe { - var artifactProviderMetadata: ArtifactProviderMetadata { - guard case .artifactProvider(let metadata) = source else { - fatalError("Only build providers are supported in the graphical Quick Launch editor.") - } - - return metadata - } -} diff --git a/Tophat/Views/Settings/AppsTab.swift b/Tophat/Views/Settings/AppsTab.swift index 407fc16..f4ce4a1 100644 --- a/Tophat/Views/Settings/AppsTab.swift +++ b/Tophat/Views/Settings/AppsTab.swift @@ -7,13 +7,19 @@ // import SwiftUI +import SwiftData struct AppsTab: View { + @Environment(\.modelContext) private var modelContext + @AppStorage("ShowQuickLaunch") private var showQuickLaunch = true - @EnvironmentObject private var pinnedApplicationState: PinnedApplicationState - @State private var selection: String? - @State private var editingApplication: PinnedApplication? = nil - @State private var addPinnedApplicationSheetVisible = false + + @State private var selectedEntry: QuickLaunchEntry? + @State private var editingEntry: QuickLaunchEntry? + @State private var isAddingNewEntry = false + + @Query(sort: \QuickLaunchEntry.order) + var entries: [QuickLaunchEntry] var body: some View { Form { @@ -31,49 +37,62 @@ struct AppsTab: View { } Section { - List(selection: $selection) { - ForEach(pinnedApplicationState.pinnedApplications) { application in - PinnedApplicationRow(application: application) + List(selection: $selectedEntry) { + ForEach(entries) { entry in + QuickLaunchEntryRow(entry: entry) + .tag(entry) .listRowInsets(EdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12)) } - .onMove(perform: move) + .onMove { indexSet, offset in + var mutableEntries = entries + + mutableEntries.move(fromOffsets: indexSet, toOffset: offset) + + for (index, entry) in mutableEntries.enumerated() { + entry.order = index + } + + try? self.modelContext.save() + } + .onDelete { indexSet in + for index in indexSet { + let entry = entries[index] + modelContext.delete(entry) + } + } } .listGradientButtons { GradientButton(style: .plus) { - addPinnedApplicationSheetVisible = true + isAddingNewEntry = true } - } minusButton: { GradientButton(style: .minus) { - pinnedApplicationState.pinnedApplications.removeAll { $0.id == selection } - selection = nil + if let selectedEntry { + modelContext.delete(selectedEntry) + self.selectedEntry = nil + } } - .disabled(selection == nil) + .disabled(selectedEntry == nil) } - .contextMenu(forSelectionType: String.self) { selectionSet in + .contextMenu(forSelectionType: QuickLaunchEntry.self) { selectionSet in Button("Edit…") { - editingApplication = pinnedApplicationState.pinnedApplications.first { $0.id == selectionSet.first } + editingEntry = entries.first { $0 == selectionSet.first } } } primaryAction: { selectionSet in - editingApplication = pinnedApplicationState.pinnedApplications.first { $0.id == selectionSet.first } + editingEntry = entries.first { $0 == selectionSet.first } } - } .disabled(!showQuickLaunch) } .formStyle(.grouped) .onTapGesture(count: 1) { - selection = nil + selectedEntry = nil } - .sheet(isPresented: $addPinnedApplicationSheetVisible) { - AddPinnedApplicationSheet() + .sheet(item: $editingEntry) { entry in + QuickLaunchEntrySheet(entry: entry) } - .sheet(item: $editingApplication) { app in - AddPinnedApplicationSheet(applicationToEdit: app) + .sheet(isPresented: $isAddingNewEntry) { + QuickLaunchEntrySheet() } } - - private func move(from source: IndexSet, to destination: Int) { - pinnedApplicationState.pinnedApplications.move(fromOffsets: source, toOffset: destination) - } } diff --git a/Tophat/Views/Settings/PinnedApplicationRow.swift b/Tophat/Views/Settings/QuickLaunchEntryRow.swift similarity index 50% rename from Tophat/Views/Settings/PinnedApplicationRow.swift rename to Tophat/Views/Settings/QuickLaunchEntryRow.swift index a732a5f..81bfe9d 100644 --- a/Tophat/Views/Settings/PinnedApplicationRow.swift +++ b/Tophat/Views/Settings/QuickLaunchEntryRow.swift @@ -1,5 +1,5 @@ // -// PinnedApplicationRow.swift +// QuickLaunchEntryRow.swift // Tophat // // Created by Lukas Romsicki on 2022-11-30. @@ -10,47 +10,40 @@ import SwiftUI import TophatFoundation @_spi(TophatKitInternal) import TophatKit -struct PinnedApplicationRow: View { - @Environment(ExtensionHost.self) private var extensionHost +struct QuickLaunchEntryRow: View { @Environment(\.isEnabled) private var isEnabled - let application: PinnedApplication + let entry: QuickLaunchEntry var body: some View { HStack(spacing: 10) { - AsyncImage(url: application.icon?.url) { image in + AsyncImage(url: entry.iconURL) { image in image - .pinnedApplicationImageStyle() + .quickLaunchEntryImageStyle() } placeholder: { Image(.appIconPlaceholder) - .pinnedApplicationImageStyle() + .quickLaunchEntryImageStyle() } VStack(alignment: .leading, spacing: 3) { - Text(application.name) + Text(entry.name) .fontWeight(.medium) HStack { - BadgedText(text: "\(application.platform.description)") - - if case .artifactProvider(let metadata) = application.recipes.first?.source, let artifactProvider = artifactProviders.first(where: { $0.id == metadata.id }) { - BadgedText(text: artifactProvider.title) + ForEach(Array(entry.platforms), id: \.self) { platform in + BadgedText(text: Text(String(describing: platform))) } } } } .opacity(isEnabled ? 1 : 0.5) } - - private var artifactProviders: [ArtifactProviderSpecification] { - extensionHost.availableExtensions.flatMap(\.specification.artifactProviders) - } } -private struct BadgedText: View { - var text: LocalizedStringResource +struct BadgedText: View { + var text: Text var body: some View { - Text(text) + text .font(.caption) .foregroundColor(.secondary) .padding(.vertical, 1) @@ -61,7 +54,7 @@ private struct BadgedText: View { } private extension Image { - func pinnedApplicationImageStyle() -> some View { + func quickLaunchEntryImageStyle() -> some View { self .resizable() .scaledToFit() diff --git a/Tophat/Views/Settings/QuickLaunchEntrySheet.swift b/Tophat/Views/Settings/QuickLaunchEntrySheet.swift new file mode 100644 index 0000000..fd8811f --- /dev/null +++ b/Tophat/Views/Settings/QuickLaunchEntrySheet.swift @@ -0,0 +1,143 @@ +// +// QuickLaunchEntrySheet.swift +// Tophat +// +// Created by Lukas Romsicki on 2022-11-30. +// Copyright © 2022 Shopify. All rights reserved. +// + +import SwiftUI +import SwiftData +import TophatFoundation +@_spi(TophatKitInternal) import TophatKit + +struct QuickLaunchEntrySheet: View { + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) private var dismiss + @Environment(ExtensionHost.self) private var extensionHost + + var entry: QuickLaunchEntry? + + @State private var name: String = "" + @State private var sources: [QuickLaunchEntrySource] = [] + + @State private var selectedSource: QuickLaunchEntrySource? + @State private var editingSource: QuickLaunchEntrySource? + + @State private var isAddingNewSource = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Form { + Section { + TextField("Name", text: $name, prompt: Text("Name")) + } + + Section { + List(selection: $selectedSource) { + ForEach(sources) { source in + if let artifactProvider = artifactProvider(id: source.artifactProviderID) { + VStack(alignment: .leading, spacing: 3) { + Text(artifactProvider.title) + HStack { + BadgedText(text: Text(String(describing: source.platformHint))) + + if let destinationHint = source.destinationHint { + BadgedText(text: Text(String(describing: destinationHint))) + } + } + } + .listRowInsets(EdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12)) + .tag(source) + } + } + .onMove { indexSet, offset in + sources.move(fromOffsets: indexSet, toOffset: offset) + } + .onDelete { indexSet in + sources.remove(atOffsets: indexSet) + } + } + .listGradientButtons { + GradientButton(style: .plus) { + isAddingNewSource = true + } + } minusButton: { + GradientButton(style: .minus) { + if let selectedSourceID = selectedSource?.id { + sources.removeAll { $0.id == selectedSourceID } + } + } + .disabled(selectedSource == nil) + } + .contextMenu(forSelectionType: QuickLaunchEntrySource.self) { selectionSet in + Button("Edit…") { + editingSource = sources.first { $0 == selectionSet.first } + } + } primaryAction: { selectionSet in + editingSource = sources.first { $0 == selectionSet.first } + } + } header: { + Text("Sources") + Text("Create one or more sources so that Tophat can install this application to each of your selected devices.") + } + } + .formStyle(.grouped) + + Divider() + + FormFooterView( + defaultActionTitleKey: entry == nil ? "Add" : "Save", + defaultActionDisabled: name.isEmpty || sources.isEmpty + ) { + performSave() + dismiss() + } cancelAction: { + dismiss() + } + } + .frame(width: 550) + .fixedSize() + .onAppear { + if let entry { + self.name = entry.name + self.sources = entry.sources + } + } + .onTapGesture(count: 1) { + selectedSource = nil + } + .sheet(isPresented: $isAddingNewSource) { + QuickLaunchEntrySourceSheet(sources: $sources) + } + .sheet(item: $editingSource) { source in + QuickLaunchEntrySourceSheet(sources: $sources, source: source) + } + } + + private var artifactProviders: [ArtifactProviderSpecification] { + extensionHost.availableExtensions.flatMap(\.specification.artifactProviders) + } + + private func artifactProvider(id: ArtifactProviderSpecification.ID) -> ArtifactProviderSpecification? { + artifactProviders.first { $0.id == id } + } + + private func performSave() { + if let entry { + entry.name = name + entry.sources = sources + } else { + var fetchDescriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.order, order: .reverse)] + ) + fetchDescriptor.fetchLimit = 1 + + let existingEntries = try? modelContext.fetch(fetchDescriptor) + let lastOrder = existingEntries?.first?.order ?? 0 + + let newEntry = QuickLaunchEntry(name: name, sources: sources, order: lastOrder + 1) + modelContext.insert(newEntry) + } + } +} diff --git a/Tophat/Views/Settings/QuickLaunchEntrySourceSheet.swift b/Tophat/Views/Settings/QuickLaunchEntrySourceSheet.swift new file mode 100644 index 0000000..4d26cf1 --- /dev/null +++ b/Tophat/Views/Settings/QuickLaunchEntrySourceSheet.swift @@ -0,0 +1,195 @@ +// +// QuickLaunchEntrySourceSheet.swift +// Tophat +// +// Created by Lukas Romsicki on 2024-11-25. +// Copyright © 2024 Shopify. All rights reserved. +// + +import SwiftUI +import TophatFoundation +@_spi(TophatKitInternal) import TophatKit + +struct QuickLaunchEntrySourceSheet: View { + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) private var dismiss + @Environment(ExtensionHost.self) private var extensionHost + + @Binding var sources: [QuickLaunchEntrySource] + var source: QuickLaunchEntrySource? + + @State private var artifactProviderID: String? + @State private var artifactProviderParameters: [String: String] = [:] + @State private var launchArguments: [String] = [] + @State private var platform: Platform = .iOS + @State private var destination: DeviceType? + + @State private var selectedLaunchArgumentIndex: Int? + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Form { + Section { + Picker("Provider", selection: $artifactProviderID) { + ForEach(artifactProviders) { artifactProvider in + Text(artifactProvider.title) + .tag(artifactProvider.id) + } + } + } + + Section { + Picker("Platform", selection: $platform) { + ForEach([Platform.iOS, Platform.android], id: \.self) { platform in + Text(String(describing: platform)) + .tag(platform) + } + } + + Picker(selection: $destination) { + Text("Any") + .tag(Optional.none) + + ForEach(Array(DeviceType.allCases), id: \.self) { deviceType in + Text(String(describing: deviceType)) + .tag(Optional(deviceType)) + } + } label: { + Text("Destination") + Text(destinationHelpText) + } + } + + if let selectedArtifactProvider { + Section("Parameters") { + ForEach(selectedArtifactProvider.parameters, id: \.key) { parameter in + ParameterTextField( + parameter: parameter, + text: parameterBinding(key: parameter.key) + ) + } + } + } + + Section("Launch Arguments") { + List(selection: $selectedLaunchArgumentIndex) { + ForEach(Array($launchArguments.enumerated()), id: \.0) { (index, $launchArgument) in + TextField("Blank Argument", text: $launchArgument) + .tag(launchArgument) + } + .onMove { indexSet, offset in + launchArguments.move(fromOffsets: indexSet, toOffset: offset) + } + .onDelete { indexSet in + launchArguments.remove(atOffsets: indexSet) + } + } + .listGradientButtons { + GradientButton(style: .plus) { + launchArguments.append("") + } + } minusButton: { + GradientButton(style: .minus) { + if let selectedLaunchArgumentIndex { + launchArguments.remove(at: selectedLaunchArgumentIndex) + } + selectedLaunchArgumentIndex = nil + } + .disabled(selectedLaunchArgumentIndex == nil) + } + } + } + .formStyle(.grouped) + + Divider() + + FormFooterView( + defaultActionTitleKey: source == nil ? "Add" : "Save", + defaultActionDisabled: defaultActionDisabled + ) { + performSave() + dismiss() + } cancelAction: { + dismiss() + } + } + .frame(width: 500) + .fixedSize() + .onAppear { + if let source { + self.artifactProviderID = source.artifactProviderID + self.artifactProviderParameters = source.artifactProviderParameters + self.launchArguments = source.launchArguments + self.platform = source.platformHint + self.destination = source.destinationHint + } else if artifactProviderID == nil { + artifactProviderID = artifactProviders.first?.id + } + } + } + + private var artifactProviders: [ArtifactProviderSpecification] { + extensionHost.availableExtensions.flatMap(\.specification.artifactProviders) + } + + private var selectedArtifactProvider: ArtifactProviderSpecification? { + artifactProviders.first { $0.id == artifactProviderID } + } + + private var destinationHelpText: LocalizedStringKey { + switch destination { + case .simulator: + "This build can only run on simulators." + case .device: + "This build can only run on devices." + case nil: + "This build can run on both simulators and devices." + } + } + + private var defaultActionDisabled: Bool { + guard let selectedArtifactProvider else { + return true + } + + for parameter in selectedArtifactProvider.parameters { + if !parameter.isOptional { + return artifactProviderParameters[parameter.key, default: ""].isEmpty + } + } + + return false + } + + private func parameterBinding(key: String) -> Binding { + Binding( + get: { artifactProviderParameters[key, default: ""] }, + set: { artifactProviderParameters[key] = $0 } + ) + } + + private func performSave() { + guard let artifactProviderID else { + return + } + + if let source { + source.artifactProviderID = artifactProviderID + source.artifactProviderParameters = self.artifactProviderParameters + source.launchArguments = self.launchArguments + source.platformHint = self.platform + source.destinationHint = self.destination + + } else { + let newSource = QuickLaunchEntrySource( + artifactProviderID: artifactProviderID, + artifactProviderParameters: self.artifactProviderParameters, + launchArguments: self.launchArguments, + platformHint: self.platform, + destinationHint: self.destination + ) + + sources.append(newSource) + } + } +} diff --git a/TophatCtl/Commands/Apps/Apps+Add.swift b/TophatCtl/Commands/Apps/Apps+Add.swift index 6097d53..6a94f23 100644 --- a/TophatCtl/Commands/Apps/Apps+Add.swift +++ b/TophatCtl/Commands/Apps/Apps+Add.swift @@ -30,11 +30,11 @@ extension Apps { let data = try Data(contentsOf: path) let configuration = try JSONDecoder().decode(UserSpecifiedQuickLaunchEntryConfiguration.self, from: data) - let payload = TophatAddPinnedApplicationNotification.Payload( + let payload = TophatAddQuickLaunchEntryNotification.Payload( configuration: configuration ) - let notification = TophatAddPinnedApplicationNotification(payload: payload) + let notification = TophatAddQuickLaunchEntryNotification(payload: payload) TophatInterProcessNotifier().send(notification: notification) } } diff --git a/TophatCtl/Commands/Apps/Apps+Remove.swift b/TophatCtl/Commands/Apps/Apps+Remove.swift index 2305a9d..27b77d5 100644 --- a/TophatCtl/Commands/Apps/Apps+Remove.swift +++ b/TophatCtl/Commands/Apps/Apps+Remove.swift @@ -26,7 +26,7 @@ extension Apps { print("Warning: Tophat must be running for this command to succeed, but it is not running.") } - let notification = TophatRemovePinnedApplicationNotification(payload: .init(id: id)) + let notification = TophatRemoveQuickLaunchEntryNotification(payload: .init(id: id)) TophatInterProcessNotifier().send(notification: notification) } } diff --git a/TophatKit/Sources/TophatKit/Internal/AnyArtifactProviderParameter.swift b/TophatKit/Sources/TophatKit/Internal/AnyArtifactProviderParameter.swift index 461a730..f35ba1c 100644 --- a/TophatKit/Sources/TophatKit/Internal/AnyArtifactProviderParameter.swift +++ b/TophatKit/Sources/TophatKit/Internal/AnyArtifactProviderParameter.swift @@ -17,3 +17,9 @@ protocol AnyArtifactProviderParameter: AnyObject, Sendable { var prompt: LocalizedStringResource? { get } var help: LocalizedStringResource? { get } } + +extension AnyArtifactProviderParameter { + var isOptional: Bool { + Value.self is ExpressibleByNilLiteral.Type + } +} diff --git a/TophatKit/Sources/TophatKit/Internal/Messages/FetchExtensionSpecificationMessage.swift b/TophatKit/Sources/TophatKit/Internal/Messages/FetchExtensionSpecificationMessage.swift index 8024b21..eb5651d 100644 --- a/TophatKit/Sources/TophatKit/Internal/Messages/FetchExtensionSpecificationMessage.swift +++ b/TophatKit/Sources/TophatKit/Internal/Messages/FetchExtensionSpecificationMessage.swift @@ -59,6 +59,7 @@ public struct ArtifactProviderParameterSpecification: Codable { public let description: LocalizedStringResource? public let prompt: LocalizedStringResource? public let help: LocalizedStringResource? + public let isOptional: Bool init(parameter: some AnyArtifactProviderParameter) { self.key = parameter.key @@ -66,5 +67,6 @@ public struct ArtifactProviderParameterSpecification: Codable { self.description = parameter.description self.prompt = parameter.prompt self.help = parameter.help + self.isOptional = parameter.isOptional } } diff --git a/TophatModules/Sources/TophatUtilities/Control Notifications/Definitions/TophatAddPinnedApplicationNotification.swift b/TophatModules/Sources/TophatUtilities/Control Notifications/Definitions/TophatAddQuickLaunchEntryNotification.swift similarity index 71% rename from TophatModules/Sources/TophatUtilities/Control Notifications/Definitions/TophatAddPinnedApplicationNotification.swift rename to TophatModules/Sources/TophatUtilities/Control Notifications/Definitions/TophatAddQuickLaunchEntryNotification.swift index b6ce965..db57e5f 100644 --- a/TophatModules/Sources/TophatUtilities/Control Notifications/Definitions/TophatAddPinnedApplicationNotification.swift +++ b/TophatModules/Sources/TophatUtilities/Control Notifications/Definitions/TophatAddQuickLaunchEntryNotification.swift @@ -1,5 +1,5 @@ // -// TophatAddPinnedApplicationNotification.swift +// TophatAddQuickLaunchEntryNotification.swift // TophatUtilities // // Created by Lukas Romsicki on 2023-01-27. @@ -9,8 +9,8 @@ import Foundation import TophatFoundation -public struct TophatAddPinnedApplicationNotification: TophatInterProcessNotification { - public static let name = "TophatAddPinnedApplication" +public struct TophatAddQuickLaunchEntryNotification: TophatInterProcessNotification { + public static let name = "TophatAddQuickLaunchEntry" public struct Payload: Codable { public let configuration: UserSpecifiedQuickLaunchEntryConfiguration diff --git a/TophatModules/Sources/TophatUtilities/Control Notifications/Definitions/TophatRemovePinnedApplicationNotification.swift b/TophatModules/Sources/TophatUtilities/Control Notifications/Definitions/TophatRemoveQuickLaunchEntryNotification.swift similarity index 63% rename from TophatModules/Sources/TophatUtilities/Control Notifications/Definitions/TophatRemovePinnedApplicationNotification.swift rename to TophatModules/Sources/TophatUtilities/Control Notifications/Definitions/TophatRemoveQuickLaunchEntryNotification.swift index 8975bf0..e51502f 100644 --- a/TophatModules/Sources/TophatUtilities/Control Notifications/Definitions/TophatRemovePinnedApplicationNotification.swift +++ b/TophatModules/Sources/TophatUtilities/Control Notifications/Definitions/TophatRemoveQuickLaunchEntryNotification.swift @@ -1,5 +1,5 @@ // -// TophatRemovePinnedApplicationNotification.swift +// TophatRemoveQuickLaunchEntryNotification.swift // TophatUtilities // // Created by Lukas Romsicki on 2023-01-27. @@ -8,8 +8,8 @@ import Foundation -public struct TophatRemovePinnedApplicationNotification: TophatInterProcessNotification { - public static let name = "TophatRemovePinnedApplication" +public struct TophatRemoveQuickLaunchEntryNotification: TophatInterProcessNotification { + public static let name = "TophatRemoveQuickLaunchEntry" public struct Payload: Codable { public let id: String diff --git a/TophatModules/Sources/TophatUtilities/Shared/UserSpecifiedInstallRecipe.swift b/TophatModules/Sources/TophatUtilities/Shared/UserSpecifiedInstallRecipe.swift index 36aa252..19244f8 100644 --- a/TophatModules/Sources/TophatUtilities/Shared/UserSpecifiedInstallRecipe.swift +++ b/TophatModules/Sources/TophatUtilities/Shared/UserSpecifiedInstallRecipe.swift @@ -11,6 +11,6 @@ public struct UserSpecifiedInstallRecipe: Codable { public let artifactProviderID: String public let artifactProviderParameters: [String: String] public let launchArguments: [String] - public let platformHint: Platform? + public let platformHint: Platform public let destinationHint: DeviceType? }