From f8210cf691f035ff018de33bd5032f2e4a3171ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1rady=20Mil=C3=A1n?= <61704770+MilanVarady@users.noreply.github.com> Date: Mon, 30 Dec 2024 14:33:29 +0100 Subject: [PATCH 1/5] Add SidebarItem enum Add SidebarItem enum for easier sidebar navigation --- Applite.xcodeproj/project.pbxproj | 16 +++++++--- Applite/Model/SidebarItem.swift | 18 +++++++++++ .../Content View/ContentView+DetailView.swift | 31 ++++++++++--------- ...s.swift => ContentView+SidebarViews.swift} | 22 +++++++------ Applite/Views/Content View/ContentView.swift | 10 +++--- .../Views/Detail Views/AppMigrationView.swift | 18 +++++++++++ .../DiscoverSectionView+CategoryHeader.swift | 2 +- .../DiscoverSectionView.swift | 2 +- .../Detail Views/Discover/DiscoverView.swift | 4 +-- .../Detail Views/Download/DownloadView.swift | 4 +-- Localizable.xcstrings | 6 ++++ 11 files changed, 94 insertions(+), 39 deletions(-) create mode 100644 Applite/Model/SidebarItem.swift rename Applite/Views/Content View/{ContentView+SidebarItems.swift => ContentView+SidebarViews.swift} (60%) create mode 100644 Applite/Views/Detail Views/AppMigrationView.swift diff --git a/Applite.xcodeproj/project.pbxproj b/Applite.xcodeproj/project.pbxproj index 9ec12a0..f26f72d 100644 --- a/Applite.xcodeproj/project.pbxproj +++ b/Applite.xcodeproj/project.pbxproj @@ -73,7 +73,7 @@ 419256352D1DF30700D9EF10 /* SettingsView+UpdateSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419256342D1DF30700D9EF10 /* SettingsView+UpdateSettings.swift */; }; 419256372D1DF34200D9EF10 /* SettingsView+ProxySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419256362D1DF34200D9EF10 /* SettingsView+ProxySettings.swift */; }; 419256392D1DF35C00D9EF10 /* SettingsView+Uninstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419256382D1DF35C00D9EF10 /* SettingsView+Uninstaller.swift */; }; - 4192563C2D1DF3C900D9EF10 /* ContentView+SidebarItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4192563B2D1DF3C900D9EF10 /* ContentView+SidebarItems.swift */; }; + 4192563C2D1DF3C900D9EF10 /* ContentView+SidebarViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4192563B2D1DF3C900D9EF10 /* ContentView+SidebarViews.swift */; }; 4192563E2D1DF3E900D9EF10 /* ContentView+DetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4192563D2D1DF3E900D9EF10 /* ContentView+DetailView.swift */; }; 419256402D1DF41300D9EF10 /* ContentView+LoadCasks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4192563F2D1DF41300D9EF10 /* ContentView+LoadCasks.swift */; }; 419256432D1E0A0200D9EF10 /* BrewPathSelectorView+PathOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419256422D1E0A0200D9EF10 /* BrewPathSelectorView+PathOption.swift */; }; @@ -95,6 +95,8 @@ 4192566E2D1F293700D9EF10 /* Cask+LaunchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4192566D2D1F293700D9EF10 /* Cask+LaunchApp.swift */; }; 419256702D1F299E00D9EF10 /* Cask+ProtocolConformances.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4192566F2D1F299E00D9EF10 /* Cask+ProtocolConformances.swift */; }; 419256832D22055200D9EF10 /* CaskInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419256822D22055200D9EF10 /* CaskInfo.swift */; }; + 4192568A2D22D67400D9EF10 /* SidebarItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419256892D22D67400D9EF10 /* SidebarItem.swift */; }; + 4192568C2D22D7FC00D9EF10 /* AppMigrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4192568B2D22D7FC00D9EF10 /* AppMigrationView.swift */; }; 419506A42964A27F00FE5802 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419506A32964A27F00FE5802 /* SetupView.swift */; }; 419506A62964A5EF00FE5802 /* BrewPathSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419506A52964A5EF00FE5802 /* BrewPathSelectorView.swift */; }; 4196C8F528F9CB2600EADDDA /* DiscoverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4196C8F428F9CB2600EADDDA /* DiscoverView.swift */; }; @@ -172,7 +174,7 @@ 419256342D1DF30700D9EF10 /* SettingsView+UpdateSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SettingsView+UpdateSettings.swift"; sourceTree = ""; }; 419256362D1DF34200D9EF10 /* SettingsView+ProxySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SettingsView+ProxySettings.swift"; sourceTree = ""; }; 419256382D1DF35C00D9EF10 /* SettingsView+Uninstaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SettingsView+Uninstaller.swift"; sourceTree = ""; }; - 4192563B2D1DF3C900D9EF10 /* ContentView+SidebarItems.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+SidebarItems.swift"; sourceTree = ""; }; + 4192563B2D1DF3C900D9EF10 /* ContentView+SidebarViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+SidebarViews.swift"; sourceTree = ""; }; 4192563D2D1DF3E900D9EF10 /* ContentView+DetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+DetailView.swift"; sourceTree = ""; }; 4192563F2D1DF41300D9EF10 /* ContentView+LoadCasks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+LoadCasks.swift"; sourceTree = ""; }; 419256422D1E0A0200D9EF10 /* BrewPathSelectorView+PathOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BrewPathSelectorView+PathOption.swift"; sourceTree = ""; }; @@ -194,6 +196,8 @@ 4192566D2D1F293700D9EF10 /* Cask+LaunchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Cask+LaunchApp.swift"; sourceTree = ""; }; 4192566F2D1F299E00D9EF10 /* Cask+ProtocolConformances.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Cask+ProtocolConformances.swift"; sourceTree = ""; }; 419256822D22055200D9EF10 /* CaskInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaskInfo.swift; sourceTree = ""; }; + 419256892D22D67400D9EF10 /* SidebarItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarItem.swift; sourceTree = ""; }; + 4192568B2D22D7FC00D9EF10 /* AppMigrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppMigrationView.swift; sourceTree = ""; }; 419506A32964A27F00FE5802 /* SetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupView.swift; sourceTree = ""; }; 419506A52964A5EF00FE5802 /* BrewPathSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrewPathSelectorView.swift; sourceTree = ""; }; 419506A729696A5300FE5802 /* Applite-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "Applite-Info.plist"; sourceTree = SOURCE_ROOT; }; @@ -401,6 +405,7 @@ 41483CCA29101C7200BB10C2 /* Cask Models */, 41483CCB29101C7D00BB10C2 /* Categories */, 4104D7442A8FC53200F84F9B /* Preferences */, + 419256892D22D67400D9EF10 /* SidebarItem.swift */, ); path = Model; sourceTree = ""; @@ -482,7 +487,7 @@ isa = PBXGroup; children = ( 414074F628DF53E80073EB22 /* ContentView.swift */, - 4192563B2D1DF3C900D9EF10 /* ContentView+SidebarItems.swift */, + 4192563B2D1DF3C900D9EF10 /* ContentView+SidebarViews.swift */, 4192563D2D1DF3E900D9EF10 /* ContentView+DetailView.swift */, 4192563F2D1DF41300D9EF10 /* ContentView+LoadCasks.swift */, ); @@ -585,6 +590,7 @@ 41857B742912D94A004A1894 /* CategoryView.swift */, 418989AC2A33A5C4004AC23B /* BrewManagementView.swift */, 41B731382A879353008BF6B9 /* ActiveTasksView.swift */, + 4192568B2D22D7FC00D9EF10 /* AppMigrationView.swift */, ); path = "Detail Views"; sourceTree = ""; @@ -719,11 +725,12 @@ 413F77A52972B2E70053349A /* DependencyManager.swift in Sources */, 418989B42A35D67C004AC23B /* isCommandLineToolsInstalled.swift in Sources */, 419256352D1DF30700D9EF10 /* SettingsView+UpdateSettings.swift in Sources */, - 4192563C2D1DF3C900D9EF10 /* ContentView+SidebarItems.swift in Sources */, + 4192563C2D1DF3C900D9EF10 /* ContentView+SidebarViews.swift in Sources */, 4192560F2D1CC09500D9EF10 /* DependencyError.swift in Sources */, 4192566E2D1F293700D9EF10 /* Cask+LaunchApp.swift in Sources */, 419256592D1E0EA000D9EF10 /* DiscoverSectionView+OffsetKey.swift in Sources */, 4192565E2D1E155400D9EF10 /* UpdateView+UpdateAllButton.swift in Sources */, + 4192568A2D22D67400D9EF10 /* SidebarItem.swift in Sources */, 419256572D1E0E5F00D9EF10 /* DiscoverSectionView+AppRow.swift in Sources */, 419506A42964A27F00FE5802 /* SetupView.swift in Sources */, 4192561E2D1DEBE700D9EF10 /* AppView+UninstallButton.swift in Sources */, @@ -767,6 +774,7 @@ 418F332628EC921D0023D76F /* CaskData.swift in Sources */, 4178CF922A8689AF0037F270 /* ExportCasks.swift in Sources */, 4192562E2D1DF22500D9EF10 /* SetupView+AllSet.swift in Sources */, + 4192568C2D22D7FC00D9EF10 /* AppMigrationView.swift in Sources */, 413E60B72BBAE5E000978F6A /* NetworkProxyManager.swift in Sources */, 4196C8F928F9CDF700EADDDA /* DownloadView.swift in Sources */, 4120AB682A755B5A00F68EFE /* CheckForUpdatesView.swift in Sources */, diff --git a/Applite/Model/SidebarItem.swift b/Applite/Model/SidebarItem.swift new file mode 100644 index 0000000..cb35728 --- /dev/null +++ b/Applite/Model/SidebarItem.swift @@ -0,0 +1,18 @@ +// +// SidebarItem.swift +// Applite +// +// Created by Milán Várady on 2024.12.30. +// + +import Foundation + +enum SidebarItem: Equatable, Hashable { + case home + case updates + case installed + case activeTasks + case appMigration + case brew + case appCategory(categoryId: String) +} diff --git a/Applite/Views/Content View/ContentView+DetailView.swift b/Applite/Views/Content View/ContentView+DetailView.swift index 20d69b4..7f021c3 100644 --- a/Applite/Views/Content View/ContentView+DetailView.swift +++ b/Applite/Views/Content View/ContentView+DetailView.swift @@ -11,14 +11,14 @@ extension ContentView { @ViewBuilder var detailView: some View { switch selection { - case "home": + case .home: if !brokenInstall { DownloadView(navigationSelection: $selection, searchText: $searchTextSubmitted) } else { // Broken install VStack(alignment: .center) { Text(DependencyManager.brokenPathOrIstallMessage) - + Button { Task { await loadCasks() @@ -31,25 +31,28 @@ extension ContentView { } .frame(maxWidth: 600) } - - case "updates": + + case .updates: UpdateView() - - case "installed": + + case .installed: InstalledView() - - case "activeTasks": + + case .activeTasks: ActiveTasksView() - - case "brew": - BrewManagementView(modifyingBrew: $modifyingBrew) - - default: - if let category = categories.first(where: { $0.id == selection }) { + + case .appMigration: + AppMigrationView() + + case .appCategory(let categoryId): + if let category = categories.first(where: { $0.id == categoryId }) { CategoryView(category: category) } else { Text("No Selection") } + + case .brew: + BrewManagementView(modifyingBrew: $modifyingBrew) } } } diff --git a/Applite/Views/Content View/ContentView+SidebarItems.swift b/Applite/Views/Content View/ContentView+SidebarViews.swift similarity index 60% rename from Applite/Views/Content View/ContentView+SidebarItems.swift rename to Applite/Views/Content View/ContentView+SidebarViews.swift index 7404571..90b0580 100644 --- a/Applite/Views/Content View/ContentView+SidebarItems.swift +++ b/Applite/Views/Content View/ContentView+SidebarViews.swift @@ -1,5 +1,5 @@ // -// ContentView+SidebarItems.swift +// ContentView+SidebarViews.swift // Applite // // Created by Milán Várady on 2024.12.26. @@ -8,35 +8,37 @@ import SwiftUI extension ContentView { - var sidebarItems: some View { + var sidebarViews: some View { List(selection: $selection) { Divider() Label("Discover", systemImage: "house.fill") - .tag("home") + .tag(SidebarItem.home) Label("Updates", systemImage: "arrow.clockwise.circle.fill") .badge(caskData.outdatedCasks.count) - .tag("updates") + .tag(SidebarItem.updates) Label("Installed", systemImage: "externaldrive.fill.badge.checkmark") - .tag("installed") + .tag(SidebarItem.installed) Label("Active Tasks", systemImage: "gearshape.arrow.triangle.2.circlepath") .badge(caskData.busyCasks.count) - .tag("activeTasks") + .tag(SidebarItem.activeTasks) + + Label("App Migration", systemImage: "square.and.arrow.up.on.square") + .tag(SidebarItem.appMigration) Section("Categories") { ForEach(categories) { category in Label(LocalizedStringKey(category.id), systemImage: category.sfSymbol) - .tag(category.id) + .tag(SidebarItem.appCategory(categoryId: category.id)) } } Section("Homebrew") { - NavigationLink(value: "brew", label: { - Label("Manage Homebrew", systemImage: "mug") - }) + Label("Manage Homebrew", systemImage: "mug") + .tag(SidebarItem.brew) } } } diff --git a/Applite/Views/Content View/ContentView.swift b/Applite/Views/Content View/ContentView.swift index de2b780..29cc1e1 100755 --- a/Applite/Views/Content View/ContentView.swift +++ b/Applite/Views/Content View/ContentView.swift @@ -12,8 +12,8 @@ struct ContentView: View { @EnvironmentObject var caskData: CaskData /// Currently selected tab in the sidebar - @State var selection: String = "home" - + @State var selection: SidebarItem = .home + /// App search query @State var searchText = "" /// This variable is set to the value of searchText whenever the user submits the search quiery @@ -30,7 +30,7 @@ struct ContentView: View { var body: some View { NavigationSplitView { - sidebarItems + sidebarViews .disabled(modifyingBrew) } detail: { detailView @@ -44,8 +44,8 @@ struct ContentView: View { .onSubmit(of: .search) { searchTextSubmitted = searchText - if !searchText.isEmpty && selection != "home" { - selection = "home" + if !searchText.isEmpty && selection != .home { + selection = .home } } .onChange(of: searchText) { newSearchText in diff --git a/Applite/Views/Detail Views/AppMigrationView.swift b/Applite/Views/Detail Views/AppMigrationView.swift new file mode 100644 index 0000000..62d4a9b --- /dev/null +++ b/Applite/Views/Detail Views/AppMigrationView.swift @@ -0,0 +1,18 @@ +// +// AppMigrationView.swift +// Applite +// +// Created by Milán Várady on 2024.12.30. +// + +import SwiftUI + +struct AppMigrationView: View { + var body: some View { + Text("App migration view") + } +} + +#Preview { + AppMigrationView() +} diff --git a/Applite/Views/Detail Views/Discover/Discover Section/DiscoverSectionView+CategoryHeader.swift b/Applite/Views/Detail Views/Discover/Discover Section/DiscoverSectionView+CategoryHeader.swift index b9a05f7..66d97f3 100644 --- a/Applite/Views/Detail Views/Discover/Discover Section/DiscoverSectionView+CategoryHeader.swift +++ b/Applite/Views/Detail Views/Discover/Discover Section/DiscoverSectionView+CategoryHeader.swift @@ -17,7 +17,7 @@ extension DiscoverSectionView { .font(.system(size: 24, weight: .bold)) Button("See All") { - navigationSelection = category.id + navigationSelection = .appCategory(categoryId: category.id) } .buttonStyle(.plain) .foregroundColor(.blue) diff --git a/Applite/Views/Detail Views/Discover/Discover Section/DiscoverSectionView.swift b/Applite/Views/Detail Views/Discover/Discover Section/DiscoverSectionView.swift index 9cf9619..40620be 100644 --- a/Applite/Views/Detail Views/Discover/Discover Section/DiscoverSectionView.swift +++ b/Applite/Views/Detail Views/Discover/Discover Section/DiscoverSectionView.swift @@ -9,7 +9,7 @@ import SwiftUI struct DiscoverSectionView: View { let category: Category - @Binding var navigationSelection: String + @Binding var navigationSelection: SidebarItem @EnvironmentObject var caskData: CaskData diff --git a/Applite/Views/Detail Views/Discover/DiscoverView.swift b/Applite/Views/Detail Views/Discover/DiscoverView.swift index b3d6e60..223bdab 100755 --- a/Applite/Views/Detail Views/Discover/DiscoverView.swift +++ b/Applite/Views/Detail Views/Discover/DiscoverView.swift @@ -10,7 +10,7 @@ import Shimmer /// Shows apps in categories struct DiscoverView: View { - @Binding var navigationSelection: String + @Binding var navigationSelection: SidebarItem @State var currentPage: Float = 0 var body: some View { @@ -32,7 +32,7 @@ struct DiscoverView: View { struct DiscoverView_Previews: PreviewProvider { static var previews: some View { - DiscoverView(navigationSelection: .constant("")) + DiscoverView(navigationSelection: .constant(.home)) .environmentObject(CaskData()) } } diff --git a/Applite/Views/Detail Views/Download/DownloadView.swift b/Applite/Views/Detail Views/Download/DownloadView.swift index b8e7ea1..cb67156 100755 --- a/Applite/Views/Detail Views/Download/DownloadView.swift +++ b/Applite/Views/Detail Views/Download/DownloadView.swift @@ -10,7 +10,7 @@ import Fuse /// Download section. Either dispays the `DiscoverView` or search results struct DownloadView: View { - @Binding var navigationSelection: String + @Binding var navigationSelection: SidebarItem @Binding var searchText: String @EnvironmentObject var caskData: CaskData @@ -67,7 +67,7 @@ struct DownloadView: View { struct DownloadView_Previews: PreviewProvider { static var previews: some View { - DownloadView(navigationSelection: .constant(""), searchText: .constant("")) + DownloadView(navigationSelection: .constant(.home), searchText: .constant("")) .environmentObject(CaskData()) } } diff --git a/Localizable.xcstrings b/Localizable.xcstrings index dbc83bd..13aac5f 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -894,6 +894,12 @@ } } } + }, + "App Migration" : { + + }, + "App migration view" : { + }, "Appdir" : { "localizations" : { From 40aee0b408bad8ac06a140136d8daeb5f3a674dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1rady=20Mil=C3=A1n?= <61704770+MilanVarady@users.noreply.github.com> Date: Tue, 31 Dec 2024 13:44:12 +0100 Subject: [PATCH 2/5] Create App Migration view --- Applite.xcodeproj/project.pbxproj | 8 ++ Applite/Extensions/FontExtension.swift | 14 +++ Applite/Model/Cask Models/CaskData.swift | 2 +- Applite/Views/Components/Card.swift | 25 ++++++ .../Views/Detail Views/AppMigrationView.swift | 85 ++++++++++++++++++- .../Detail Views/BrewManagementView.swift | 2 +- Applite/Views/Detail Views/CategoryView.swift | 2 +- .../DiscoverSectionView+CategoryHeader.swift | 6 +- .../Detail Views/Discover/DiscoverView.swift | 2 +- .../DownloadView+NoSearchResults.swift | 2 +- Applite/Views/Setup/SetupView+AllSet.swift | 2 +- .../Views/Setup/SetupView+BrewInstall.swift | 2 +- Applite/Views/Setup/SetupView+Welcome.swift | 2 +- Localizable.xcstrings | 21 ++++- 14 files changed, 161 insertions(+), 14 deletions(-) create mode 100644 Applite/Extensions/FontExtension.swift create mode 100644 Applite/Views/Components/Card.swift diff --git a/Applite.xcodeproj/project.pbxproj b/Applite.xcodeproj/project.pbxproj index f26f72d..7bd3596 100644 --- a/Applite.xcodeproj/project.pbxproj +++ b/Applite.xcodeproj/project.pbxproj @@ -97,6 +97,8 @@ 419256832D22055200D9EF10 /* CaskInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419256822D22055200D9EF10 /* CaskInfo.swift */; }; 4192568A2D22D67400D9EF10 /* SidebarItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419256892D22D67400D9EF10 /* SidebarItem.swift */; }; 4192568C2D22D7FC00D9EF10 /* AppMigrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4192568B2D22D7FC00D9EF10 /* AppMigrationView.swift */; }; + 4192568F2D22DC9E00D9EF10 /* FontExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4192568E2D22DC9E00D9EF10 /* FontExtension.swift */; }; + 419256912D23F93E00D9EF10 /* Card.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419256902D23F93E00D9EF10 /* Card.swift */; }; 419506A42964A27F00FE5802 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419506A32964A27F00FE5802 /* SetupView.swift */; }; 419506A62964A5EF00FE5802 /* BrewPathSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419506A52964A5EF00FE5802 /* BrewPathSelectorView.swift */; }; 4196C8F528F9CB2600EADDDA /* DiscoverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4196C8F428F9CB2600EADDDA /* DiscoverView.swift */; }; @@ -198,6 +200,8 @@ 419256822D22055200D9EF10 /* CaskInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaskInfo.swift; sourceTree = ""; }; 419256892D22D67400D9EF10 /* SidebarItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarItem.swift; sourceTree = ""; }; 4192568B2D22D7FC00D9EF10 /* AppMigrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppMigrationView.swift; sourceTree = ""; }; + 4192568E2D22DC9E00D9EF10 /* FontExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FontExtension.swift; sourceTree = ""; }; + 419256902D23F93E00D9EF10 /* Card.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Card.swift; sourceTree = ""; }; 419506A32964A27F00FE5802 /* SetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupView.swift; sourceTree = ""; }; 419506A52964A5EF00FE5802 /* BrewPathSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrewPathSelectorView.swift; sourceTree = ""; }; 419506A729696A5300FE5802 /* Applite-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "Applite-Info.plist"; sourceTree = SOURCE_ROOT; }; @@ -259,6 +263,7 @@ 4126353D2A77C6EF00155034 /* ArrayExtension.swift */, 411EDDD62A9F58180051E07B /* URLExtension.swift */, 419256092D1C9FF800D9EF10 /* StringExtension.swift */, + 4192568E2D22DC9E00D9EF10 /* FontExtension.swift */, ); path = Extensions; sourceTree = ""; @@ -269,6 +274,7 @@ 419256412D1E072700D9EF10 /* Brew Path Selector */, 415563A32A98C54300AE2F2E /* AppdirSelectorView.swift */, 418989AE2A33B65A004AC23B /* SmallProgressView.swift */, + 419256902D23F93E00D9EF10 /* Card.swift */, 4120AB662A755B0700F68EFE /* Sparkle Updater */, ); path = Components; @@ -732,6 +738,7 @@ 4192565E2D1E155400D9EF10 /* UpdateView+UpdateAllButton.swift in Sources */, 4192568A2D22D67400D9EF10 /* SidebarItem.swift in Sources */, 419256572D1E0E5F00D9EF10 /* DiscoverSectionView+AppRow.swift in Sources */, + 4192568F2D22DC9E00D9EF10 /* FontExtension.swift in Sources */, 419506A42964A27F00FE5802 /* SetupView.swift in Sources */, 4192561E2D1DEBE700D9EF10 /* AppView+UninstallButton.swift in Sources */, 41524B99295E352200D0046A /* SettingsView.swift in Sources */, @@ -781,6 +788,7 @@ 4120AB652A754B1700F68EFE /* AppliteAppView.swift in Sources */, 412635442A77FB1600155034 /* BrewInstallationProgress.swift in Sources */, 419256202D1DEC0D00D9EF10 /* AppView+UpdateButton.swift in Sources */, + 419256912D23F93E00D9EF10 /* Card.swift in Sources */, 419256252D1DF17F00D9EF10 /* SetupView+Welcome.swift in Sources */, 419256082D1C734600D9EF10 /* Shell.swift in Sources */, 4192563E2D1DF3E900D9EF10 /* ContentView+DetailView.swift in Sources */, diff --git a/Applite/Extensions/FontExtension.swift b/Applite/Extensions/FontExtension.swift new file mode 100644 index 0000000..209c905 --- /dev/null +++ b/Applite/Extensions/FontExtension.swift @@ -0,0 +1,14 @@ +// +// FontExtension.swift +// Applite +// +// Created by Milán Várady on 2024.12.30. +// + +import SwiftUI + +extension Font { + public static let appliteLargeTitle: Font = .system(size: 52, weight: .bold) + public static let appliteMediumTitle: Font = .system(size: 42, weight: .bold) + public static let appliteSmallTitle: Font = .system(size: 32, weight: .bold) +} diff --git a/Applite/Model/Cask Models/CaskData.swift b/Applite/Model/Cask Models/CaskData.swift index f371688..f9df9dc 100755 --- a/Applite/Model/Cask Models/CaskData.swift +++ b/Applite/Model/Cask Models/CaskData.swift @@ -6,7 +6,7 @@ // import Foundation -import os +import OSLog /// A dictionary that has cask id's as keys and number of downloads as the values typealias BrewAnalyticsDictionary = [String: Int] diff --git a/Applite/Views/Components/Card.swift b/Applite/Views/Components/Card.swift new file mode 100644 index 0000000..32f75b5 --- /dev/null +++ b/Applite/Views/Components/Card.swift @@ -0,0 +1,25 @@ +// +// Card.swift +// Applite +// +// Created by Milán Várady on 2024.12.31. +// + +import SwiftUI + +/// A reusabe view that adds a rounded rectange background shadow +struct Card: View { + let cardWidth: CGFloat + let cardHeight: CGFloat + let paddig: CGFloat + @ViewBuilder let content: Content + + var body: some View { + content + .padding(paddig) + .frame(width: cardWidth, height: cardHeight) + .background(.regularMaterial) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .shadow(radius: 3) + } +} diff --git a/Applite/Views/Detail Views/AppMigrationView.swift b/Applite/Views/Detail Views/AppMigrationView.swift index 62d4a9b..a0a0be7 100644 --- a/Applite/Views/Detail Views/AppMigrationView.swift +++ b/Applite/Views/Detail Views/AppMigrationView.swift @@ -8,8 +8,91 @@ import SwiftUI struct AppMigrationView: View { + let width: CGFloat = 620 + let columnSpacing: CGFloat = 40 + + var cardWidth: CGFloat { + (width - columnSpacing) / 2 + } + let cardHeight: CGFloat = 210 + let cardPadding: CGFloat = 28 + var body: some View { - Text("App migration view") + VStack { + titleAndDescription + .padding(.vertical, 40) + + HStack(spacing: columnSpacing) { + Card(cardWidth: cardWidth, cardHeight: cardHeight, paddig: cardPadding) { + ExportView() + } + + Card(cardWidth: cardWidth, cardHeight: cardHeight, paddig: cardPadding) { + ImportView() + } + } + + Spacer() + } + .frame(maxWidth: width) + } + + var titleAndDescription: some View { + VStack(alignment: .leading) { + Text("App Migration") + .font(.appliteMediumTitle) + .padding(.bottom, 2) + + Text("Export all of your currently installed apps to a file. Import the file to another device to install them all. Useful when setting up a new Mac.") + } + } + + private struct ExportView: View { + @State var selectedExportFileType: CaskExportType = .txtFile + + var body: some View { + VStack(alignment: .leading) { + Text("Export") + .font(.appliteSmallTitle) + + Button { + + } label: { + Label("Export Apps to File", systemImage: "square.and.arrow.up") + } + .controlSize(.large) + .padding(.bottom, 2) + + Picker("Export file type", selection: $selectedExportFileType) { + ForEach(CaskExportType.allCases) { type in + Text(LocalizedStringKey(type.rawValue)) + } + } + + Spacer() + } + } + } + + private struct ImportView: View { + var body: some View { + VStack(alignment: .leading) { + Text("Import") + .font(.appliteSmallTitle) + + Button { + + } label: { + Label("Import Apps", systemImage: "square.and.arrow.down") + } + .controlSize(.large) + .padding(.bottom, 4) + + Text("**Note:** When importing a Brewfile only casks will be installed. Other items like formulae and taps will be skipped.") + + Spacer() + } + } } } diff --git a/Applite/Views/Detail Views/BrewManagementView.swift b/Applite/Views/Detail Views/BrewManagementView.swift index 4e163ed..daab4bf 100755 --- a/Applite/Views/Detail Views/BrewManagementView.swift +++ b/Applite/Views/Detail Views/BrewManagementView.swift @@ -20,7 +20,7 @@ struct BrewManagementView: View { VStack(alignment: .leading) { // Title Text("Manage Homebrew") - .font(.system(size: 32, weight: .bold)) + .font(.appliteSmallTitle) Text("This application uses the [Homebrew](https://brew.sh/) (brew for short) package manager to download apps. Homebrew is a free and open source command line utility that can download useful developer tools as well as desktop applications.") .padding(.bottom) diff --git a/Applite/Views/Detail Views/CategoryView.swift b/Applite/Views/Detail Views/CategoryView.swift index 0d5a3fe..de0c90e 100755 --- a/Applite/Views/Detail Views/CategoryView.swift +++ b/Applite/Views/Detail Views/CategoryView.swift @@ -17,7 +17,7 @@ struct CategoryView: View { // Category name Group { Text(LocalizedStringKey(category.id)) - .font(.system(size: 42, weight: .bold)) + .font(.appliteMediumTitle) .padding(.bottom, -20) Divider() diff --git a/Applite/Views/Detail Views/Discover/Discover Section/DiscoverSectionView+CategoryHeader.swift b/Applite/Views/Detail Views/Discover/Discover Section/DiscoverSectionView+CategoryHeader.swift index 66d97f3..2593baa 100644 --- a/Applite/Views/Detail Views/Discover/Discover Section/DiscoverSectionView+CategoryHeader.swift +++ b/Applite/Views/Detail Views/Discover/Discover Section/DiscoverSectionView+CategoryHeader.swift @@ -10,11 +10,13 @@ import SwiftUI extension DiscoverSectionView { var categoryHeader: some View { HStack(alignment: .bottom) { + let fontSize: CGFloat = 24 + Image(systemName: category.sfSymbol) - .font(.system(size: 24)) + .font(.system(size: fontSize)) Text(LocalizedStringKey(category.id)) - .font(.system(size: 24, weight: .bold)) + .font(.system(size: fontSize, weight: .bold)) Button("See All") { navigationSelection = .appCategory(categoryId: category.id) diff --git a/Applite/Views/Detail Views/Discover/DiscoverView.swift b/Applite/Views/Detail Views/Discover/DiscoverView.swift index 223bdab..162c241 100755 --- a/Applite/Views/Detail Views/Discover/DiscoverView.swift +++ b/Applite/Views/Detail Views/Discover/DiscoverView.swift @@ -16,7 +16,7 @@ struct DiscoverView: View { var body: some View { LazyVStack(alignment: .leading) { Text("Discover") - .font(.system(size: 52, weight: .bold)) + .font(.appliteLargeTitle) .padding(.bottom) ForEach(categories) { category in diff --git a/Applite/Views/Detail Views/Download/DownloadView+NoSearchResults.swift b/Applite/Views/Detail Views/Download/DownloadView+NoSearchResults.swift index 53e79fa..8441a3d 100644 --- a/Applite/Views/Detail Views/Download/DownloadView+NoSearchResults.swift +++ b/Applite/Views/Detail Views/Download/DownloadView+NoSearchResults.swift @@ -13,7 +13,7 @@ extension DownloadView { HStack { Image(systemName: "magnifyingglass") .foregroundColor(.red) - .font(.system(size: 32)) + .font(.appliteMediumTitle) Text("\"\(searchText)\" didn't match any app. Either it's not available in the Homebrew catalog or you misspelled it.") .font(.system(size: 20)) diff --git a/Applite/Views/Setup/SetupView+AllSet.swift b/Applite/Views/Setup/SetupView+AllSet.swift index ab5a226..d81a7fe 100644 --- a/Applite/Views/Setup/SetupView+AllSet.swift +++ b/Applite/Views/Setup/SetupView+AllSet.swift @@ -14,7 +14,7 @@ extension SetupView { var body: some View { Text("All set!") - .font(.system(size: 52, weight: .bold)) + .font(.appliteLargeTitle) .padding(.top, 40) Button("Start Using \(Bundle.main.appName)") { diff --git a/Applite/Views/Setup/SetupView+BrewInstall.swift b/Applite/Views/Setup/SetupView+BrewInstall.swift index d32c4f1..12d6603 100644 --- a/Applite/Views/Setup/SetupView+BrewInstall.swift +++ b/Applite/Views/Setup/SetupView+BrewInstall.swift @@ -24,7 +24,7 @@ extension SetupView { var body: some View { VStack { Text("Installing dependencies") - .font(.system(size: 32, weight: .bold)) + .font(.appliteSmallTitle) .padding(.vertical) // Xcode Command Line Tools diff --git a/Applite/Views/Setup/SetupView+Welcome.swift b/Applite/Views/Setup/SetupView+Welcome.swift index 0ef5134..291a84e 100644 --- a/Applite/Views/Setup/SetupView+Welcome.swift +++ b/Applite/Views/Setup/SetupView+Welcome.swift @@ -13,7 +13,7 @@ extension SetupView { var body: some View { VStack { Text("Welcome to \(Bundle.main.appName)") - .font(.system(size: 36, weight: .bold)) + .font(.appliteSmallTitle) .padding(.top, 50) .padding(.bottom, 25) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 13aac5f..4425642 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -198,6 +198,9 @@ } } } + }, + "**Note:** When importing a Brewfile only casks will be installed. Other items like formulae and taps will be skipped." : { + }, "**Warning**: Homebrew cache is shared between Homebrew installations. Deleting the cache will remove the cache for all installations!" : { "localizations" : { @@ -897,9 +900,6 @@ }, "App Migration" : { - }, - "App migration view" : { - }, "Appdir" : { "localizations" : { @@ -2309,6 +2309,9 @@ } } } + }, + "Export" : { + }, "Export a file containing all currently installed applications. This can be imported to another device." : { "localizations" : { @@ -2343,6 +2346,9 @@ } } } + }, + "Export all of your currently installed apps to a file. Import the file to another device to install them all. Useful when setting up a new Mac." : { + }, "Export apps to file" : { "localizations" : { @@ -2377,6 +2383,9 @@ } } } + }, + "Export Apps to File" : { + }, "Export failed" : { "localizations" : { @@ -2823,6 +2832,9 @@ } } } + }, + "Import" : { + }, "Import apps" : { "localizations" : { @@ -2857,6 +2869,9 @@ } } } + }, + "Import Apps" : { + }, "Import failed" : { "localizations" : { From 3ea36bd3657119c94007b11e85ef3e68d0d69505 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1rady=20Mil=C3=A1n?= <61704770+MilanVarady@users.noreply.github.com> Date: Wed, 1 Jan 2025 20:13:49 +0100 Subject: [PATCH 3/5] Rework brew actions Move brew actions (install, update, etc.) into CaskManager (formerly CaskData). This way the processes don't run on the views. --- Applite.xcodeproj/project.pbxproj | 54 +++- Applite/AppliteApp.swift | 4 +- .../Cask Manager/CaskLoadError.swift | 31 ++ .../CaskManager+BrewFunctions.swift | 282 ++++++++++++++++++ .../CaskManager+LoadData.swift} | 207 ++++++------- .../CaskManager+RefreshOutdated.swift | 36 +++ .../Cask Manager/CaskManager.swift | 61 ++++ .../Cask Manager/CaskProgressState.swift | 17 ++ .../Cask Manager/CaskTaskError.swift | 26 ++ .../Cask Models/Cask/Cask+BrewFunctions.swift | 270 ----------------- .../Cask/Cask+ProtocolConformances.swift | 24 -- Applite/Model/Cask Models/Cask/Cask.swift | 36 +-- Applite/Model/Categories/Category.swift | 19 +- .../Model/Categories/CategoryViewModel.swift | 17 ++ Applite/Model/SidebarItem.swift | 2 +- .../Utilities/Import Export/ExportCasks.swift | 12 +- .../App View/AppView+DownloadButton.swift | 20 +- .../App View/AppView+OpenAndManageView.swift | 10 +- .../App View/AppView+UninstallButton.swift | 6 +- .../App View/AppView+UpdateButton.swift | 6 +- .../Views/App Views/App View/AppView.swift | 4 +- Applite/Views/App Views/AppGridView.swift | 7 +- .../Content View/ContentView+DetailView.swift | 8 +- .../Content View/ContentView+LoadCasks.swift | 2 +- .../ContentView+SidebarViews.swift | 10 +- Applite/Views/Content View/ContentView.swift | 4 +- .../Views/Detail Views/ActiveTasksView.swift | 9 +- .../Detail Views/BrewManagementView.swift | 4 +- Applite/Views/Detail Views/CategoryView.swift | 20 +- .../DiscoverSectionView+AppRow.swift | 7 +- .../DiscoverSectionView+CategoryHeader.swift | 4 +- .../DiscoverSectionView.swift | 4 +- .../Detail Views/Discover/DiscoverView.swift | 5 +- .../Download/DownloadView+Search.swift | 14 +- .../Detail Views/Download/DownloadView.swift | 6 +- .../Views/Detail Views/InstalledView.swift | 19 +- .../Update/UpdateView+ToolbarItems.swift | 4 +- .../Update/UpdateView+UpdateAllButton.swift | 22 +- .../Detail Views/Update/UpdateView.swift | 18 +- Localizable.xcstrings | 1 + 40 files changed, 716 insertions(+), 596 deletions(-) create mode 100644 Applite/Model/Cask Models/Cask Manager/CaskLoadError.swift create mode 100644 Applite/Model/Cask Models/Cask Manager/CaskManager+BrewFunctions.swift rename Applite/Model/Cask Models/{CaskData.swift => Cask Manager/CaskManager+LoadData.swift} (56%) mode change 100755 => 100644 create mode 100644 Applite/Model/Cask Models/Cask Manager/CaskManager+RefreshOutdated.swift create mode 100755 Applite/Model/Cask Models/Cask Manager/CaskManager.swift create mode 100644 Applite/Model/Cask Models/Cask Manager/CaskProgressState.swift create mode 100644 Applite/Model/Cask Models/Cask Manager/CaskTaskError.swift delete mode 100644 Applite/Model/Cask Models/Cask/Cask+BrewFunctions.swift delete mode 100644 Applite/Model/Cask Models/Cask/Cask+ProtocolConformances.swift create mode 100644 Applite/Model/Categories/CategoryViewModel.swift diff --git a/Applite.xcodeproj/project.pbxproj b/Applite.xcodeproj/project.pbxproj index 7bd3596..3c2c0ec 100644 --- a/Applite.xcodeproj/project.pbxproj +++ b/Applite.xcodeproj/project.pbxproj @@ -48,7 +48,7 @@ 418E9EF42AACD9C000046A58 /* CircularProgress in Frameworks */ = {isa = PBXBuildFile; productRef = 418E9EF32AACD9C000046A58 /* CircularProgress */; }; 418F331C28EB3D540023D76F /* AppGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 418F331B28EB3D540023D76F /* AppGridView.swift */; }; 418F332428EC8BA10023D76F /* Cask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 418F332328EC8BA10023D76F /* Cask.swift */; }; - 418F332628EC921D0023D76F /* CaskData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 418F332528EC921D0023D76F /* CaskData.swift */; }; + 418F332628EC921D0023D76F /* CaskManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 418F332528EC921D0023D76F /* CaskManager.swift */; }; 4191392C29159B5C00F1D75D /* CaskDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4191392B29159B5C00F1D75D /* CaskDTO.swift */; }; 419256062D1C546D00D9EF10 /* askpass.js in Resources */ = {isa = PBXBuildFile; fileRef = 419256052D1C546D00D9EF10 /* askpass.js */; }; 419256082D1C734600D9EF10 /* Shell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419256072D1C734600D9EF10 /* Shell.swift */; }; @@ -91,14 +91,19 @@ 419256622D1E15EA00D9EF10 /* UpdateView+UpdateUnavailable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419256612D1E15EA00D9EF10 /* UpdateView+UpdateUnavailable.swift */; }; 419256642D1E164600D9EF10 /* UpdateView+ToolbarItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419256632D1E164600D9EF10 /* UpdateView+ToolbarItems.swift */; }; 419256682D1E18D100D9EF10 /* AlertManagerViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419256672D1E18D100D9EF10 /* AlertManagerViewModifier.swift */; }; - 4192566B2D1F286B00D9EF10 /* Cask+BrewFunctions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4192566A2D1F286B00D9EF10 /* Cask+BrewFunctions.swift */; }; + 4192566B2D1F286B00D9EF10 /* CaskManager+BrewFunctions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4192566A2D1F286B00D9EF10 /* CaskManager+BrewFunctions.swift */; }; 4192566E2D1F293700D9EF10 /* Cask+LaunchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4192566D2D1F293700D9EF10 /* Cask+LaunchApp.swift */; }; - 419256702D1F299E00D9EF10 /* Cask+ProtocolConformances.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4192566F2D1F299E00D9EF10 /* Cask+ProtocolConformances.swift */; }; 419256832D22055200D9EF10 /* CaskInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419256822D22055200D9EF10 /* CaskInfo.swift */; }; 4192568A2D22D67400D9EF10 /* SidebarItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419256892D22D67400D9EF10 /* SidebarItem.swift */; }; 4192568C2D22D7FC00D9EF10 /* AppMigrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4192568B2D22D7FC00D9EF10 /* AppMigrationView.swift */; }; 4192568F2D22DC9E00D9EF10 /* FontExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4192568E2D22DC9E00D9EF10 /* FontExtension.swift */; }; 419256912D23F93E00D9EF10 /* Card.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419256902D23F93E00D9EF10 /* Card.swift */; }; + 419256942D24255000D9EF10 /* CaskLoadError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419256932D24255000D9EF10 /* CaskLoadError.swift */; }; + 419256962D2430C500D9EF10 /* CaskManager+LoadData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419256952D2430C500D9EF10 /* CaskManager+LoadData.swift */; }; + 419256982D24311B00D9EF10 /* CaskManager+RefreshOutdated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419256972D24311B00D9EF10 /* CaskManager+RefreshOutdated.swift */; }; + 4192569B2D24335900D9EF10 /* CaskTaskError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4192569A2D24335900D9EF10 /* CaskTaskError.swift */; }; + 4192569D2D2433E200D9EF10 /* CaskProgressState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4192569C2D2433E200D9EF10 /* CaskProgressState.swift */; }; + 419256A12D25ACC300D9EF10 /* CategoryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419256A02D25ACC300D9EF10 /* CategoryViewModel.swift */; }; 419506A42964A27F00FE5802 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419506A32964A27F00FE5802 /* SetupView.swift */; }; 419506A62964A5EF00FE5802 /* BrewPathSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419506A52964A5EF00FE5802 /* BrewPathSelectorView.swift */; }; 4196C8F528F9CB2600EADDDA /* DiscoverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4196C8F428F9CB2600EADDDA /* DiscoverView.swift */; }; @@ -151,7 +156,7 @@ 4189CE40293C980E009C836D /* BigButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BigButtonStyle.swift; sourceTree = ""; }; 418F331B28EB3D540023D76F /* AppGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGridView.swift; sourceTree = ""; }; 418F332328EC8BA10023D76F /* Cask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cask.swift; sourceTree = ""; }; - 418F332528EC921D0023D76F /* CaskData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaskData.swift; sourceTree = ""; }; + 418F332528EC921D0023D76F /* CaskManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaskManager.swift; sourceTree = ""; }; 4191392B29159B5C00F1D75D /* CaskDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaskDTO.swift; sourceTree = ""; }; 419256052D1C546D00D9EF10 /* askpass.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = askpass.js; sourceTree = ""; }; 419256072D1C734600D9EF10 /* Shell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Shell.swift; sourceTree = ""; }; @@ -194,14 +199,19 @@ 419256612D1E15EA00D9EF10 /* UpdateView+UpdateUnavailable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UpdateView+UpdateUnavailable.swift"; sourceTree = ""; }; 419256632D1E164600D9EF10 /* UpdateView+ToolbarItems.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UpdateView+ToolbarItems.swift"; sourceTree = ""; }; 419256672D1E18D100D9EF10 /* AlertManagerViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertManagerViewModifier.swift; sourceTree = ""; }; - 4192566A2D1F286B00D9EF10 /* Cask+BrewFunctions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Cask+BrewFunctions.swift"; sourceTree = ""; }; + 4192566A2D1F286B00D9EF10 /* CaskManager+BrewFunctions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CaskManager+BrewFunctions.swift"; sourceTree = ""; }; 4192566D2D1F293700D9EF10 /* Cask+LaunchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Cask+LaunchApp.swift"; sourceTree = ""; }; - 4192566F2D1F299E00D9EF10 /* Cask+ProtocolConformances.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Cask+ProtocolConformances.swift"; sourceTree = ""; }; 419256822D22055200D9EF10 /* CaskInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaskInfo.swift; sourceTree = ""; }; 419256892D22D67400D9EF10 /* SidebarItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarItem.swift; sourceTree = ""; }; 4192568B2D22D7FC00D9EF10 /* AppMigrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppMigrationView.swift; sourceTree = ""; }; 4192568E2D22DC9E00D9EF10 /* FontExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FontExtension.swift; sourceTree = ""; }; 419256902D23F93E00D9EF10 /* Card.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Card.swift; sourceTree = ""; }; + 419256932D24255000D9EF10 /* CaskLoadError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaskLoadError.swift; sourceTree = ""; }; + 419256952D2430C500D9EF10 /* CaskManager+LoadData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CaskManager+LoadData.swift"; sourceTree = ""; }; + 419256972D24311B00D9EF10 /* CaskManager+RefreshOutdated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CaskManager+RefreshOutdated.swift"; sourceTree = ""; }; + 4192569A2D24335900D9EF10 /* CaskTaskError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaskTaskError.swift; sourceTree = ""; }; + 4192569C2D2433E200D9EF10 /* CaskProgressState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaskProgressState.swift; sourceTree = ""; }; + 419256A02D25ACC300D9EF10 /* CategoryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryViewModel.swift; sourceTree = ""; }; 419506A32964A27F00FE5802 /* SetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupView.swift; sourceTree = ""; }; 419506A52964A5EF00FE5802 /* BrewPathSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrewPathSelectorView.swift; sourceTree = ""; }; 419506A729696A5300FE5802 /* Applite-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "Applite-Info.plist"; sourceTree = SOURCE_ROOT; }; @@ -379,8 +389,8 @@ 41483CCA29101C7200BB10C2 /* Cask Models */ = { isa = PBXGroup; children = ( + 419256922D24253800D9EF10 /* Cask Manager */, 419256692D1F284100D9EF10 /* Cask */, - 418F332528EC921D0023D76F /* CaskData.swift */, 419256822D22055200D9EF10 /* CaskInfo.swift */, 4191392B29159B5C00F1D75D /* CaskDTO.swift */, 4166EE7C28F73B2300CE305A /* BrewAnalytics.swift */, @@ -392,6 +402,7 @@ isa = PBXGroup; children = ( 41483CCC29101C9900BB10C2 /* Category.swift */, + 419256A02D25ACC300D9EF10 /* CategoryViewModel.swift */, ); path = Categories; sourceTree = ""; @@ -567,13 +578,25 @@ isa = PBXGroup; children = ( 418F332328EC8BA10023D76F /* Cask.swift */, - 4192566A2D1F286B00D9EF10 /* Cask+BrewFunctions.swift */, 4192566D2D1F293700D9EF10 /* Cask+LaunchApp.swift */, - 4192566F2D1F299E00D9EF10 /* Cask+ProtocolConformances.swift */, ); path = Cask; sourceTree = ""; }; + 419256922D24253800D9EF10 /* Cask Manager */ = { + isa = PBXGroup; + children = ( + 418F332528EC921D0023D76F /* CaskManager.swift */, + 419256952D2430C500D9EF10 /* CaskManager+LoadData.swift */, + 4192566A2D1F286B00D9EF10 /* CaskManager+BrewFunctions.swift */, + 419256972D24311B00D9EF10 /* CaskManager+RefreshOutdated.swift */, + 4192569C2D2433E200D9EF10 /* CaskProgressState.swift */, + 419256932D24255000D9EF10 /* CaskLoadError.swift */, + 4192569A2D24335900D9EF10 /* CaskTaskError.swift */, + ); + path = "Cask Manager"; + sourceTree = ""; + }; 4196C8F628F9CB4100EADDDA /* App Views */ = { isa = PBXGroup; children = ( @@ -741,12 +764,14 @@ 4192568F2D22DC9E00D9EF10 /* FontExtension.swift in Sources */, 419506A42964A27F00FE5802 /* SetupView.swift in Sources */, 4192561E2D1DEBE700D9EF10 /* AppView+UninstallButton.swift in Sources */, + 4192569B2D24335900D9EF10 /* CaskTaskError.swift in Sources */, 41524B99295E352200D0046A /* SettingsView.swift in Sources */, 415563A02A9265CE00AE2F2E /* CaskExportType.swift in Sources */, 41524B9E295FA36E00D0046A /* DebounceObject.swift in Sources */, 419256292D1DF1CF00D9EF10 /* SetupView+BrewPathSelection.swift in Sources */, 415563A22A98BB2500AE2F2E /* ErrorWindowView.swift in Sources */, 4196C8FE28F9E13600EADDDA /* UpdateView.swift in Sources */, + 419256982D24311B00D9EF10 /* CaskManager+RefreshOutdated.swift in Sources */, 41062C992A3A263F00FD48EA /* UninstallSelfView.swift in Sources */, 419256372D1DF34200D9EF10 /* SettingsView+ProxySettings.swift in Sources */, 414074F528DF53E80073EB22 /* AppliteApp.swift in Sources */, @@ -762,23 +787,25 @@ 419256272D1DF1AC00D9EF10 /* SetupView+BrewTypeSelection.swift in Sources */, 4192560A2D1C9FF800D9EF10 /* StringExtension.swift in Sources */, 4191392C29159B5C00F1D75D /* CaskDTO.swift in Sources */, - 419256702D1F299E00D9EF10 /* Cask+ProtocolConformances.swift in Sources */, 413E60C22BBFF98A00978F6A /* AppIconView.swift in Sources */, 418989AD2A33A5C4004AC23B /* BrewManagementView.swift in Sources */, 418989B22A35D651004AC23B /* isBrewPathValid.swift in Sources */, + 419256A12D25ACC300D9EF10 /* CategoryViewModel.swift in Sources */, 419256832D22055200D9EF10 /* CaskInfo.swift in Sources */, 4192560D2D1CA02C00D9EF10 /* ShellError.swift in Sources */, - 4192566B2D1F286B00D9EF10 /* Cask+BrewFunctions.swift in Sources */, + 4192566B2D1F286B00D9EF10 /* CaskManager+BrewFunctions.swift in Sources */, 4192561A2D1DEB2100D9EF10 /* AppView+OpenAndManageView.swift in Sources */, 4192562C2D1DF20600D9EF10 /* SetupView+BrewInstall.swift in Sources */, 41062C952A3794EA00FD48EA /* BrewPaths.swift in Sources */, 4192565B2D1E0ECF00D9EF10 /* DiscoverSectionView+Placeholder.swift in Sources */, 41483CCD29101C9900BB10C2 /* Category.swift in Sources */, + 4192569D2D2433E200D9EF10 /* CaskProgressState.swift in Sources */, 418F331C28EB3D540023D76F /* AppGridView.swift in Sources */, 419256332D1DF2E000D9EF10 /* SettingsView+BrewPath.swift in Sources */, + 419256962D2430C500D9EF10 /* CaskManager+LoadData.swift in Sources */, 419256552D1E0E2500D9EF10 /* DiscoverSectionView+CategoryHeader.swift in Sources */, 41062C972A3A20F900FD48EA /* UninstallSelf.swift in Sources */, - 418F332628EC921D0023D76F /* CaskData.swift in Sources */, + 418F332628EC921D0023D76F /* CaskManager.swift in Sources */, 4178CF922A8689AF0037F270 /* ExportCasks.swift in Sources */, 4192562E2D1DF22500D9EF10 /* SetupView+AllSet.swift in Sources */, 4192568C2D22D7FC00D9EF10 /* AppMigrationView.swift in Sources */, @@ -793,6 +820,7 @@ 419256082D1C734600D9EF10 /* Shell.swift in Sources */, 4192563E2D1DF3E900D9EF10 /* ContentView+DetailView.swift in Sources */, 419256162D1DEA0A00D9EF10 /* AppView+IconAndDescriptionView.swift in Sources */, + 419256942D24255000D9EF10 /* CaskLoadError.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1027,7 +1055,7 @@ repositoryURL = "https://github.com/onevcat/Kingfisher"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 7.11.0; + minimumVersion = 8.1.0; }; }; 4189CE372937CD41009C836D /* XCRemoteSwiftPackageReference "SwiftUI-Shimmer" */ = { diff --git a/Applite/AppliteApp.swift b/Applite/AppliteApp.swift index ccd944b..0c22743 100755 --- a/Applite/AppliteApp.swift +++ b/Applite/AppliteApp.swift @@ -12,7 +12,7 @@ import Kingfisher @main struct AppliteApp: App { - @StateObject var caskData = CaskData() + @StateObject var caskManager = CaskManager() @AppStorage(Preferences.colorSchemePreference.rawValue) var colorSchemePreference: ColorSchemePreference = .system @AppStorage(Preferences.setupComplete.rawValue) var setupComplete: Bool = false @@ -42,7 +42,7 @@ struct AppliteApp: App { WindowGroup { if setupComplete { ContentView() - .environmentObject(caskData) + .environmentObject(caskManager) .frame(minWidth: 970, minHeight: 520) .preferredColorScheme(selectedColorScheme) } else { diff --git a/Applite/Model/Cask Models/Cask Manager/CaskLoadError.swift b/Applite/Model/Cask Models/Cask Manager/CaskLoadError.swift new file mode 100644 index 0000000..dce7813 --- /dev/null +++ b/Applite/Model/Cask Models/Cask Manager/CaskLoadError.swift @@ -0,0 +1,31 @@ +// +// CaskLoadError.swift +// Applite +// +// Created by Milán Várady on 2024.12.31. +// + +import Foundation + +enum CaskLoadError: LocalizedError { + case failedToLoadCategoryJSON + case failedToLoadFromCache + + var errorDescription: String? { + switch self { + case .failedToLoadCategoryJSON: + return "Failed to load categories" + case .failedToLoadFromCache: + return "Failed to load app catalog from cache" + } + } + + var failureReason: String? { + switch self { + case .failedToLoadCategoryJSON: + return "Couldn't load category JSON file" + case .failedToLoadFromCache: + return "The file doesn't exist or couldn't be read" + } + } +} diff --git a/Applite/Model/Cask Models/Cask Manager/CaskManager+BrewFunctions.swift b/Applite/Model/Cask Models/Cask Manager/CaskManager+BrewFunctions.swift new file mode 100644 index 0000000..0fe143b --- /dev/null +++ b/Applite/Model/Cask Models/Cask Manager/CaskManager+BrewFunctions.swift @@ -0,0 +1,282 @@ +// +// Cask+BrewFunctions.swift +// Applite +// +// Created by Milán Várady on 2024.12.27. +// + +import Foundation + +extension CaskManager { + /// Installs the cask + /// + /// - Parameters: + /// - caskManager: ``CaskData`` object passed in by the view + /// - force: If `true` install will be run with the `--force` flag + func install(_ cask: Cask, force: Bool = false) { + runTask(for: cask) { + Self.logger.info("Cask \"\(cask.id)\" installation started") + + // Appdir argument + let appdirOn = UserDefaults.standard.bool(forKey: Preferences.appdirOn.rawValue) + let appdirPath = UserDefaults.standard.string(forKey: Preferences.appdirPath.rawValue) + let appdirArgument = "--appdir=\"\(appdirPath ?? "/Applications")\"" + + // Install command + var arguments = [cask.id] + if force { arguments.append("--force") } + if appdirOn { arguments.append(appdirArgument) } + + let command = "\(BrewPaths.currentBrewExecutable) install --cask \(arguments.joined(separator: " "))" + + // Setup progress + cask.progressState = .busy(withTask: "") + + /// Holds the complete output of the install process + var completeOutput = "" + + // Run install command and stream output + do { + for try await line in Shell.stream(command, pty: true) { + completeOutput += line + + let newProgress = self.parseBrewInstall(output: line) + cask.progressState = newProgress + } + } catch { + let alertMessage = switch completeOutput { + // Already installed + case _ where completeOutput.contains("It seems there is already an App"): + String(localized: "\(cask.info.name) is already installed. If you want to add it to \(Bundle.main.appName) click more options (chevron icon) and press Force Install.") + // Network error + case _ where completeOutput.contains("Could not resolve host"): + String(localized: "Couldn't download app. No internet connection, or host is unreachable.") + default: + error.localizedDescription + } + + await self.showFailure( + for: cask, + error: error, + output: completeOutput, + alertTitle: "Failed to install \(cask.info.name)", + alertMessage: alertMessage + ) + return + } + + await self.showSuccess( + for: cask, + logMessage: "Successfully installed cask \(cask.id)", + alertTitle: "\(cask.info.name) successfully installed!" + ) + + // Update state + cask.isInstalled = true + self.installedCasks.insert(cask) + } + } + + /// Uninstalls the cask + /// - Parameters: + /// - caskManager: ``CaskData`` object + /// - zap: If true the app will be uninstalled completely using the brew --zap flag + func uninstall(_ cask: Cask, zap: Bool = false) { + runTask(for: cask) { + cask.progressState = .busy(withTask: "Uninstalling") + + var arguments: [String] = [cask.info.id] + + // Add -- zap argument + if zap { + arguments.append("--zap") + } + + var output: String = "" + + do { + output = try await Shell.runBrewCommand("uninstall", arguments: arguments) + } catch { + await self.showFailure( + for: cask, + error: error, + output: output, + alertTitle: "Failed to uninstall \(cask.info.name)", + alertMessage: error.localizedDescription + ) + return + } + + await self.showSuccess( + for: cask, + logMessage: "Successfully uninstalled \(cask.info.id)", + alertTitle: "\(cask.info.name) successfully uninstalled" + ) + + // Update state + cask.isInstalled = false + self.installedCasks.remove(cask) + } + } + + /// Updates the cask + func update(_ cask: Cask) { + runTask(for: cask) { + cask.progressState = .busy(withTask: "Updating") + + var output: String = "" + + do { + output = try await Shell.runBrewCommand("upgrade", arguments: [cask.info.id]) + } catch { + await self.showFailure( + for: cask, + error: error, + output: output, + alertTitle: "Failed to update \(cask.info.name)", + alertMessage: error.localizedDescription + ) + return + } + + await self.showSuccess( + for: cask, + logMessage: "Successfully updated \(cask.id)", + alertTitle: "\(cask.info.name) successfully updated" + ) + + // Update state + self.outdatedCasks.remove(cask) + } + } + + /// Reinstalls the cask + func reinstall(_ cask: Cask) { + runTask(for: cask) { + cask.progressState = .busy(withTask: "Reinstalling") + + var output: String = "" + + do { + output = try await Shell.runBrewCommand("reinstall", arguments: [cask.info.id]) + } catch { + await self.showFailure( + for: cask, + error: error, + output: output, + alertTitle: "Failed to reinstall \(cask.info.name)", + alertMessage: error.localizedDescription + ) + return + } + + await self.showSuccess( + for: cask, + logMessage: "Successfully reinstalled \(cask.info.id)", + alertTitle: "\(cask.info.name) successfully reinstalled" + ) + } + } + + /// Update all outdated casks + func updateAll(_ casks: [Cask]) { + for cask in casks { + self.update(cask) + } + } + + // MARK: - Helper functions + + /// Starts a brew task and appends it to active tasks + private func runTask(for cask: Cask, _ operation: @escaping () async -> Void) { + let task = Task { + defer { + self.activeTasks.removeAll { + $0.cask == cask + } + } + + // Make sure if brew path is valid + guard await BrewPaths.isSelectedBrewPathValid() else { + Self.logger.error("Couln't start brew operation because brew path is invalid") + alert.show(title: "Brew path is invalid", message: DependencyManager.brokenPathOrIstallMessage) + return + } + + await operation() + } + + self.activeTasks.append((cask: cask, task: task)) + } + + /// Parses the shell output when installing a cask + private func parseBrewInstall(output: String) -> CaskProgressState { + if output.contains("Downloading") { + return .busy(withTask: "") + } else if output.contains("#") { + let regex = /#+\s+(\d+\.\d+)%/ + + if let result = output.firstMatch(of: regex) { + return .downloading(percent: (Double(result.1) ?? 0) / 100) + } + } + else if output.contains("Installing") || output.contains("Moving") || output.contains("Linking") { + return .busy(withTask: String(localized: "Installing")) + } + else if output.contains("successfully installed") { + return .success + } + + return .busy(withTask: "") + } + + /// Register successful task + /// + /// - Logs success + /// - Sends notification + /// - Sets progress state to success for 2 seconds + private func showSuccess( + for cask: Cask, + logMessage: String, + alertTitle: String, + alertMessage: String = "" + ) async { + Self.logger.info("\(logMessage)") + + // Show success for 2 seconds + cask.progressState = .success + try? await Task.sleep(for: .seconds(2)) + cask.progressState = .idle + + await sendNotification(title: alertTitle, body: alertMessage, reason: .success) + } + + /// Register failed task + /// + /// - Logs error + /// - Shows alert and notification + /// - Sets progress state to failed + private func showFailure( + for cask: Cask, + error: Error, + output: String, + alertTitle: String, + alertMessage: String, + notificationTitle: String? = nil, + notificationMessage: String = "" + ) async { + // Log error + Self.logger.error("\(alertTitle)\nError: \(error.localizedDescription)\nOutput: \(output)") + + // Alert + alert.show(title: alertTitle, message: alertMessage) + + // Send notification + let notificationTitle = notificationTitle ?? alertTitle + + // Set progress state to failed + cask.progressState = .failed(output: output) + + await sendNotification(title: notificationTitle, body: notificationMessage, reason: .failure) + } +} diff --git a/Applite/Model/Cask Models/CaskData.swift b/Applite/Model/Cask Models/Cask Manager/CaskManager+LoadData.swift old mode 100755 new mode 100644 similarity index 56% rename from Applite/Model/Cask Models/CaskData.swift rename to Applite/Model/Cask Models/Cask Manager/CaskManager+LoadData.swift index f9df9dc..d3ef339 --- a/Applite/Model/Cask Models/CaskData.swift +++ b/Applite/Model/Cask Models/Cask Manager/CaskManager+LoadData.swift @@ -1,45 +1,25 @@ // -// CaskData.swift +// CaskManager+LoadData.swift // Applite // -// Created by Milán Várady on 2022. 10. 04.. +// Created by Milán Várady on 2024.12.31. // import Foundation -import OSLog - -/// A dictionary that has cask id's as keys and number of downloads as the values -typealias BrewAnalyticsDictionary = [String: Int] - -/// Gathers data from Homebrew API and local sources and combines them concurrently into ``Cask`` objects -@MainActor -final class CaskData: ObservableObject { - @Published var casks: [Cask] = [] - @Published var busyCasks: Set = [] - @Published var outdatedCasks: Set = [] - + +extension CaskManager { + // URLs private static let cacheDirectory = URL.cachesDirectory .appendingPathComponent(Bundle.main.appName, conformingTo: .directory) - + private static let caskCacheURL = URL.cachesDirectory .appendingPathComponent(Bundle.main.appName, conformingTo: .directory) .appendingPathComponent("cask.json", conformingTo: .json) - + private static let analyicsCacheURL = URL.cachesDirectory .appendingPathComponent(Bundle.main.appName, conformingTo: .directory) .appendingPathComponent("caskAnalytics.json", conformingTo: .json) - - private static let logger = Logger( - subsystem: Bundle.main.bundleIdentifier!, - category: String(describing: CaskData.self) - ) - - typealias CaskCategoryDict = [String: [Cask]] - typealias CoupledCaskCategoryDict = [String: [[Cask]]] - - var casksByCategory: CaskCategoryDict = [:] - var casksByCategoryCoupled: CoupledCaskCategoryDict = [:] - + /// Gathers all necessary information and combines them to a list of ``Cask`` objects /// - Returns: Void func loadData() async throws -> Void { @@ -49,36 +29,36 @@ final class CaskData: ObservableObject { func loadCaskInfo() async throws -> [CaskInfo] { // Get json data from api guard let casksURL = URL(string: "https://formulae.brew.sh/api/cask.json") else { return [] } - - let caskData: Data + + let caskManager: Data let sessionConfiguration = NetworkProxyManager.getURLSessionConfiguration() let urlSession = URLSession(configuration: sessionConfiguration) do { - (caskData, _) = try await urlSession.data(from: casksURL) + (caskManager, _) = try await urlSession.data(from: casksURL) } catch { await Self.logger.error("Couldn't get cask data from brew API. Error: \(error.localizedDescription)") // Try to load from cache await Self.logger.notice("Attempting to load cask data from cache") - caskData = try await loadDataFromCache(dataURL: Self.caskCacheURL) + caskManager = try await loadDataFromCache(dataURL: Self.caskCacheURL) } - + // Chache json file - await cacheData(data: caskData, to: Self.caskCacheURL) - + await cacheData(data: caskManager, to: Self.caskCacheURL) + // Decode static cask data - return try JSONDecoder().decode([CaskInfo].self, from: caskData) + return try JSONDecoder().decode([CaskInfo].self, from: caskManager) } - + /// Gets cask analytics information from the Homebrew API and decodes it into a dictionary /// - Returns: A Cask ID to download count dictionary @Sendable func loadAnalyticsData() async throws -> BrewAnalyticsDictionary { // Get json data from api guard let analyticsURL = URL(string: "https://formulae.brew.sh/api/analytics/cask-install/365d.json") else { return [:] } - + let analyticsData: Data let sessionConfiguration = NetworkProxyManager.getURLSessionConfiguration() @@ -88,28 +68,28 @@ final class CaskData: ObservableObject { (analyticsData, _) = try await urlSession.data(from: analyticsURL) } catch { await Self.logger.error("Couldn't get analytics data from brew API. Error: \(error.localizedDescription)") - + // Try to load from cache await Self.logger.notice("Attempting to load analytics data from cache") analyticsData = try await loadDataFromCache(dataURL: Self.analyicsCacheURL) } - + // Chache json file await cacheData(data: analyticsData, to: Self.analyicsCacheURL) - + let analyticsDecoded: BrewAnalytics - + // Decode data analyticsDecoded = try JSONDecoder().decode(BrewAnalytics.self, from: analyticsData) - + // Convert analytics to a cask ID to download count dictionary let analyticsDict: BrewAnalyticsDictionary = Dictionary(uniqueKeysWithValues: analyticsDecoded.items.map { ($0.cask, Int($0.count.replacingOccurrences(of: ",", with: "")) ?? 0) }) - + return analyticsDict } - + /// Gets the list of installed casks /// - Returns: A list of Cask ID's @Sendable @@ -119,23 +99,12 @@ final class CaskData: ObservableObject { if output.isEmpty { await Self.logger.notice("No installed casks were found") } - - return output - .trimmingCharacters(in: .whitespacesAndNewlines) - .components(separatedBy: "\n") - } - - /// Gets the list of outdated casks - /// - Returns: A list of Cask ID's - @Sendable - func getOutdatedCasks() async throws -> [String] { - let output = try await Shell.runAsync("\(BrewPaths.currentBrewExecutable) outdated --cask -q") return output .trimmingCharacters(in: .whitespacesAndNewlines) .components(separatedBy: "\n") } - + /// Saves ``Data`` objects to cache /// /// - Parameters: @@ -147,7 +116,7 @@ final class CaskData: ObservableObject { // Create cache directory if doesn't exists do { var isDirectory: ObjCBool = true - + if await !FileManager.default.fileExists(atPath: Self.cacheDirectory.path, isDirectory: &isDirectory) { await Self.logger.warning("Cache directory doesn't exists, attempting to create it") try await FileManager.default.createDirectory(at: Self.cacheDirectory, withIntermediateDirectories: false) @@ -156,7 +125,7 @@ final class CaskData: ObservableObject { await Self.logger.error("Cound't create cache directory") return } - + // Save data to cache do { try data.write(to: filePath) @@ -164,92 +133,86 @@ final class CaskData: ObservableObject { await Self.logger.error("Couldn't write data to cache") } } - + /// Loads data from cache /// - Returns: A ``Data`` object @Sendable func loadDataFromCache(dataURL: URL) async throws -> Data { - let data = try Data(contentsOf: dataURL) - - return data - } - - /// Filters casks into a category to casks dictionary - /// - Returns: A tuple of two dictionaries, the first is just a category to casks dict, the second is the same but chunked into two for the discover view - func fillCategoryDicts() -> (CaskCategoryDict, CoupledCaskCategoryDict) { - var categoryDict: CaskCategoryDict = [:] - - for category in categories { - // Filter casks - let filteredCasks = casks.filter { - category.casks.contains($0.info.id) - } - - // Sort by number of downloads - categoryDict[category.id] = filteredCasks.sorted(by: { $0.downloadsIn365days > $1.downloadsIn365days }) - } - - var coupledCategoryDict: CoupledCaskCategoryDict = [:] - - for (categoryID, cask) in categoryDict { - let chunkedCasks = cask.chunked(into: 2) - - coupledCategoryDict[categoryID] = chunkedCasks + do { + return try Data(contentsOf: dataURL) + } catch { + throw CaskLoadError.failedToLoadFromCache } - - return (categoryDict, coupledCategoryDict) } - + + func loadCategoryJSONAsync() async throws -> [Category] { + try loadCategoryJSON() + } + // Get data components concurrently + async let categories = loadCategoryJSONAsync() async let caskInfo = loadCaskInfo() async let analyticsDict = loadAnalyticsData() async let installedCasks = getInstalledCasks() - async let outdatedCaskIDs = getOutdatedCasks() - var casks: [Cask] = [] + // Set casks reseve capacity for better performance + await self.casks.reserveCapacity(try caskInfo.count) + + // Casks by category + var categoryDict: [CategoryId: [Cask]] = [:] for caskInfo in try await caskInfo { + let isInstalled = try await installedCasks.contains(caskInfo.id) + let cask = Cask( info: caskInfo, downloadsIn365days: try await analyticsDict[caskInfo.id] ?? 0, - isInstalled: try await installedCasks.contains(caskInfo.id), - isOutdated: try await outdatedCaskIDs.contains(caskInfo.id) + isInstalled: isInstalled ) - casks.append(cask) + casks[cask.id] = cask + + if isInstalled { + self.installedCasks.insert(cask) + } + + // Add to category if needed + for category in try await categories { + // Add to category + if category.casks.contains(cask.id) { + if let casksInCategory = categoryDict[category.id] { + categoryDict[category.id] = casksInCategory + [cask] + } else { + categoryDict[category.id] = [cask] + } + } + } } - self.casks = casks + Self.logger.info("Compiling categories") - Self.logger.info("Cask data loaded successfully!") - - // Create category dicts - (casksByCategory, casksByCategoryCoupled) = fillCategoryDicts() - } + var categoryViewModels: [CategoryViewModel] = [] + + // Make category view models + for category in try await categories { + if let casksInCategory = categoryDict[category.id] { + let casks = casksInCategory.sorted(by: { $0.downloadsIn365days > $1.downloadsIn365days }) + let chunkedCasks = casks.chunked(into: 2) - func refreshOutdatedApps(greedy: Bool = false) async throws -> Void { - let output = try await Shell.runAsync("\(BrewPaths.currentBrewExecutable) outdated --cask \(greedy ? "-g" : "") -q") - - let outdatedCaskIDs = output - .trimmingCharacters(in: .whitespacesAndNewlines) - .components(separatedBy: "\n") - .filter({ !$0.isEmpty }) // Remove empty strings - .map({ $0.trimmingCharacters(in: .whitespacesAndNewlines) }) // Trim whitespace - - for i in self.casks.indices { - if outdatedCaskIDs.contains(self.casks[i].info.id) && self.casks[i].isInstalled { - self.casks[i].isOutdated = true - outdatedCasks.insert(casks[i]) + categoryViewModels.append( + CategoryViewModel( + name: category.id, + sfSymbol: category.sfSymbol, + casks: casks, + casksCoupled: chunkedCasks) + ) } } - - Self.logger.info("Outdated apps refreshed") - } - - /// Filters busy casks - func filterBusyCasks() { - self.busyCasks = self.busyCasks.filter { - $0.progressState != .idle - } + + self.categories = categoryViewModels + + Self.logger.info("Cask data loaded successfully!") + + try await self.refreshOutdated() } } diff --git a/Applite/Model/Cask Models/Cask Manager/CaskManager+RefreshOutdated.swift b/Applite/Model/Cask Models/Cask Manager/CaskManager+RefreshOutdated.swift new file mode 100644 index 0000000..c26e744 --- /dev/null +++ b/Applite/Model/Cask Models/Cask Manager/CaskManager+RefreshOutdated.swift @@ -0,0 +1,36 @@ +// +// CaskManager+RefreshOutdated.swift +// Applite +// +// Created by Milán Várady on 2024.12.31. +// + +import Foundation + +extension CaskManager { + func refreshOutdated(greedy: Bool = false) async throws -> Void { + var arguments: [String] = ["-q"] + + if greedy { + arguments.append("-g") + } + + let output = try await Shell.runBrewCommand("outdated", arguments: arguments) + + let outdatedCaskIDs = output + .trimmingCharacters(in: .whitespacesAndNewlines) + .components(separatedBy: .newlines) + .filter({ !$0.isEmpty }) // Remove empty strings + .map({ $0.trimmingCharacters(in: .whitespacesAndNewlines) }) // Trim whitespace + + self.outdatedCasks.removeAll() + + for caskID in outdatedCaskIDs { + if let cask = self.casks[caskID] { + self.outdatedCasks.insert(cask) + } + } + + Self.logger.info("Outdated apps refreshed") + } +} diff --git a/Applite/Model/Cask Models/Cask Manager/CaskManager.swift b/Applite/Model/Cask Models/Cask Manager/CaskManager.swift new file mode 100755 index 0000000..8e392ea --- /dev/null +++ b/Applite/Model/Cask Models/Cask Manager/CaskManager.swift @@ -0,0 +1,61 @@ +// +// CaskManager.swift +// Applite +// +// Created by Milán Várady on 2022. 10. 04.. +// + +import Foundation +import OSLog + +typealias CaskId = String +typealias BrewAnalyticsDictionary = [CaskId: Int] +typealias BrewTask = (cask: Cask, task: Task) + +/// Holds all cask data and provides methods to take actions on them (e.g. install, update) +@MainActor +final class CaskManager: ObservableObject { + /// Cask view models + @Published var casks: [CaskId: Cask] = [:] + /// All currently running brew tasks + @Published var activeTasks: [BrewTask] = [] + @Published var installedCasks: Set = [] + @Published var outdatedCasks: Set = [] + + @Published var alert = AlertManager() + + // Precompiled cask category dicts + var categories: [CategoryViewModel] = [] + + static let logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: String(describing: CaskManager.self) + ) + + init() { + // Load categories at init so the view can display them + do { + let categories = try loadCategoryJSON() + let categoryViewModels = categories.map { + CategoryViewModel(name: $0.id, sfSymbol: $0.sfSymbol, casks: [], casksCoupled: []) + } + + self.categories = categoryViewModels + } catch { + self.alert.show(title: "Couldn't load categories") + Self.logger.error("Failed to load categories: \(error.localizedDescription)") + } + } + + func loadCategoryJSON() throws -> [Category] { + let decoder = JSONDecoder() + guard let url = Bundle.main.url(forResource: "categories", withExtension: "json") else { + throw CaskLoadError.failedToLoadCategoryJSON + } + + let data = try Data(contentsOf: url) + let categories = try decoder.decode([Category].self, from: data) + + return categories + } +} diff --git a/Applite/Model/Cask Models/Cask Manager/CaskProgressState.swift b/Applite/Model/Cask Models/Cask Manager/CaskProgressState.swift new file mode 100644 index 0000000..024cba3 --- /dev/null +++ b/Applite/Model/Cask Models/Cask Manager/CaskProgressState.swift @@ -0,0 +1,17 @@ +// +// CaskProgressState.swift +// Applite +// +// Created by Milán Várady on 2024.12.31. +// + +import Foundation + +/// Cask progress state when installing, updating or uninstalling +enum CaskProgressState: Equatable, Hashable { + case idle + case busy(withTask: String) + case downloading(percent: Double) + case success + case failed(output: String) +} diff --git a/Applite/Model/Cask Models/Cask Manager/CaskTaskError.swift b/Applite/Model/Cask Models/Cask Manager/CaskTaskError.swift new file mode 100644 index 0000000..c239ffb --- /dev/null +++ b/Applite/Model/Cask Models/Cask Manager/CaskTaskError.swift @@ -0,0 +1,26 @@ +// +// CaskTaskError.swift +// Applite +// +// Created by Milán Várady on 2024.12.31. +// + +import Foundation + +enum CaskTaskError: LocalizedError { + case failedToUpdateProgress + + var errorDescription: String? { + switch self { + case .failedToUpdateProgress: + return "Failed to update progress state" + } + } + + var failureReason: String? { + switch self { + case .failedToUpdateProgress: + return "The cask isn't present" + } + } +} diff --git a/Applite/Model/Cask Models/Cask/Cask+BrewFunctions.swift b/Applite/Model/Cask Models/Cask/Cask+BrewFunctions.swift deleted file mode 100644 index 407d6ce..0000000 --- a/Applite/Model/Cask Models/Cask/Cask+BrewFunctions.swift +++ /dev/null @@ -1,270 +0,0 @@ -// -// Cask+BrewFunctions.swift -// Applite -// -// Created by Milán Várady on 2024.12.27. -// - -import Foundation - -extension Cask { - /// Installs the cask - /// - /// - Parameters: - /// - caskData: ``CaskData`` object passed in by the view - /// - force: If `true` install will be run with the `--force` flag - func install(caskData: CaskData, force: Bool = false) async { - defer { - resetProgressState(caskData: caskData) - } - - Self.logger.info("Cask \"\(self.info.id)\" installation started") - - // Appdir argument - let appdirOn = UserDefaults.standard.bool(forKey: Preferences.appdirOn.rawValue) - let appdirPath = UserDefaults.standard.string(forKey: Preferences.appdirPath.rawValue) - let appdirArgument = "--appdir=\"\(appdirPath ?? "/Applications")\"" - - // Install command - let command = "\(BrewPaths.currentBrewExecutable) install --cask \(force ? "--force" : "") \(self.id) \(appdirOn ? appdirArgument : "")" - - // Setup progress - self.progressState = .busy(withTask: "") - caskData.busyCasks.insert(self) - - /// Holds the complete output of the install process - var completeOutput = "" - - // Run install command and stream output - do { - for try await line in Shell.stream(command, pty: true) { - completeOutput += line - self.progressState = self.parseBrewInstall(output: line) - } - } catch { - let alertMessage = switch completeOutput { - // Already installed - case _ where completeOutput.contains("It seems there is already an App"): - String(localized: "\(self.info.name) is already installed. If you want to add it to \(Bundle.main.appName) click more options (chevron icon) and press Force Install.") - // Network error - case _ where completeOutput.contains("Could not resolve host"): - String(localized: "Couldn't download app. No internet connection, or host is unreachable.") - default: - error.localizedDescription - } - - showFailure( - error: error, - output: completeOutput, - alertTitle: "Failed to install \(self.info.name)", - alertMessage: alertMessage - ) - return - } - - showSuccess( - logMessage: "Successfully installed cask \(self.id)", - alertTitle: "\(self.info.name) successfully installed!" - ) - - // Update state - self.isInstalled = true - } - - /// Uninstalls the cask - /// - Parameters: - /// - caskData: ``CaskData`` object - /// - zap: If true the app will be uninstalled completely using the brew --zap flag - func uninstall(caskData: CaskData, zap: Bool = false) async { - defer { - resetProgressState(caskData: caskData) - } - - progressState = .busy(withTask: "Uninstalling") - caskData.busyCasks.insert(self) - - var arguments: [String] = [self.info.id] - - // Add -- zap argument - if zap { - arguments.append("--zap") - } - - var output: String = "" - - do { - output = try await Shell.runBrewCommand("uninstall", arguments: arguments) - } catch { - showFailure( - error: error, - output: output, - alertTitle: "Failed to uninstall \(self.info.name)", - alertMessage: error.localizedDescription - ) - return - } - - showSuccess( - logMessage: "Successfully uninstalled \(self.info.id)", - alertTitle: "\(self.info.name) successfully uninstalled" - ) - - // Update state - self.isInstalled = false - } - - /// Updates the cask - func update(caskData: CaskData) async { - defer { - resetProgressState(caskData: caskData) - } - - progressState = .busy(withTask: "Updating") - caskData.busyCasks.insert(self) - - var output: String = "" - - do { - output = try await Shell.runBrewCommand("uninstall", arguments: [self.info.id]) - } catch { - showFailure( - error: error, - output: output, - alertTitle: "Failed to update \(self.info.name)", - alertMessage: error.localizedDescription - ) - return - } - - showSuccess( - logMessage: "Successfully updated \(self.id)", - alertTitle: "\(self.info.name) successfully updated" - ) - - // Update state - caskData.outdatedCasks.remove(self) - } - - /// Reinstalls the cask - func reinstall(caskData: CaskData) async { - defer { - resetProgressState(caskData: caskData) - } - - progressState = .busy(withTask: "Reinstalling") - caskData.busyCasks.insert(self) - - var output: String = "" - - do { - output = try await Shell.runBrewCommand("uninstall", arguments: [self.info.id]) - } catch { - showFailure( - error: error, - output: output, - alertTitle: "Failed to reinstall \(self.info.name)", - alertMessage: error.localizedDescription - ) - return - } - - showSuccess( - logMessage: "Successfully reinstalled \(self.info.id)", - alertTitle: "\(self.info.name) successfully reinstalled" - ) - } - - // MARK: - Helper functions - - /// Parses the shell output when installing a cask - private func parseBrewInstall(output: String) -> ProgressState { - if output.contains("Downloading") { - return .busy(withTask: "") - } else if output.contains("#") { - let regex = /#+\s+(\d+\.\d+)%/ - - if let result = output.firstMatch(of: regex) { - return .downloading(percent: (Double(result.1) ?? 0) / 100) - } - } - else if output.contains("Installing") || output.contains("Moving") || output.contains("Linking") { - return .busy(withTask: String(localized: "Installing")) - } - else if output.contains("successfully installed") { - return .success - } - - return .busy(withTask: "") - } - - /// Register successful task - /// - /// - Logs success - /// - Sends notification - /// - Sets progress state to success for 2 seconds - private func showSuccess( - logMessage: String, - alertTitle: String, - alertMessage: String = "" - ) { - Self.logger.info("\(logMessage)") - - Task { - await sendNotification(title: alertTitle, body: alertMessage, reason: .success) - } - - // Show success for 2 seconds - progressState = .success - Task { - try? await Task.sleep(for: .seconds(2)) - progressState = .idle - } - } - - /// Register failed task - /// - /// - Logs error - /// - Shows alert and notification - /// - Sets progress state to failed - private func showFailure( - error: Error, - output: String, - alertTitle: String, - alertMessage: String, - notificationTitle: String? = nil, - notificationMessage: String = "" - ) { - // Log error - Self.logger.error("\(alertTitle)\nError: \(error.localizedDescription)\nOutput: \(output)") - - // Alert - alert.show(title: alertTitle, message: alertMessage) - - // Send notification - let notificationTitle = notificationTitle ?? alertTitle - - Task { - await sendNotification(title: notificationTitle, body: notificationMessage, reason: .failure) - } - - // Set progress state to failed - progressState = .failed(output: output) - } - - /// Resets progress state and removes self from ``CaskData.busyCasks`` - private func resetProgressState(caskData: CaskData) { - Task { - caskData.busyCasks.remove(self) - - // Reset state unless it's not succes or failed - switch self.progressState { - case .success: - break - case .failed(_): - break - default: - self.progressState = .idle - } - } - } -} diff --git a/Applite/Model/Cask Models/Cask/Cask+ProtocolConformances.swift b/Applite/Model/Cask Models/Cask/Cask+ProtocolConformances.swift deleted file mode 100644 index d894c97..0000000 --- a/Applite/Model/Cask Models/Cask/Cask+ProtocolConformances.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// Cask+ProtocolConformances.swift -// Applite -// -// Created by Milán Várady on 2024.12.27. -// - -import Foundation - -extension Cask { - nonisolated var id: String { - self.info.id - } - - // Equatable - nonisolated static func == (lhs: Cask, rhs: Cask) -> Bool { - lhs.id == rhs.id - } - - // Hashable - nonisolated func hash(into hasher: inout Hasher) { - hasher.combine(self.id) - } -} diff --git a/Applite/Model/Cask Models/Cask/Cask.swift b/Applite/Model/Cask Models/Cask/Cask.swift index 6a1077a..55e4fa7 100755 --- a/Applite/Model/Cask Models/Cask/Cask.swift +++ b/Applite/Model/Cask Models/Cask/Cask.swift @@ -17,35 +17,21 @@ final class Cask: ObservableObject, Identifiable, Hashable { /// Number of downloads in the last 365 days let downloadsIn365days: Int - // MARK: - Published properties - + // MARK: Published properties @Published var isInstalled: Bool = false - @Published var isOutdated: Bool = false /// Progress state of the cask when installing, updating or uninstalling - @Published var progressState: ProgressState = .idle - - @Published var alert = AlertManager() + @Published var progressState: CaskProgressState = .idle static let logger = Logger( subsystem: Bundle.main.bundleIdentifier!, category: String(describing: Cask.self) ) - /// Cask progress state when installing, updating or uninstalling - enum ProgressState: Equatable, Hashable { - case idle - case busy(withTask: String) - case downloading(percent: Double) - case success - case failed(output: String) - } - - required init(info: CaskInfo, downloadsIn365days: Int, isInstalled: Bool = false, isOutdated: Bool = false) { + required init(info: CaskInfo, downloadsIn365days: Int, isInstalled: Bool = false) { self.info = info self.downloadsIn365days = downloadsIn365days self.isInstalled = isInstalled - self.isOutdated = isOutdated } static let dummy = Cask(info: CaskInfo( @@ -56,4 +42,20 @@ final class Cask: ObservableObject, Identifiable, Hashable { caveats: nil, pkgInstaller: false ), downloadsIn365days: 100) + + // MARK: - Protocols + + nonisolated var id: String { + self.info.id + } + + // Equatable + nonisolated static func == (lhs: Cask, rhs: Cask) -> Bool { + lhs.id == rhs.id + } + + // Hashable + nonisolated func hash(into hasher: inout Hasher) { + hasher.combine(self.id) + } } diff --git a/Applite/Model/Categories/Category.swift b/Applite/Model/Categories/Category.swift index 90041b8..9d5e8f8 100755 --- a/Applite/Model/Categories/Category.swift +++ b/Applite/Model/Categories/Category.swift @@ -7,29 +7,14 @@ import Foundation -/// Holds the app categories -let categories: [Category] = loadLocalJson(fileName: "categories") +typealias CategoryId = String /// App category object struct Category: Decodable, Identifiable { /// Category id let id: String /// List of cask ids - let casks: [String] + let casks: [CaskId] /// SF Symbol of the category let sfSymbol: String } - -/// Loads a json from resources -fileprivate func loadLocalJson(fileName: String) -> [Category] { - let decoder = JSONDecoder() - guard - let url = Bundle.main.url(forResource: fileName, withExtension: "json"), - let data = try? Data(contentsOf: url), - let categories = try? decoder.decode([Category].self, from: data) - else { - return [] - } - - return categories -} diff --git a/Applite/Model/Categories/CategoryViewModel.swift b/Applite/Model/Categories/CategoryViewModel.swift new file mode 100644 index 0000000..df3fef1 --- /dev/null +++ b/Applite/Model/Categories/CategoryViewModel.swift @@ -0,0 +1,17 @@ +// +// CategoryViewModel.swift +// Applite +// +// Created by Milán Várady on 2025.01.01. +// + +import Foundation + +struct CategoryViewModel: Identifiable, Equatable, Hashable { + let name: String + let sfSymbol: String + let casks: [Cask] + let casksCoupled: [[Cask]] + + var id: String { name } +} diff --git a/Applite/Model/SidebarItem.swift b/Applite/Model/SidebarItem.swift index cb35728..9d37ef0 100644 --- a/Applite/Model/SidebarItem.swift +++ b/Applite/Model/SidebarItem.swift @@ -14,5 +14,5 @@ enum SidebarItem: Equatable, Hashable { case activeTasks case appMigration case brew - case appCategory(categoryId: String) + case appCategory(category: CategoryViewModel) } diff --git a/Applite/Utilities/Import Export/ExportCasks.swift b/Applite/Utilities/Import Export/ExportCasks.swift index 7262031..ae24351 100644 --- a/Applite/Utilities/Import Export/ExportCasks.swift +++ b/Applite/Utilities/Import Export/ExportCasks.swift @@ -64,14 +64,16 @@ enum CaskToFileManager { return casks } - static func installImportedCasks(casks: [String], caskData: CaskData) async { - let casksToInstall: [Cask] = await caskData.casks.filter({ casks.contains($0.info.id) }) - + static func installImportedCasks(caskIds: [CaskId], caskManager: CaskManager) async { await withTaskGroup(of: Void.self) { group in - for cask in casksToInstall { + for caskId in caskIds { + guard let cask = await caskManager.casks[caskId] else { + continue + } + group.addTask { if await !cask.isInstalled { - await cask.install(caskData: caskData) + await caskManager.install(cask) } } } diff --git a/Applite/Views/App Views/App View/AppView+DownloadButton.swift b/Applite/Views/App Views/App View/AppView+DownloadButton.swift index ca96c5b..b058114 100644 --- a/Applite/Views/App Views/App View/AppView+DownloadButton.swift +++ b/Applite/Views/App Views/App View/AppView+DownloadButton.swift @@ -12,7 +12,7 @@ extension AppView { struct DownloadButton: View { @ObservedObject var cask: Cask - @EnvironmentObject var caskData: CaskData + @EnvironmentObject var caskManager: CaskManager // Alerts @State var showingPopover = false @@ -31,7 +31,7 @@ extension AppView { return } - download() + caskManager.install(cask) } label: { Image(systemName: "arrow.down.to.line.circle\(buttonFill ? ".fill" : "")") .font(.system(size: 22)) @@ -46,7 +46,7 @@ extension AppView { } .alert("App caveats", isPresented: $showingCaveats) { Button("Download Anyway") { - download() + caskManager.install(cask) } Button("Cancel", role: .cancel) { } @@ -90,23 +90,11 @@ extension AppView { } .confirmationDialog("Are you sure you want to force install \(cask.info.name)? This will override any current installation!", isPresented: $showingForceInstallConfirmation) { Button("Yes") { - download(force: true) + caskManager.install(cask) } Button("Cancel", role: .cancel) { } } } - - private func download(force: Bool = false) { - Task { @MainActor in - // Check if brew path is valid - guard await BrewPaths.isSelectedBrewPathValid() else { - showingBrewError = true - return - } - - await cask.install(caskData: caskData, force: force) - } - } } } diff --git a/Applite/Views/App Views/App View/AppView+OpenAndManageView.swift b/Applite/Views/App Views/App View/AppView+OpenAndManageView.swift index 9935d76..525bdb9 100644 --- a/Applite/Views/App Views/App View/AppView+OpenAndManageView.swift +++ b/Applite/Views/App Views/App View/AppView+OpenAndManageView.swift @@ -13,7 +13,7 @@ extension AppView { @StateObject var cask: Cask let deleteButton: Bool - @EnvironmentObject var caskData: CaskData + @EnvironmentObject var caskManager: CaskManager @State var showAppNotFoundAlert = false @State var showPopover = false @@ -52,9 +52,7 @@ extension AppView { VStack(alignment: .leading, spacing: 6) { // Reinstall button Button { - Task { - await cask.reinstall(caskData: caskData) - } + caskManager.reinstall(cask) } label: { Label("Reinstall", systemImage: "arrow.2.squarepath") } @@ -62,7 +60,7 @@ extension AppView { // Uninstall button Button(role: .destructive) { Task { - await cask.uninstall(caskData: caskData) + caskManager.uninstall(cask) } } label: { Label("Uninstall", systemImage: "trash") @@ -72,7 +70,7 @@ extension AppView { // Uninstall completely button Button(role: .destructive) { Task { - await cask.uninstall(caskData: caskData, zap: true) + caskManager.uninstall(cask, zap: true) } } label: { Label("Uninstall Completely", systemImage: "trash.fill") diff --git a/Applite/Views/App Views/App View/AppView+UninstallButton.swift b/Applite/Views/App Views/App View/AppView+UninstallButton.swift index 9a4bfcc..cdc7d91 100644 --- a/Applite/Views/App Views/App View/AppView+UninstallButton.swift +++ b/Applite/Views/App Views/App View/AppView+UninstallButton.swift @@ -10,15 +10,13 @@ import SwiftUI extension AppView { struct UninstallButton: View { @StateObject var cask: Cask - @EnvironmentObject var caskData: CaskData + @EnvironmentObject var caskManager: CaskManager @State var showingError = false var body: some View { Button { - Task { - await cask.uninstall(caskData: caskData) - } + caskManager.uninstall(cask) } label: { Image(systemName: "trash.fill") .font(.system(size: 20)) diff --git a/Applite/Views/App Views/App View/AppView+UpdateButton.swift b/Applite/Views/App Views/App View/AppView+UpdateButton.swift index 50997ea..29e6d75 100644 --- a/Applite/Views/App Views/App View/AppView+UpdateButton.swift +++ b/Applite/Views/App Views/App View/AppView+UpdateButton.swift @@ -9,14 +9,12 @@ import SwiftUI extension AppView { struct UpdateButton: View { - @EnvironmentObject var caskData: CaskData + @EnvironmentObject var caskManager: CaskManager @StateObject var cask: Cask var body: some View { Button { - Task { - await cask.update(caskData: caskData) - } + caskManager.update(cask) } label: { Image(systemName: "arrow.uturn.down.circle.fill") .font(.system(size: 20)) diff --git a/Applite/Views/App Views/App View/AppView.swift b/Applite/Views/App Views/App View/AppView.swift index 4398b68..4c5a2ff 100755 --- a/Applite/Views/App Views/App View/AppView.swift +++ b/Applite/Views/App Views/App View/AppView.swift @@ -24,7 +24,7 @@ struct AppView: View { @Environment(\.openWindow) var openWindow - @EnvironmentObject var caskData: CaskData + @EnvironmentObject var caskManager: CaskManager // Alerts @State var failureAlertMessage = "" @@ -47,7 +47,7 @@ struct AppView: View { } .buttonStyle(.plain) .frame(width: Self.dimensions.width, height: Self.dimensions.height) - .alertManager(cask.alert) + .alertManager(caskManager.alert) } } diff --git a/Applite/Views/App Views/AppGridView.swift b/Applite/Views/App Views/AppGridView.swift index 9fa10a0..e8e7d33 100755 --- a/Applite/Views/App Views/AppGridView.swift +++ b/Applite/Views/App Views/AppGridView.swift @@ -36,9 +36,6 @@ struct AppGridView: View { } } -struct AppGridView_Previews: PreviewProvider { - static var previews: some View { - AppGridView(casks: Array(CaskData().casks[0...10]), appRole: .installAndManage) - .frame(width: 660, height: 500) - } +#Preview { + AppGridView(casks: Array(repeating: .dummy, count: 8), appRole: .installAndManage) } diff --git a/Applite/Views/Content View/ContentView+DetailView.swift b/Applite/Views/Content View/ContentView+DetailView.swift index 7f021c3..e9c623a 100644 --- a/Applite/Views/Content View/ContentView+DetailView.swift +++ b/Applite/Views/Content View/ContentView+DetailView.swift @@ -44,12 +44,8 @@ extension ContentView { case .appMigration: AppMigrationView() - case .appCategory(let categoryId): - if let category = categories.first(where: { $0.id == categoryId }) { - CategoryView(category: category) - } else { - Text("No Selection") - } + case .appCategory(let category): + CategoryView(category: category) case .brew: BrewManagementView(modifyingBrew: $modifyingBrew) diff --git a/Applite/Views/Content View/ContentView+LoadCasks.swift b/Applite/Views/Content View/ContentView+LoadCasks.swift index d8bef85..4e8d51f 100644 --- a/Applite/Views/Content View/ContentView+LoadCasks.swift +++ b/Applite/Views/Content View/ContentView+LoadCasks.swift @@ -27,7 +27,7 @@ extension ContentView { } do { - try await caskData.loadData() + try await caskManager.loadData() brokenInstall = false } catch { loadAlert.show(title: "Couldn't load app catalog", message: error.localizedDescription) diff --git a/Applite/Views/Content View/ContentView+SidebarViews.swift b/Applite/Views/Content View/ContentView+SidebarViews.swift index 90b0580..d8e38c2 100644 --- a/Applite/Views/Content View/ContentView+SidebarViews.swift +++ b/Applite/Views/Content View/ContentView+SidebarViews.swift @@ -16,23 +16,23 @@ extension ContentView { .tag(SidebarItem.home) Label("Updates", systemImage: "arrow.clockwise.circle.fill") - .badge(caskData.outdatedCasks.count) + .badge(caskManager.outdatedCasks.count) .tag(SidebarItem.updates) Label("Installed", systemImage: "externaldrive.fill.badge.checkmark") .tag(SidebarItem.installed) Label("Active Tasks", systemImage: "gearshape.arrow.triangle.2.circlepath") - .badge(caskData.busyCasks.count) + .badge(caskManager.activeTasks.count) .tag(SidebarItem.activeTasks) Label("App Migration", systemImage: "square.and.arrow.up.on.square") .tag(SidebarItem.appMigration) Section("Categories") { - ForEach(categories) { category in - Label(LocalizedStringKey(category.id), systemImage: category.sfSymbol) - .tag(SidebarItem.appCategory(categoryId: category.id)) + ForEach(caskManager.categories) { category in + Label(LocalizedStringKey(category.name), systemImage: category.sfSymbol) + .tag(SidebarItem.appCategory(category: category)) } } diff --git a/Applite/Views/Content View/ContentView.swift b/Applite/Views/Content View/ContentView.swift index 29cc1e1..63abef1 100755 --- a/Applite/Views/Content View/ContentView.swift +++ b/Applite/Views/Content View/ContentView.swift @@ -9,7 +9,7 @@ import SwiftUI import os struct ContentView: View { - @EnvironmentObject var caskData: CaskData + @EnvironmentObject var caskManager: CaskManager /// Currently selected tab in the sidebar @State var selection: SidebarItem = .home @@ -27,7 +27,7 @@ struct ContentView: View { @State var modifyingBrew = false let logger = Logger() - + var body: some View { NavigationSplitView { sidebarViews diff --git a/Applite/Views/Detail Views/ActiveTasksView.swift b/Applite/Views/Detail Views/ActiveTasksView.swift index c4c7f93..0cd9b28 100644 --- a/Applite/Views/Detail Views/ActiveTasksView.swift +++ b/Applite/Views/Detail Views/ActiveTasksView.swift @@ -8,25 +8,22 @@ import SwiftUI struct ActiveTasksView: View { - @EnvironmentObject var caskData: CaskData + @EnvironmentObject var caskManager: CaskManager var body: some View { ScrollView { VStack { - if caskData.busyCasks.isEmpty { + if caskManager.activeTasks.isEmpty { Text("No Active Tasks") .font(.title) } else { - AppGridView(casks: Array(caskData.busyCasks), appRole: .update) + AppGridView(casks: caskManager.activeTasks.map { $0.cask }, appRole: .update) } Spacer() } .padding() } - .onAppear { - caskData.filterBusyCasks() - } } } diff --git a/Applite/Views/Detail Views/BrewManagementView.swift b/Applite/Views/Detail Views/BrewManagementView.swift index daab4bf..a9f6ae6 100755 --- a/Applite/Views/Detail Views/BrewManagementView.swift +++ b/Applite/Views/Detail Views/BrewManagementView.swift @@ -258,7 +258,7 @@ struct BrewManagementView: View { } struct ExportView: View { - @EnvironmentObject var caskData: CaskData + @EnvironmentObject var caskManager: CaskManager @State private var fileExporterPresented = false @State private var fileImporterPresented = false @@ -344,7 +344,7 @@ struct BrewManagementView: View { func installImported(casks: [String]) { Task { - await CaskToFileManager.installImportedCasks(casks: casks, caskData: caskData) + await CaskToFileManager.installImportedCasks(caskIds: casks, caskManager: caskManager) } } } diff --git a/Applite/Views/Detail Views/CategoryView.swift b/Applite/Views/Detail Views/CategoryView.swift index de0c90e..66bac8f 100755 --- a/Applite/Views/Detail Views/CategoryView.swift +++ b/Applite/Views/Detail Views/CategoryView.swift @@ -9,14 +9,13 @@ import SwiftUI /// Detail view used in the category section struct CategoryView: View { - let category: Category - @EnvironmentObject var caskData: CaskData + let category: CategoryViewModel var body: some View { VStack(alignment: .leading) { // Category name Group { - Text(LocalizedStringKey(category.id)) + Text(category.name) .font(.appliteMediumTitle) .padding(.bottom, -20) @@ -26,15 +25,20 @@ struct CategoryView: View { // Apps ScrollView { - AppGridView(casks: caskData.casksByCategory[category.id] ?? [], appRole: .installAndManage) + AppGridView(casks: category.casks, appRole: .installAndManage) } } } } -struct CategoryView_Previews: PreviewProvider { - static var previews: some View { - CategoryView(category: categories[0]) - } +#Preview { + CategoryView(category: + .init( + name: "Test", + sfSymbol: "star", + casks: Array(repeating: .dummy, count: 8), + casksCoupled: [Array(repeating: .dummy, count: 8)] + ) + ) } diff --git a/Applite/Views/Detail Views/Discover/Discover Section/DiscoverSectionView+AppRow.swift b/Applite/Views/Detail Views/Discover/Discover Section/DiscoverSectionView+AppRow.swift index ca11711..7cb4c1c 100644 --- a/Applite/Views/Detail Views/Discover/Discover Section/DiscoverSectionView+AppRow.swift +++ b/Applite/Views/Detail Views/Discover/Discover Section/DiscoverSectionView+AppRow.swift @@ -21,8 +21,7 @@ extension DiscoverSectionView { // App row appRow - - .coordinateSpace(name: "\(category.id)Scroll") + .coordinateSpace(name: "\(category.id)Scroll") // Forward button scrollButton( @@ -38,8 +37,8 @@ extension DiscoverSectionView { private var appRow: some View { ScrollView(.horizontal, showsIndicators: false) { LazyHStack { - if caskData.casksByCategoryCoupled[category.id]?.count ?? 0 > 0 { - ForEach(Array((caskData.casksByCategoryCoupled[category.id]?.enumerated())!), id: \.offset) { index, casks in + if category.casksCoupled.count > 0 { + ForEach(Array(category.casksCoupled.enumerated()), id: \.offset) { index, casks in VStack { ForEach(casks) { cask in AppView(cask: cask, role: .installAndManage) diff --git a/Applite/Views/Detail Views/Discover/Discover Section/DiscoverSectionView+CategoryHeader.swift b/Applite/Views/Detail Views/Discover/Discover Section/DiscoverSectionView+CategoryHeader.swift index 2593baa..3eba857 100644 --- a/Applite/Views/Detail Views/Discover/Discover Section/DiscoverSectionView+CategoryHeader.swift +++ b/Applite/Views/Detail Views/Discover/Discover Section/DiscoverSectionView+CategoryHeader.swift @@ -15,11 +15,11 @@ extension DiscoverSectionView { Image(systemName: category.sfSymbol) .font(.system(size: fontSize)) - Text(LocalizedStringKey(category.id)) + Text(LocalizedStringKey(category.name)) .font(.system(size: fontSize, weight: .bold)) Button("See All") { - navigationSelection = .appCategory(categoryId: category.id) + navigationSelection = .appCategory(category: category) } .buttonStyle(.plain) .foregroundColor(.blue) diff --git a/Applite/Views/Detail Views/Discover/Discover Section/DiscoverSectionView.swift b/Applite/Views/Detail Views/Discover/Discover Section/DiscoverSectionView.swift index 40620be..50b685a 100644 --- a/Applite/Views/Detail Views/Discover/Discover Section/DiscoverSectionView.swift +++ b/Applite/Views/Detail Views/Discover/Discover Section/DiscoverSectionView.swift @@ -8,10 +8,10 @@ import SwiftUI struct DiscoverSectionView: View { - let category: Category + let category: CategoryViewModel @Binding var navigationSelection: SidebarItem - @EnvironmentObject var caskData: CaskData + @EnvironmentObject var caskManager: CaskManager @State var scrollOffset: CGFloat = 0 diff --git a/Applite/Views/Detail Views/Discover/DiscoverView.swift b/Applite/Views/Detail Views/Discover/DiscoverView.swift index 162c241..7cd1072 100755 --- a/Applite/Views/Detail Views/Discover/DiscoverView.swift +++ b/Applite/Views/Detail Views/Discover/DiscoverView.swift @@ -10,6 +10,7 @@ import Shimmer /// Shows apps in categories struct DiscoverView: View { + @EnvironmentObject var caskManager: CaskManager @Binding var navigationSelection: SidebarItem @State var currentPage: Float = 0 @@ -19,7 +20,7 @@ struct DiscoverView: View { .font(.appliteLargeTitle) .padding(.bottom) - ForEach(categories) { category in + ForEach(caskManager.categories) { category in DiscoverSectionView(category: category, navigationSelection: $navigationSelection) Divider() @@ -33,6 +34,6 @@ struct DiscoverView: View { struct DiscoverView_Previews: PreviewProvider { static var previews: some View { DiscoverView(navigationSelection: .constant(.home)) - .environmentObject(CaskData()) + .environmentObject(CaskManager()) } } diff --git a/Applite/Views/Detail Views/Download/DownloadView+Search.swift b/Applite/Views/Detail Views/Download/DownloadView+Search.swift index 086fb36..e6812f4 100644 --- a/Applite/Views/Detail Views/Download/DownloadView+Search.swift +++ b/Applite/Views/Detail Views/Download/DownloadView+Search.swift @@ -15,13 +15,13 @@ extension DownloadView { /// - searchText: Search query /// - Returns: List of filtered casks func fuzzyFilter(casks: [Cask], searchText: String) -> [Cask] { - var casks = casks + var filteredCasks = casks if searchText.isEmpty { - casks = caskData.casks + filteredCasks = casks } else { // A score of 0 means a perfect match, a score of one matches everything - casks = caskData.casks.filter { + filteredCasks = casks.filter { ($0.info.name.lowercased().contains(searchText.lowercased()) || $0.info.description.lowercased().contains(searchText.lowercased())) || (fuseSearch.search(searchText.lowercased(), in: $0.info.name.lowercased())?.score ?? 1) < 0.25 || (fuseSearch.search(searchText.lowercased(), in: $0.info.description.lowercased())?.score ?? 1) < 0.25 @@ -30,19 +30,19 @@ extension DownloadView { // Filters if sortBy == .mostDownloaded { - casks = casks.sorted(by: { $0.downloadsIn365days > $1.downloadsIn365days }) + filteredCasks = casks.sorted(by: { $0.downloadsIn365days > $1.downloadsIn365days }) } if hideUnpopularApps { - casks = casks.filter { + filteredCasks = casks.filter { $0.downloadsIn365days > 500 } } - return casks + return filteredCasks } func search() { - self.searchResults = fuzzyFilter(casks: caskData.casks, searchText: searchText) + self.searchResults = fuzzyFilter(casks: Array(caskManager.casks.values), searchText: searchText) } } diff --git a/Applite/Views/Detail Views/Download/DownloadView.swift b/Applite/Views/Detail Views/Download/DownloadView.swift index cb67156..1138101 100755 --- a/Applite/Views/Detail Views/Download/DownloadView.swift +++ b/Applite/Views/Detail Views/Download/DownloadView.swift @@ -13,7 +13,7 @@ struct DownloadView: View { @Binding var navigationSelection: SidebarItem @Binding var searchText: String - @EnvironmentObject var caskData: CaskData + @EnvironmentObject var caskManager: CaskManager @State var searchResults: [Cask] = [] @@ -48,7 +48,7 @@ struct DownloadView: View { } .onChange(of: searchText) { newSearchText in // Filter apps - searchResults = fuzzyFilter(casks: caskData.casks, searchText: newSearchText) + searchResults = fuzzyFilter(casks: Array(caskManager.casks.values), searchText: newSearchText) } .onChange(of: sortBy) { _newValue in // Refilter if sorting options change @@ -68,6 +68,6 @@ struct DownloadView: View { struct DownloadView_Previews: PreviewProvider { static var previews: some View { DownloadView(navigationSelection: .constant(.home), searchText: .constant("")) - .environmentObject(CaskData()) + .environmentObject(CaskManager()) } } diff --git a/Applite/Views/Detail Views/InstalledView.swift b/Applite/Views/Detail Views/InstalledView.swift index cf933cb..0b38a58 100755 --- a/Applite/Views/Detail Views/InstalledView.swift +++ b/Applite/Views/Detail Views/InstalledView.swift @@ -10,7 +10,7 @@ import Fuse /// Shows installed apps, where the user can open and uninstall them struct InstalledView: View { - @EnvironmentObject var caskData: CaskData + @EnvironmentObject var caskManager: CaskManager @State var searchText = "" let fuseSearch = Fuse() @@ -23,25 +23,28 @@ struct InstalledView: View { } } .searchable(text: $searchText) + .id("InstalledView") } - + // Filter installed casks var casks: [Cask] { - var filteredCasks = caskData.casks.filter { $0.isInstalled } - + var installedCasks = caskManager.installedCasks + if !$searchText.wrappedValue.isEmpty { - filteredCasks = filteredCasks.filter { + installedCasks = installedCasks.filter { (fuseSearch.search(searchText, in: $0.info.name)?.score ?? 1) < 0.4 } } - - return filteredCasks + + let installedCasksAlphabetical = installedCasks.sorted { $0.info.name < $1.info.name } + + return installedCasksAlphabetical } } struct InstalledView_Previews: PreviewProvider { static var previews: some View { InstalledView() - .environmentObject(CaskData()) + .environmentObject(CaskManager()) } } diff --git a/Applite/Views/Detail Views/Update/UpdateView+ToolbarItems.swift b/Applite/Views/Detail Views/Update/UpdateView+ToolbarItems.swift index 77e5f25..44bd359 100644 --- a/Applite/Views/Detail Views/Update/UpdateView+ToolbarItems.swift +++ b/Applite/Views/Detail Views/Update/UpdateView+ToolbarItems.swift @@ -32,7 +32,7 @@ extension UpdateView { Button("Show All") { Task { do { - try await caskData.refreshOutdatedApps(greedy: true) + try await caskManager.refreshOutdated(greedy: true) } catch { loadAlert.show(title: "Failed to load updates", message: error.localizedDescription) } @@ -51,7 +51,7 @@ extension UpdateView { refreshing = true do { - try await caskData.refreshOutdatedApps(greedy: true) + try await caskManager.refreshOutdated(greedy: true) } catch { loadAlert.show(title: "Failed to refresh updates", message: error.localizedDescription) } diff --git a/Applite/Views/Detail Views/Update/UpdateView+UpdateAllButton.swift b/Applite/Views/Detail Views/Update/UpdateView+UpdateAllButton.swift index 69ed9e2..4ab46e9 100644 --- a/Applite/Views/Detail Views/Update/UpdateView+UpdateAllButton.swift +++ b/Applite/Views/Detail Views/Update/UpdateView+UpdateAllButton.swift @@ -15,27 +15,11 @@ extension UpdateView { withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) { updateAllButtonRotation = 360.0 } - - Task { - await withTaskGroup(of: Void.self) { group in - for cask in casks { - group.addTask { - await cask.update(caskData: caskData) - } - } - } - - await MainActor.run { - withAnimation(.linear(duration: 0.2)) { - updateAllButtonRotation = 0.0 - } - } - - updateAllFinished = true - } + + caskManager.updateAll(casks) } label: { HStack { - Image(systemName: updateAllFinished ? "checkmark" : "arrow.2.circlepath") + Image(systemName: "arrow.2.circlepath") .rotationEffect(.degrees(updateAllButtonRotation)) Text("Update All") diff --git a/Applite/Views/Detail Views/Update/UpdateView.swift b/Applite/Views/Detail Views/Update/UpdateView.swift index a3f8207..c366e6b 100755 --- a/Applite/Views/Detail Views/Update/UpdateView.swift +++ b/Applite/Views/Detail Views/Update/UpdateView.swift @@ -10,12 +10,11 @@ import Fuse /// Update section struct UpdateView: View { - @EnvironmentObject var caskData: CaskData + @EnvironmentObject var caskManager: CaskManager @State var searchText = "" @State var refreshing = false @State var isUpdatingAll = false - @State var updateAllFinished = false @State var updateAllButtonRotation = 0.0 @State var showingGreedyUpdateConfirm = false @@ -23,15 +22,16 @@ struct UpdateView: View { // Filter outdated casks var casks: [Cask] { - var filteredCasks = caskData.casks.filter { $0.isOutdated } - + var outdatedCasks = caskManager.outdatedCasks if !$searchText.wrappedValue.isEmpty { - filteredCasks = filteredCasks.filter { + outdatedCasks = outdatedCasks.filter { (fuseSearch.search(searchText, in: $0.info.name)?.score ?? 1) < 0.4 } } - - return filteredCasks + + let outdatedCasksAphabetical = Array(outdatedCasks).sorted { $0.info.name < $1.info.name } + + return outdatedCasksAphabetical } let fuseSearch = Fuse() @@ -39,7 +39,7 @@ struct UpdateView: View { var body: some View { ScrollView { // App grid - AppGridView(casks: Array(caskData.outdatedCasks), appRole: .update) + AppGridView(casks: Array(caskManager.outdatedCasks), appRole: .update) .padding() if casks.count > 1 { @@ -59,7 +59,7 @@ struct UpdateView: View { struct UpdateView_Previews: PreviewProvider { static var previews: some View { UpdateView() - .environmentObject(CaskData()) + .environmentObject(CaskManager()) .frame(width: 500, height: 400) } } diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 4425642..7a6584a 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -3492,6 +3492,7 @@ } }, "No Selection" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { From 0ac1bc715ba2b10970d0ca62e4015a01a755c737 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1rady=20Mil=C3=A1n?= <61704770+MilanVarady@users.noreply.github.com> Date: Wed, 1 Jan 2025 21:42:52 +0100 Subject: [PATCH 4/5] Add export import functionality --- Applite.xcodeproj/project.pbxproj | 34 ++++-- .../CaskManager+BrewFunctions.swift | 9 +- Applite/Model/Cask Models/Cask/Cask.swift | 2 +- .../Alert Manager/AlertManager.swift | 8 +- .../Utilities/App Migration/ExportCasks.swift | 52 +++++++++ .../Brew Installation/DependencyManager.swift | 2 +- .../Import Export/CaskExportType.swift | 15 --- .../Utilities/Import Export/ExportCasks.swift | 82 -------------- Applite/Utilities/Shell/Shell.swift | 2 +- Applite/Views/Content View/ContentView.swift | 2 +- .../AppMigrationView+ExportView.swift | 65 +++++++++++ .../AppMigrationView+ImportView.swift | 83 ++++++++++++++ .../App Migration/AppMigrationView.swift | 53 +++++++++ .../App Migration/ExportFile.swift | 34 ++++++ .../Views/Detail Views/AppMigrationView.swift | 101 ------------------ .../Detail Views/BrewManagementView.swift | 98 +---------------- Localizable.xcstrings | 13 ++- 17 files changed, 340 insertions(+), 315 deletions(-) create mode 100644 Applite/Utilities/App Migration/ExportCasks.swift delete mode 100644 Applite/Utilities/Import Export/CaskExportType.swift delete mode 100644 Applite/Utilities/Import Export/ExportCasks.swift create mode 100644 Applite/Views/Detail Views/App Migration/AppMigrationView+ExportView.swift create mode 100644 Applite/Views/Detail Views/App Migration/AppMigrationView+ImportView.swift create mode 100644 Applite/Views/Detail Views/App Migration/AppMigrationView.swift create mode 100644 Applite/Views/Detail Views/App Migration/ExportFile.swift delete mode 100644 Applite/Views/Detail Views/AppMigrationView.swift diff --git a/Applite.xcodeproj/project.pbxproj b/Applite.xcodeproj/project.pbxproj index 3c2c0ec..a90b298 100644 --- a/Applite.xcodeproj/project.pbxproj +++ b/Applite.xcodeproj/project.pbxproj @@ -31,7 +31,6 @@ 41483CCD29101C9900BB10C2 /* Category.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41483CCC29101C9900BB10C2 /* Category.swift */; }; 41524B99295E352200D0046A /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41524B98295E352200D0046A /* SettingsView.swift */; }; 41524B9E295FA36E00D0046A /* DebounceObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41524B9D295FA36E00D0046A /* DebounceObject.swift */; }; - 415563A02A9265CE00AE2F2E /* CaskExportType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4155639F2A9265CE00AE2F2E /* CaskExportType.swift */; }; 415563A22A98BB2500AE2F2E /* ErrorWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 415563A12A98BB2500AE2F2E /* ErrorWindowView.swift */; }; 415563A42A98C54300AE2F2E /* AppdirSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 415563A32A98C54300AE2F2E /* AppdirSelectorView.swift */; }; 4166EE7028F5D4C900CE305A /* Commands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4166EE6F28F5D4C900CE305A /* Commands.swift */; }; @@ -104,6 +103,9 @@ 4192569B2D24335900D9EF10 /* CaskTaskError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4192569A2D24335900D9EF10 /* CaskTaskError.swift */; }; 4192569D2D2433E200D9EF10 /* CaskProgressState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4192569C2D2433E200D9EF10 /* CaskProgressState.swift */; }; 419256A12D25ACC300D9EF10 /* CategoryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419256A02D25ACC300D9EF10 /* CategoryViewModel.swift */; }; + 419256A42D25CFE600D9EF10 /* AppMigrationView+ExportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419256A32D25CFE600D9EF10 /* AppMigrationView+ExportView.swift */; }; + 419256A62D25D00200D9EF10 /* AppMigrationView+ImportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419256A52D25D00200D9EF10 /* AppMigrationView+ImportView.swift */; }; + 419256A82D25D10F00D9EF10 /* ExportFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419256A72D25D10F00D9EF10 /* ExportFile.swift */; }; 419506A42964A27F00FE5802 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419506A32964A27F00FE5802 /* SetupView.swift */; }; 419506A62964A5EF00FE5802 /* BrewPathSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419506A52964A5EF00FE5802 /* BrewPathSelectorView.swift */; }; 4196C8F528F9CB2600EADDDA /* DiscoverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4196C8F428F9CB2600EADDDA /* DiscoverView.swift */; }; @@ -141,7 +143,6 @@ 41483CCC29101C9900BB10C2 /* Category.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Category.swift; sourceTree = ""; }; 41524B98295E352200D0046A /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 41524B9D295FA36E00D0046A /* DebounceObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebounceObject.swift; sourceTree = ""; }; - 4155639F2A9265CE00AE2F2E /* CaskExportType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaskExportType.swift; sourceTree = ""; }; 415563A12A98BB2500AE2F2E /* ErrorWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorWindowView.swift; sourceTree = ""; }; 415563A32A98C54300AE2F2E /* AppdirSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppdirSelectorView.swift; sourceTree = ""; }; 4166EE6F28F5D4C900CE305A /* Commands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Commands.swift; sourceTree = ""; }; @@ -212,6 +213,9 @@ 4192569A2D24335900D9EF10 /* CaskTaskError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaskTaskError.swift; sourceTree = ""; }; 4192569C2D2433E200D9EF10 /* CaskProgressState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaskProgressState.swift; sourceTree = ""; }; 419256A02D25ACC300D9EF10 /* CategoryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryViewModel.swift; sourceTree = ""; }; + 419256A32D25CFE600D9EF10 /* AppMigrationView+ExportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppMigrationView+ExportView.swift"; sourceTree = ""; }; + 419256A52D25D00200D9EF10 /* AppMigrationView+ImportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppMigrationView+ImportView.swift"; sourceTree = ""; }; + 419256A72D25D10F00D9EF10 /* ExportFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportFile.swift; sourceTree = ""; }; 419506A32964A27F00FE5802 /* SetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupView.swift; sourceTree = ""; }; 419506A52964A5EF00FE5802 /* BrewPathSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrewPathSelectorView.swift; sourceTree = ""; }; 419506A729696A5300FE5802 /* Applite-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "Applite-Info.plist"; sourceTree = SOURCE_ROOT; }; @@ -439,7 +443,7 @@ children = ( 412635422A77FB0000155034 /* Brew Installation */, 4126353F2A77C71F00155034 /* Shell */, - 41B731352A878993008BF6B9 /* Import Export */, + 41B731352A878993008BF6B9 /* App Migration */, 413E60B52BBAE58B00978F6A /* Network Proxy */, 418989B02A35D628004AC23B /* Verify Brew Installation */, 419256652D1E18A500D9EF10 /* Alert Manager */, @@ -597,6 +601,17 @@ path = "Cask Manager"; sourceTree = ""; }; + 419256A22D25CFBF00D9EF10 /* App Migration */ = { + isa = PBXGroup; + children = ( + 4192568B2D22D7FC00D9EF10 /* AppMigrationView.swift */, + 419256A32D25CFE600D9EF10 /* AppMigrationView+ExportView.swift */, + 419256A52D25D00200D9EF10 /* AppMigrationView+ImportView.swift */, + 419256A72D25D10F00D9EF10 /* ExportFile.swift */, + ); + path = "App Migration"; + sourceTree = ""; + }; 4196C8F628F9CB4100EADDDA /* App Views */ = { isa = PBXGroup; children = ( @@ -616,21 +631,20 @@ 419256502D1E0CE000D9EF10 /* Discover */, 4192565C2D1E153D00D9EF10 /* Update */, 4196C8FF28F9E1F400EADDDA /* InstalledView.swift */, + 41B731382A879353008BF6B9 /* ActiveTasksView.swift */, + 419256A22D25CFBF00D9EF10 /* App Migration */, 41857B742912D94A004A1894 /* CategoryView.swift */, 418989AC2A33A5C4004AC23B /* BrewManagementView.swift */, - 41B731382A879353008BF6B9 /* ActiveTasksView.swift */, - 4192568B2D22D7FC00D9EF10 /* AppMigrationView.swift */, ); path = "Detail Views"; sourceTree = ""; }; - 41B731352A878993008BF6B9 /* Import Export */ = { + 41B731352A878993008BF6B9 /* App Migration */ = { isa = PBXGroup; children = ( 4178CF912A8689AF0037F270 /* ExportCasks.swift */, - 4155639F2A9265CE00AE2F2E /* CaskExportType.swift */, ); - path = "Import Export"; + path = "App Migration"; sourceTree = ""; }; /* End PBXGroup section */ @@ -737,6 +751,7 @@ 4166EE7028F5D4C900CE305A /* Commands.swift in Sources */, 419256312D1DF2B600D9EF10 /* SettingsView+GeneralSettings.swift in Sources */, 4166EE7D28F73B2300CE305A /* BrewAnalytics.swift in Sources */, + 419256A82D25D10F00D9EF10 /* ExportFile.swift in Sources */, 4104D7432A8FC52C00F84F9B /* Preferences.swift in Sources */, 419256682D1E18D100D9EF10 /* AlertManagerViewModifier.swift in Sources */, 414074F728DF53E80073EB22 /* ContentView.swift in Sources */, @@ -766,7 +781,6 @@ 4192561E2D1DEBE700D9EF10 /* AppView+UninstallButton.swift in Sources */, 4192569B2D24335900D9EF10 /* CaskTaskError.swift in Sources */, 41524B99295E352200D0046A /* SettingsView.swift in Sources */, - 415563A02A9265CE00AE2F2E /* CaskExportType.swift in Sources */, 41524B9E295FA36E00D0046A /* DebounceObject.swift in Sources */, 419256292D1DF1CF00D9EF10 /* SetupView+BrewPathSelection.swift in Sources */, 415563A22A98BB2500AE2F2E /* ErrorWindowView.swift in Sources */, @@ -794,6 +808,7 @@ 419256832D22055200D9EF10 /* CaskInfo.swift in Sources */, 4192560D2D1CA02C00D9EF10 /* ShellError.swift in Sources */, 4192566B2D1F286B00D9EF10 /* CaskManager+BrewFunctions.swift in Sources */, + 419256A42D25CFE600D9EF10 /* AppMigrationView+ExportView.swift in Sources */, 4192561A2D1DEB2100D9EF10 /* AppView+OpenAndManageView.swift in Sources */, 4192562C2D1DF20600D9EF10 /* SetupView+BrewInstall.swift in Sources */, 41062C952A3794EA00FD48EA /* BrewPaths.swift in Sources */, @@ -821,6 +836,7 @@ 4192563E2D1DF3E900D9EF10 /* ContentView+DetailView.swift in Sources */, 419256162D1DEA0A00D9EF10 /* AppView+IconAndDescriptionView.swift in Sources */, 419256942D24255000D9EF10 /* CaskLoadError.swift in Sources */, + 419256A62D25D00200D9EF10 /* AppMigrationView+ImportView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Applite/Model/Cask Models/Cask Manager/CaskManager+BrewFunctions.swift b/Applite/Model/Cask Models/Cask Manager/CaskManager+BrewFunctions.swift index 0fe143b..c9e6273 100644 --- a/Applite/Model/Cask Models/Cask Manager/CaskManager+BrewFunctions.swift +++ b/Applite/Model/Cask Models/Cask Manager/CaskManager+BrewFunctions.swift @@ -178,7 +178,14 @@ extension CaskManager { } } - /// Update all outdated casks + /// Installs multiple + func installAll(_ casks: [Cask]) { + for cask in casks { + self.install(cask) + } + } + + /// Updates multiple casks func updateAll(_ casks: [Cask]) { for cask in casks { self.update(cask) diff --git a/Applite/Model/Cask Models/Cask/Cask.swift b/Applite/Model/Cask Models/Cask/Cask.swift index 55e4fa7..c7a54d8 100755 --- a/Applite/Model/Cask Models/Cask/Cask.swift +++ b/Applite/Model/Cask Models/Cask/Cask.swift @@ -6,7 +6,7 @@ // import SwiftUI -import os +import OSLog /// A view model that holds all essential data of a Homebrew cask and provides methods to run brew commands on it (e.g. install, uninstall, update) @MainActor diff --git a/Applite/Utilities/Alert Manager/AlertManager.swift b/Applite/Utilities/Alert Manager/AlertManager.swift index 4f4b538..5bf94ed 100644 --- a/Applite/Utilities/Alert Manager/AlertManager.swift +++ b/Applite/Utilities/Alert Manager/AlertManager.swift @@ -33,16 +33,14 @@ final class AlertManager: ObservableObject { /// Shows an alert based on an error func show( - error: LocalizedError, - overrideTitle: String? = nil, + error: Error, + title: String, primaryButtonTitle: String = "OK", primaryAction: (() -> Void)? = nil ) { - let title = overrideTitle ?? error.errorDescription ?? error.localizedDescription - show( title: title, - message: error.failureReason ?? "", + message: error.localizedDescription, primaryButtonTitle: primaryButtonTitle, primaryAction: primaryAction ) diff --git a/Applite/Utilities/App Migration/ExportCasks.swift b/Applite/Utilities/App Migration/ExportCasks.swift new file mode 100644 index 0000000..2f2e662 --- /dev/null +++ b/Applite/Utilities/App Migration/ExportCasks.swift @@ -0,0 +1,52 @@ +// +// ExportCasks.swift +// Applite +// +// Created by Milán Várady on 2023. 08. 11.. +// + +import Foundation +import OSLog + +enum CaskImportError: Error { + case EmptyFile +} + +enum AppMigration { + static func export() async throws -> ExportFile { + let output = try await Shell.runBrewCommand("list") + + let exportedCasks = output.trimmingCharacters(in: .whitespacesAndNewlines) + + return ExportFile(initialText: exportedCasks) + } + + static func readCaskFile(url: URL) throws -> [CaskId] { + let content = try String(contentsOf: url) + var casks: [CaskId] = [] + let brewfileRegex = /cask "([\w-]+)"/ + + // Check if the file being imported is a Brewfile + // Brewfiles store casks as cask "caskName" + if content.contains("cask \"") { + // Brewfile + let matches = content.matches(of: brewfileRegex) + casks = matches.map({ String($0.1) }) + } else { + // Try to load casks as an Applite txt file export + casks = content.components(separatedBy: .newlines) + + // Trim whitespace + casks = casks.map({ $0.trimmingCharacters(in: .whitespaces) }) + } + + // Remove empty elements + casks = casks.filter({ !$0.isEmpty }) + + if casks.isEmpty { + throw CaskImportError.EmptyFile + } + + return casks + } +} diff --git a/Applite/Utilities/Brew Installation/DependencyManager.swift b/Applite/Utilities/Brew Installation/DependencyManager.swift index 937b9ed..3b9822a 100755 --- a/Applite/Utilities/Brew Installation/DependencyManager.swift +++ b/Applite/Utilities/Brew Installation/DependencyManager.swift @@ -6,7 +6,7 @@ // import Foundation -import os +import OSLog /// Installs app dependecies: Homebrew and Xcode Command Line Tools /// diff --git a/Applite/Utilities/Import Export/CaskExportType.swift b/Applite/Utilities/Import Export/CaskExportType.swift deleted file mode 100644 index f93f5c5..0000000 --- a/Applite/Utilities/Import Export/CaskExportType.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// CaskExportType.swift -// Applite -// -// Created by Milán Várady on 2023. 08. 20.. -// - -import Foundation - -enum CaskExportType: String, CaseIterable, Identifiable { - var id: Self { self } - - case txtFile = "Cask list (.txt file)" - case brewfile = "Brewfile" -} diff --git a/Applite/Utilities/Import Export/ExportCasks.swift b/Applite/Utilities/Import Export/ExportCasks.swift deleted file mode 100644 index ae24351..0000000 --- a/Applite/Utilities/Import Export/ExportCasks.swift +++ /dev/null @@ -1,82 +0,0 @@ -// -// ExportCasks.swift -// Applite -// -// Created by Milán Várady on 2023. 08. 11.. -// - -import Foundation -import OSLog - -enum CaskImportError: Error { - case EmptyFile -} - -enum CaskToFileManager { - static func export(url: URL, exportType: CaskExportType) async throws { - let today = Date.now - - let formatter = DateFormatter() - formatter.dateFormat = "y_MM_dd_HH:mm" - let currentDateString = formatter.string(from: today) - - switch exportType { - case .txtFile: - let output = try await Shell.runAsync("\(BrewPaths.currentBrewExecutable) list --cask") - - let exportedCasks = output.trimmingCharacters(in: .whitespacesAndNewlines) - - let fileURL = url.appendingPathComponent("applite_export_\(currentDateString).txt", conformingTo: .plainText) - - let data = exportedCasks.data(using: .utf8) - try data?.write(to: fileURL) - case .brewfile: - let brewfileURL = url.appendingPathComponent("Brewfile_\(currentDateString)") - - try await Shell.runAsync("\(BrewPaths.currentBrewExecutable) bundle dump --file=\"\(brewfileURL.path)\"") - } - } - - static func readCaskFile(url: URL) throws -> [String] { - let content = try String(contentsOf: url) - var casks: [String] = [] - let brewfileRegex = /cask "([\w-]+)"/ - - if content.contains("cask \"") { - // Brewfile - let matches = content.matches(of: brewfileRegex) - casks = matches.map({ String($0.1) }) - } else { - // Txt file - casks = content.components(separatedBy: .newlines) - - // Trim whitespace - casks = casks.map({ $0.trimmingCharacters(in: .whitespaces) }) - } - - // Remove empty elements - casks = casks.filter({ !$0.isEmpty }) - - if casks.isEmpty { - throw CaskImportError.EmptyFile - } - - return casks - } - - static func installImportedCasks(caskIds: [CaskId], caskManager: CaskManager) async { - await withTaskGroup(of: Void.self) { group in - for caskId in caskIds { - guard let cask = await caskManager.casks[caskId] else { - continue - } - - group.addTask { - if await !cask.isInstalled { - await caskManager.install(cask) - } - } - } - } - } -} diff --git a/Applite/Utilities/Shell/Shell.swift b/Applite/Utilities/Shell/Shell.swift index 8d154b8..254a6ec 100644 --- a/Applite/Utilities/Shell/Shell.swift +++ b/Applite/Utilities/Shell/Shell.swift @@ -76,7 +76,7 @@ enum Shell { /// /// Using the `pty` option can leave unwanted characters in the output, use only when necessary @discardableResult - static func runBrewCommand(_ brewCommand: String, arguments: [String], pty: Bool = false) async throws -> String { + static func runBrewCommand(_ brewCommand: String, arguments: [String] = [], pty: Bool = false) async throws -> String { let command = "\(BrewPaths.currentBrewExecutable) \(brewCommand) --cask \(arguments.joined(separator: " "))" return try await runAsync(command) } diff --git a/Applite/Views/Content View/ContentView.swift b/Applite/Views/Content View/ContentView.swift index 63abef1..ea4037e 100755 --- a/Applite/Views/Content View/ContentView.swift +++ b/Applite/Views/Content View/ContentView.swift @@ -6,7 +6,7 @@ // import SwiftUI -import os +import OSLog struct ContentView: View { @EnvironmentObject var caskManager: CaskManager diff --git a/Applite/Views/Detail Views/App Migration/AppMigrationView+ExportView.swift b/Applite/Views/Detail Views/App Migration/AppMigrationView+ExportView.swift new file mode 100644 index 0000000..bb86f38 --- /dev/null +++ b/Applite/Views/Detail Views/App Migration/AppMigrationView+ExportView.swift @@ -0,0 +1,65 @@ +// +// AppMigrationView+ExportView.swift +// Applite +// +// Created by Milán Várady on 2025.01.01. +// + +import SwiftUI +import OSLog + +extension AppMigrationView { + struct ExportView: View { + @State var showFileExporter = false + @State var exportFile: ExportFile = .init() + @State var exportSuccessful = false + @StateObject var alert = AlertManager() + + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "AppMigrationView.ExportView") + + var body: some View { + VStack(alignment: .leading) { + Text("Export") + .font(.appliteSmallTitle) + + HStack { + Button { + Task { + do { + exportFile = try await AppMigration.export() + showFileExporter = true + } catch { + alert.show(error: error, title: "Failed to export") + } + } + } label: { + Label("Export Apps to File", systemImage: "square.and.arrow.up") + } + .controlSize(.large) + + if exportSuccessful { + Image(systemName: "square.and.arrow.down.badge.checkmark") + .foregroundStyle(.green) + .imageScale(.large) + } + } + .padding(.bottom, 10) + + Text("Export all apps currently installed by \(Bundle.main.appName) to a file.") + + Spacer() + } + .alertManager(alert) + .fileExporter(isPresented: $showFileExporter, document: exportFile, contentType: .plainText, defaultFilename: "applite_export") { result in + switch result { + case .success(let url): + logger.notice("Successful cask export: \(url.path(percentEncoded: false))") + withAnimation { exportSuccessful = true } + case .failure(let error): + logger.error("File exporter failed: \(error.localizedDescription)") + alert.show(error: error, title: "Failed to export") + } + } + } + } +} diff --git a/Applite/Views/Detail Views/App Migration/AppMigrationView+ImportView.swift b/Applite/Views/Detail Views/App Migration/AppMigrationView+ImportView.swift new file mode 100644 index 0000000..271f449 --- /dev/null +++ b/Applite/Views/Detail Views/App Migration/AppMigrationView+ImportView.swift @@ -0,0 +1,83 @@ +// +// AppMigrationView+ImportView.swift +// Applite +// +// Created by Milán Várady on 2025.01.01. +// + +import SwiftUI +import OSLog + +extension AppMigrationView { + struct ImportView: View { + @EnvironmentObject var caskManager: CaskManager + + @State var showFileImporter = false + @State var importSuccessful = false + @StateObject var alert = AlertManager() + + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "AppMigrationView.ExportView") + + var body: some View { + VStack(alignment: .leading) { + Text("Import") + .font(.appliteSmallTitle) + + HStack { + Button { + showFileImporter = true + } label: { + Label("Import Apps", systemImage: "square.and.arrow.down") + } + .controlSize(.large) + + if importSuccessful { + Image(systemName: "square.and.arrow.down.badge.checkmark") + .foregroundStyle(.green) + .imageScale(.large) + } + } + .padding(.bottom, 10) + + Text("**Tip:** You can also import apps from a Brewfile. However, only casks will be installed, other items like formulae and taps will be skipped.") + + Spacer() + } + .alertManager(alert) + .fileImporter(isPresented: $showFileImporter, allowedContentTypes: [.plainText, .data]) { result in + switch result { + case .success(let url): + installCasks(from: url) + case .failure(let error): + alert.show(error: error, title: "Failed to import") + } + } + } + + private func installCasks(from url: URL) { + var caskIds: [CaskId] = [] + + do { + caskIds = try AppMigration.readCaskFile(url: url) + } catch { + logger.error("Failed to import file: \(url.path(percentEncoded: false))") + } + + let casksToInstall = caskIds.compactMap { + caskManager.casks[$0] + } + + guard !casksToInstall.isEmpty else { + logger.notice("Imported file contains no valid apps: \(url.path(percentEncoded: false))") + alert.show(title: "Imported file contains no valid apps", message: "Check if file contains valid cask tokens") + return + } + + caskManager.installAll(casksToInstall) + + withAnimation { + importSuccessful = true + } + } + } +} diff --git a/Applite/Views/Detail Views/App Migration/AppMigrationView.swift b/Applite/Views/Detail Views/App Migration/AppMigrationView.swift new file mode 100644 index 0000000..c91af52 --- /dev/null +++ b/Applite/Views/Detail Views/App Migration/AppMigrationView.swift @@ -0,0 +1,53 @@ +// +// AppMigrationView.swift +// Applite +// +// Created by Milán Várady on 2024.12.30. +// + +import SwiftUI + +struct AppMigrationView: View { + let width: CGFloat = 620 + let columnSpacing: CGFloat = 40 + + var cardWidth: CGFloat { + (width - columnSpacing) / 2 + } + let cardHeight: CGFloat = 220 + let cardPadding: CGFloat = 24 + + var body: some View { + VStack { + titleAndDescription + .padding(.vertical, 40) + + HStack(spacing: columnSpacing) { + Card(cardWidth: cardWidth, cardHeight: cardHeight, paddig: cardPadding) { + ExportView() + } + + Card(cardWidth: cardWidth, cardHeight: cardHeight, paddig: cardPadding) { + ImportView() + } + } + + Spacer() + } + .frame(maxWidth: width) + } + + var titleAndDescription: some View { + VStack(alignment: .leading) { + Text("App Migration") + .font(.appliteMediumTitle) + .padding(.bottom, 2) + + Text("Export all of your currently installed apps to a file. Import the file to another device to install them all. Useful when setting up a new Mac.") + } + } +} + +#Preview { + AppMigrationView() +} diff --git a/Applite/Views/Detail Views/App Migration/ExportFile.swift b/Applite/Views/Detail Views/App Migration/ExportFile.swift new file mode 100644 index 0000000..878ac98 --- /dev/null +++ b/Applite/Views/Detail Views/App Migration/ExportFile.swift @@ -0,0 +1,34 @@ +// +// ExportFile.swift +// Applite +// +// Created by Milán Várady on 2025.01.01. +// + +import Foundation +import UniformTypeIdentifiers +import SwiftUI + +struct ExportFile: FileDocument { + static let readableContentTypes = [UTType.plainText] + + var text = "" + + // Creates new, empty document + init(initialText: String = "") { + text = initialText + } + + // Loads data that has been saved previously + init(configuration: ReadConfiguration) throws { + if let data = configuration.file.regularFileContents { + text = String(decoding: data, as: UTF8.self) + } + } + + // This will be called when the system wants to write our data to disk + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + let data = Data(text.utf8) + return FileWrapper(regularFileWithContents: data) + } +} diff --git a/Applite/Views/Detail Views/AppMigrationView.swift b/Applite/Views/Detail Views/AppMigrationView.swift deleted file mode 100644 index a0a0be7..0000000 --- a/Applite/Views/Detail Views/AppMigrationView.swift +++ /dev/null @@ -1,101 +0,0 @@ -// -// AppMigrationView.swift -// Applite -// -// Created by Milán Várady on 2024.12.30. -// - -import SwiftUI - -struct AppMigrationView: View { - let width: CGFloat = 620 - let columnSpacing: CGFloat = 40 - - var cardWidth: CGFloat { - (width - columnSpacing) / 2 - } - let cardHeight: CGFloat = 210 - let cardPadding: CGFloat = 28 - - var body: some View { - VStack { - titleAndDescription - .padding(.vertical, 40) - - HStack(spacing: columnSpacing) { - Card(cardWidth: cardWidth, cardHeight: cardHeight, paddig: cardPadding) { - ExportView() - } - - Card(cardWidth: cardWidth, cardHeight: cardHeight, paddig: cardPadding) { - ImportView() - } - } - - Spacer() - } - .frame(maxWidth: width) - } - - var titleAndDescription: some View { - VStack(alignment: .leading) { - Text("App Migration") - .font(.appliteMediumTitle) - .padding(.bottom, 2) - - Text("Export all of your currently installed apps to a file. Import the file to another device to install them all. Useful when setting up a new Mac.") - } - } - - private struct ExportView: View { - @State var selectedExportFileType: CaskExportType = .txtFile - - var body: some View { - VStack(alignment: .leading) { - Text("Export") - .font(.appliteSmallTitle) - - Button { - - } label: { - Label("Export Apps to File", systemImage: "square.and.arrow.up") - } - .controlSize(.large) - .padding(.bottom, 2) - - Picker("Export file type", selection: $selectedExportFileType) { - ForEach(CaskExportType.allCases) { type in - Text(LocalizedStringKey(type.rawValue)) - } - } - - Spacer() - } - } - } - - private struct ImportView: View { - var body: some View { - VStack(alignment: .leading) { - Text("Import") - .font(.appliteSmallTitle) - - Button { - - } label: { - Label("Import Apps", systemImage: "square.and.arrow.down") - } - .controlSize(.large) - .padding(.bottom, 4) - - Text("**Note:** When importing a Brewfile only casks will be installed. Other items like formulae and taps will be skipped.") - - Spacer() - } - } - } -} - -#Preview { - AppMigrationView() -} diff --git a/Applite/Views/Detail Views/BrewManagementView.swift b/Applite/Views/Detail Views/BrewManagementView.swift index a9f6ae6..745e687 100755 --- a/Applite/Views/Detail Views/BrewManagementView.swift +++ b/Applite/Views/Detail Views/BrewManagementView.swift @@ -6,7 +6,7 @@ // import SwiftUI -import os +import OSLog /// Displays info and provides tools to manage brew installation struct BrewManagementView: View { @@ -35,10 +35,6 @@ struct BrewManagementView: View { } .padding(.bottom) - section(title: "Import/Export apps") { - ExportView() - } - Spacer() } .frame(maxWidth: 800) @@ -256,98 +252,6 @@ struct BrewManagementView: View { }) } } - - struct ExportView: View { - @EnvironmentObject var caskManager: CaskManager - - @State private var fileExporterPresented = false - @State private var fileImporterPresented = false - - @State var showingExportError = false - @State var showingImportError = false - - @State var selectedExportFileType: CaskExportType = .txtFile - - var body: some View { - VStack(alignment: .leading) { - Text("Export a file containing all currently installed applications. This can be imported to another device.") - - Divider() - .padding(.vertical, 8) - - Button { - fileExporterPresented = true - } label: { - Label("Export apps to file", systemImage: "square.and.arrow.up") - } - .fileImporter( - isPresented: $fileExporterPresented, - allowedContentTypes: [.folder], - allowsMultipleSelection: false - ) { result in - switch result { - case .success(let url): - Task { @MainActor in - do { - try await CaskToFileManager.export(url: url[0], exportType: selectedExportFileType) - } catch { - logger.error("Failed to export casks. Error: \(error.localizedDescription)") - showingExportError = true - } - } - case .failure(let error): - logger.error("\(error.localizedDescription)") - } - } - .alert("Export failed", isPresented: $showingExportError, actions: {}) - - Picker("Export file type", selection: $selectedExportFileType) { - ForEach(CaskExportType.allCases) { type in - Text(LocalizedStringKey(type.rawValue)) - } - } - .frame(maxWidth: 300) - - Divider() - .padding(.vertical, 6) - - Button { - fileImporterPresented = true - } label: { - Label("Import apps", systemImage: "square.and.arrow.down") - } - .fileImporter( - isPresented: $fileImporterPresented, - allowedContentTypes: [.plainText, .data], - allowsMultipleSelection: false - ) { result in - switch result { - case .success(let url): - do { - let casks = try CaskToFileManager.readCaskFile(url: url[0]) - - installImported(casks: casks) - } catch { - logger.error("Failed to import cask. Reason: \(error.localizedDescription)") - showingImportError = true - } - case .failure(let error): - logger.error("\(error.localizedDescription)") - showingImportError = true - } - } - .alert("Import failed", isPresented: $showingImportError, actions: {}) - - notice(type: .note, "When importing a Brewfile only casks will be installed. Other items like formulae and taps will be skipped.") - } - } - - func installImported(casks: [String]) { - Task { - await CaskToFileManager.installImportedCasks(caskIds: casks, caskManager: caskManager) - } - } - } } struct BrewManagementView_Previews: PreviewProvider { diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 7a6584a..da773a3 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -199,7 +199,7 @@ } } }, - "**Note:** When importing a Brewfile only casks will be installed. Other items like formulae and taps will be skipped." : { + "**Tip:** You can also import apps from a Brewfile. However, only casks will be installed, other items like formulae and taps will be skipped." : { }, "**Warning**: Homebrew cache is shared between Homebrew installations. Deleting the cache will remove the cache for all installations!" : { @@ -2314,6 +2314,7 @@ }, "Export a file containing all currently installed applications. This can be imported to another device." : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -2346,11 +2347,15 @@ } } } + }, + "Export all apps currently installed by %@ to a file." : { + }, "Export all of your currently installed apps to a file. Import the file to another device to install them all. Useful when setting up a new Mac." : { }, "Export apps to file" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -2388,6 +2393,7 @@ }, "Export failed" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -2422,6 +2428,7 @@ } }, "Export file type" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -2837,6 +2844,7 @@ }, "Import apps" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -2874,6 +2882,7 @@ }, "Import failed" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -2908,6 +2917,7 @@ } }, "Import/Export apps" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -5757,6 +5767,7 @@ } }, "When importing a Brewfile only casks will be installed. Other items like formulae and taps will be skipped." : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { From c898feeea5de4f79d39758aac81b08298ab67634 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1rady=20Mil=C3=A1n?= <61704770+MilanVarady@users.noreply.github.com> Date: Wed, 1 Jan 2025 23:01:36 +0100 Subject: [PATCH 5/5] Rework brew management UI --- Applite.xcodeproj/project.pbxproj | 18 +- .../CaskManager+BrewFunctions.swift | 8 +- .../Cask Manager/CaskManager+LoadData.swift | 2 +- .../CaskManager+RefreshOutdated.swift | 4 +- .../Utilities/App Migration/ExportCasks.swift | 2 +- Applite/Utilities/Shell/Shell.swift | 4 +- .../Content View/ContentView+LoadCasks.swift | 2 +- .../BrewManagementView+ActionsView.swift | 207 ++++++++++++++ .../BrewManagementView+InfoView.swift | 61 ++++ .../Brew Management/BrewManagementView.swift | 60 ++++ .../Detail Views/BrewManagementView.swift | 261 ------------------ Localizable.xcstrings | 8 + 12 files changed, 364 insertions(+), 273 deletions(-) create mode 100644 Applite/Views/Detail Views/Brew Management/BrewManagementView+ActionsView.swift create mode 100644 Applite/Views/Detail Views/Brew Management/BrewManagementView+InfoView.swift create mode 100755 Applite/Views/Detail Views/Brew Management/BrewManagementView.swift delete mode 100755 Applite/Views/Detail Views/BrewManagementView.swift diff --git a/Applite.xcodeproj/project.pbxproj b/Applite.xcodeproj/project.pbxproj index a90b298..3835f16 100644 --- a/Applite.xcodeproj/project.pbxproj +++ b/Applite.xcodeproj/project.pbxproj @@ -106,6 +106,8 @@ 419256A42D25CFE600D9EF10 /* AppMigrationView+ExportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419256A32D25CFE600D9EF10 /* AppMigrationView+ExportView.swift */; }; 419256A62D25D00200D9EF10 /* AppMigrationView+ImportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419256A52D25D00200D9EF10 /* AppMigrationView+ImportView.swift */; }; 419256A82D25D10F00D9EF10 /* ExportFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419256A72D25D10F00D9EF10 /* ExportFile.swift */; }; + 419256AB2D25E19B00D9EF10 /* BrewManagementView+ActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419256AA2D25E19B00D9EF10 /* BrewManagementView+ActionsView.swift */; }; + 419256AD2D25E1F100D9EF10 /* BrewManagementView+InfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419256AC2D25E1F100D9EF10 /* BrewManagementView+InfoView.swift */; }; 419506A42964A27F00FE5802 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419506A32964A27F00FE5802 /* SetupView.swift */; }; 419506A62964A5EF00FE5802 /* BrewPathSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419506A52964A5EF00FE5802 /* BrewPathSelectorView.swift */; }; 4196C8F528F9CB2600EADDDA /* DiscoverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4196C8F428F9CB2600EADDDA /* DiscoverView.swift */; }; @@ -216,6 +218,8 @@ 419256A32D25CFE600D9EF10 /* AppMigrationView+ExportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppMigrationView+ExportView.swift"; sourceTree = ""; }; 419256A52D25D00200D9EF10 /* AppMigrationView+ImportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppMigrationView+ImportView.swift"; sourceTree = ""; }; 419256A72D25D10F00D9EF10 /* ExportFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportFile.swift; sourceTree = ""; }; + 419256AA2D25E19B00D9EF10 /* BrewManagementView+ActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BrewManagementView+ActionsView.swift"; sourceTree = ""; }; + 419256AC2D25E1F100D9EF10 /* BrewManagementView+InfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BrewManagementView+InfoView.swift"; sourceTree = ""; }; 419506A32964A27F00FE5802 /* SetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupView.swift; sourceTree = ""; }; 419506A52964A5EF00FE5802 /* BrewPathSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrewPathSelectorView.swift; sourceTree = ""; }; 419506A729696A5300FE5802 /* Applite-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "Applite-Info.plist"; sourceTree = SOURCE_ROOT; }; @@ -612,6 +616,16 @@ path = "App Migration"; sourceTree = ""; }; + 419256A92D25E17900D9EF10 /* Brew Management */ = { + isa = PBXGroup; + children = ( + 418989AC2A33A5C4004AC23B /* BrewManagementView.swift */, + 419256AC2D25E1F100D9EF10 /* BrewManagementView+InfoView.swift */, + 419256AA2D25E19B00D9EF10 /* BrewManagementView+ActionsView.swift */, + ); + path = "Brew Management"; + sourceTree = ""; + }; 4196C8F628F9CB4100EADDDA /* App Views */ = { isa = PBXGroup; children = ( @@ -634,7 +648,7 @@ 41B731382A879353008BF6B9 /* ActiveTasksView.swift */, 419256A22D25CFBF00D9EF10 /* App Migration */, 41857B742912D94A004A1894 /* CategoryView.swift */, - 418989AC2A33A5C4004AC23B /* BrewManagementView.swift */, + 419256A92D25E17900D9EF10 /* Brew Management */, ); path = "Detail Views"; sourceTree = ""; @@ -768,6 +782,7 @@ 419256182D1DEA4400D9EF10 /* AppView+ActionsView.swift in Sources */, 413F77A52972B2E70053349A /* DependencyManager.swift in Sources */, 418989B42A35D67C004AC23B /* isCommandLineToolsInstalled.swift in Sources */, + 419256AB2D25E19B00D9EF10 /* BrewManagementView+ActionsView.swift in Sources */, 419256352D1DF30700D9EF10 /* SettingsView+UpdateSettings.swift in Sources */, 4192563C2D1DF3C900D9EF10 /* ContentView+SidebarViews.swift in Sources */, 4192560F2D1CC09500D9EF10 /* DependencyError.swift in Sources */, @@ -833,6 +848,7 @@ 419256912D23F93E00D9EF10 /* Card.swift in Sources */, 419256252D1DF17F00D9EF10 /* SetupView+Welcome.swift in Sources */, 419256082D1C734600D9EF10 /* Shell.swift in Sources */, + 419256AD2D25E1F100D9EF10 /* BrewManagementView+InfoView.swift in Sources */, 4192563E2D1DF3E900D9EF10 /* ContentView+DetailView.swift in Sources */, 419256162D1DEA0A00D9EF10 /* AppView+IconAndDescriptionView.swift in Sources */, 419256942D24255000D9EF10 /* CaskLoadError.swift in Sources */, diff --git a/Applite/Model/Cask Models/Cask Manager/CaskManager+BrewFunctions.swift b/Applite/Model/Cask Models/Cask Manager/CaskManager+BrewFunctions.swift index c9e6273..0a64e66 100644 --- a/Applite/Model/Cask Models/Cask Manager/CaskManager+BrewFunctions.swift +++ b/Applite/Model/Cask Models/Cask Manager/CaskManager+BrewFunctions.swift @@ -85,7 +85,7 @@ extension CaskManager { runTask(for: cask) { cask.progressState = .busy(withTask: "Uninstalling") - var arguments: [String] = [cask.info.id] + var arguments: [String] = ["uninstall", "--cask", cask.info.id] // Add -- zap argument if zap { @@ -95,7 +95,7 @@ extension CaskManager { var output: String = "" do { - output = try await Shell.runBrewCommand("uninstall", arguments: arguments) + output = try await Shell.runBrewCommand(arguments) } catch { await self.showFailure( for: cask, @@ -127,7 +127,7 @@ extension CaskManager { var output: String = "" do { - output = try await Shell.runBrewCommand("upgrade", arguments: [cask.info.id]) + output = try await Shell.runBrewCommand(["upgrade", "--cask", cask.info.id]) } catch { await self.showFailure( for: cask, @@ -158,7 +158,7 @@ extension CaskManager { var output: String = "" do { - output = try await Shell.runBrewCommand("reinstall", arguments: [cask.info.id]) + output = try await Shell.runBrewCommand(["reinstall", "--cask", cask.info.id]) } catch { await self.showFailure( for: cask, diff --git a/Applite/Model/Cask Models/Cask Manager/CaskManager+LoadData.swift b/Applite/Model/Cask Models/Cask Manager/CaskManager+LoadData.swift index d3ef339..d8039ea 100644 --- a/Applite/Model/Cask Models/Cask Manager/CaskManager+LoadData.swift +++ b/Applite/Model/Cask Models/Cask Manager/CaskManager+LoadData.swift @@ -94,7 +94,7 @@ extension CaskManager { /// - Returns: A list of Cask ID's @Sendable func getInstalledCasks() async throws -> [String] { - let output = try await Shell.runAsync("\(BrewPaths.currentBrewExecutable) list --cask") + let output = try await Shell.runBrewCommand(["list", "--cask"]) if output.isEmpty { await Self.logger.notice("No installed casks were found") diff --git a/Applite/Model/Cask Models/Cask Manager/CaskManager+RefreshOutdated.swift b/Applite/Model/Cask Models/Cask Manager/CaskManager+RefreshOutdated.swift index c26e744..7fe71bb 100644 --- a/Applite/Model/Cask Models/Cask Manager/CaskManager+RefreshOutdated.swift +++ b/Applite/Model/Cask Models/Cask Manager/CaskManager+RefreshOutdated.swift @@ -9,13 +9,13 @@ import Foundation extension CaskManager { func refreshOutdated(greedy: Bool = false) async throws -> Void { - var arguments: [String] = ["-q"] + var arguments: [String] = ["outdated", "--cask", "-q"] if greedy { arguments.append("-g") } - let output = try await Shell.runBrewCommand("outdated", arguments: arguments) + let output = try await Shell.runBrewCommand(arguments) let outdatedCaskIDs = output .trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Applite/Utilities/App Migration/ExportCasks.swift b/Applite/Utilities/App Migration/ExportCasks.swift index 2f2e662..261c8cc 100644 --- a/Applite/Utilities/App Migration/ExportCasks.swift +++ b/Applite/Utilities/App Migration/ExportCasks.swift @@ -14,7 +14,7 @@ enum CaskImportError: Error { enum AppMigration { static func export() async throws -> ExportFile { - let output = try await Shell.runBrewCommand("list") + let output = try await Shell.runBrewCommand(["list", "--cask"]) let exportedCasks = output.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Applite/Utilities/Shell/Shell.swift b/Applite/Utilities/Shell/Shell.swift index 254a6ec..3c90c22 100644 --- a/Applite/Utilities/Shell/Shell.swift +++ b/Applite/Utilities/Shell/Shell.swift @@ -76,8 +76,8 @@ enum Shell { /// /// Using the `pty` option can leave unwanted characters in the output, use only when necessary @discardableResult - static func runBrewCommand(_ brewCommand: String, arguments: [String] = [], pty: Bool = false) async throws -> String { - let command = "\(BrewPaths.currentBrewExecutable) \(brewCommand) --cask \(arguments.joined(separator: " "))" + static func runBrewCommand(_ arguments: [String], pty: Bool = false) async throws -> String { + let command = "\(BrewPaths.currentBrewExecutable) \(arguments.joined(separator: " "))" return try await runAsync(command) } diff --git a/Applite/Views/Content View/ContentView+LoadCasks.swift b/Applite/Views/Content View/ContentView+LoadCasks.swift index 4e8d51f..ab3ad72 100644 --- a/Applite/Views/Content View/ContentView+LoadCasks.swift +++ b/Applite/Views/Content View/ContentView+LoadCasks.swift @@ -13,7 +13,7 @@ extension ContentView { loadAlert.show(title: "Couldn't load app catalog", message: DependencyManager.brokenPathOrIstallMessage) brokenInstall = true - let output = (try? await Shell.runAsync("\(BrewPaths.currentBrewExecutable) --version")) ?? "n/a" + let output = (try? await Shell.runBrewCommand(["--version"])) ?? "n/a" logger.error( """ diff --git a/Applite/Views/Detail Views/Brew Management/BrewManagementView+ActionsView.swift b/Applite/Views/Detail Views/Brew Management/BrewManagementView+ActionsView.swift new file mode 100644 index 0000000..cc92c6e --- /dev/null +++ b/Applite/Views/Detail Views/Brew Management/BrewManagementView+ActionsView.swift @@ -0,0 +1,207 @@ +// +// BrewManagementView+ActionsView.swift +// Applite +// +// Created by Milán Várady on 2025.01.01. +// + +import SwiftUI + +extension BrewManagementView { + struct ActionsView: View { + @Binding var modifyingBrew: Bool + let cardWidth: CGFloat + let cardPadding: CGFloat + let cardHeight: CGFloat = 210 + + @State var updateDone = false + @State var reinstallDone = false + + @State var isAppBrewInstalled = false + + @State var isPresentingReinstallConfirm = false + + @State var updateFailed = false + @State var reinstallFailed = false + + private struct Remark: Identifiable { + let title: LocalizedStringKey + let color: Color + let remark: LocalizedStringKey + + var id = UUID() + } + + var body: some View { + VStack(alignment: .leading) { + Text("Actions") + .font(.appliteSmallTitle) + + HStack { + ActionCard( + cardWidth: cardWidth, + cardHeight: cardHeight, + paddig: cardPadding, + actionSuccessful: $updateDone, + remarks: [ + .init(title: "Warning", color: .orange, remark: "All other app functions will be disabled during the update!") + ] + ) { + updateButton + } + + ActionCard( + cardWidth: cardWidth, + cardHeight: cardHeight, + paddig: cardPadding, + actionSuccessful: $reinstallDone, + remarks: [ + .init(title: "Note", color: .blue, remark: "This will (re)install \(Bundle.main.appName)'s Homebrew installation at: ~/Library/Application Support/\(Bundle.main.appName)/homebrew"), + .init(title: "Warning", color: .orange, remark: "After reinstalling, all currently installed apps will be unlinked from \(Bundle.main.appName). They won't be deleted, but you won't be able to update or uninstall them via \(Bundle.main.appName).") + ] + ) { + reinstallButton + } + } + .padding(.bottom, 10) + + // Progress indicator + if modifyingBrew { + HStack { + Text("In progress...") + .bold() + + SmallProgressView() + } + } + } + .task { + // Check if brew is installed in application support + isAppBrewInstalled = await isBrewPathValid(path: BrewPaths.getBrewExectuablePath(for: .appPath)) + } + } + + private struct ActionCard: View { + let cardWidth: CGFloat + let cardHeight: CGFloat + let paddig: CGFloat + @Binding var actionSuccessful: Bool + let remarks: [Remark] + @ViewBuilder let actionButton: ActionButton + + var body: some View { + Card(cardWidth: cardWidth, cardHeight: cardHeight, paddig: paddig) { + VStack(alignment: .leading) { + HStack { + actionButton + + // Success checkmark + if actionSuccessful { + Image(systemName: "checkmark.circle") + .imageScale(.large) + .foregroundStyle(.green) + } + } + .padding(.bottom, 12) + + VStack(alignment: .leading, spacing: 5) { + ForEach(remarks) { remark in + Text(remark.title) + .foregroundColor(remark.color) + .fontWeight(.bold) + + + Text(": ") + .foregroundColor(remark.color) + .fontWeight(.bold) + + + Text(remark.remark) + } + } + + Spacer() + } + } + } + } + + @MainActor + private var updateButton: some View { + Button { + withAnimation { + modifyingBrew = true + } + + Task { + logger.info("Updating brew started") + + do { + try await Shell.runBrewCommand(["update"]) + } catch { + logger.error("Brew update failed. Error: \(error.localizedDescription)") + updateFailed = true + } + + logger.info("Brew update successful") + + updateDone = true + + withAnimation { + modifyingBrew = false + } + + } + } label: { + Label("Update Homebrew", systemImage: "arrow.uturn.down.circle") + } + .controlSize(.large) + .disabled(modifyingBrew) + .padding(.trailing, 3) + .alert("Update failed", isPresented: $updateFailed, actions: {}) + } + + @MainActor + private var reinstallButton: some View { + Button(role: .destructive) { + isPresentingReinstallConfirm = true + } label: { + Label(isAppBrewInstalled ? "Reinstall Homebrew" : "Install Separate Brew", systemImage: "wrench.and.screwdriver") + } + .controlSize(.large) + .disabled(modifyingBrew) + .confirmationDialog("Are you sure you want to \(isAppBrewInstalled ? "re" : "")install Homebrew?", isPresented: $isPresentingReinstallConfirm) { + Button("Reinstall", role: .destructive) { + withAnimation { + modifyingBrew = true + } + + Task { + do { + try await DependencyManager.installHomebrew() + } catch { + reinstallFailed = true + } + + if !reinstallFailed { + reinstallDone = true + } + + withAnimation { + modifyingBrew = false + } + } + } + + Button("Cancel", role: .cancel) { } + } message: { + if isAppBrewInstalled { + Text("All currently installed apps will be unlinked from \(Bundle.main.appName).") + } else { + Text("A new Homebrew installation will be installed into ~/Library/Application Support/\(Bundle.main.appName)") + } + } + .alert("Reinstall failed", isPresented: $reinstallFailed, actions: { + Button("OK", role: .cancel) { } + }) + } + } +} diff --git a/Applite/Views/Detail Views/Brew Management/BrewManagementView+InfoView.swift b/Applite/Views/Detail Views/Brew Management/BrewManagementView+InfoView.swift new file mode 100644 index 0000000..be4e981 --- /dev/null +++ b/Applite/Views/Detail Views/Brew Management/BrewManagementView+InfoView.swift @@ -0,0 +1,61 @@ +// +// BrewManagementView+InfoView.swift +// Applite +// +// Created by Milán Várady on 2025.01.01. +// + +import SwiftUI + +extension BrewManagementView { + struct InfoView: View { + let cardWidth: CGFloat + let cardPadding: CGFloat + let cardHeight: CGFloat = 120 + + // These will be loaded in asynchronously + @State var homebrewVersion = "loading..." + @State var numberOfCasks = "loading..." + + var body: some View { + VStack(alignment: .leading) { + Text("Info") + .font(.appliteSmallTitle) + + HStack { + infoCard(title: "Homebrew Version", info: homebrewVersion) + + infoCard(title: "Apps Installed", info: numberOfCasks) + } + } + .task { + // Get version + guard let versionOutput = try? await Shell.runBrewCommand(["--version"]), + let version = versionOutput.firstMatch(of: /Homebrew ([\d\.]+)/), + let casksInstalled = try? await Shell.runAsync("\(BrewPaths.currentBrewExecutable) list --cask | wc -w") else { + + homebrewVersion = "Error" + numberOfCasks = "Error" + return + } + + homebrewVersion = String(version.1) + numberOfCasks = casksInstalled.trimmingCharacters(in: .whitespacesAndNewlines) + } + } + + private func infoCard(title: LocalizedStringKey, info: String) -> some View { + Card(cardWidth: cardWidth, cardHeight: cardHeight, paddig: cardPadding) { + VStack { + Text(title) + .font(.system(size: 16, weight: .bold)) + + Text(info) + .font(.system(size: 52, weight: .thin)) + + Spacer() + } + } + } + } +} diff --git a/Applite/Views/Detail Views/Brew Management/BrewManagementView.swift b/Applite/Views/Detail Views/Brew Management/BrewManagementView.swift new file mode 100755 index 0000000..fb580aa --- /dev/null +++ b/Applite/Views/Detail Views/Brew Management/BrewManagementView.swift @@ -0,0 +1,60 @@ +// +// BrewManagementView.swift +// Applite +// +// Created by Milán Várady on 2023. 06. 09.. +// + +import SwiftUI +import OSLog + +/// Displays info and provides tools to manage brew installation +struct BrewManagementView: View { + @Binding var modifyingBrew: Bool + + static let logger = Logger() + + let width: CGFloat = 640 + let columnSpacing: CGFloat = 40 + + var cardWidth: CGFloat { + (width - columnSpacing) / 2 + } + let cardPadding: CGFloat = 16 + + var body: some View { + ScrollView { + VStack { + VStack(alignment: .leading) { + titleAndDescription + + InfoView(cardWidth: cardWidth, cardPadding: cardPadding) + .padding(.vertical, 16) + + ActionsView(modifyingBrew: $modifyingBrew, cardWidth: cardWidth, cardPadding: cardPadding) + + Spacer() + } + .frame(width: width) + .padding(12) + } + .frame(maxWidth: .infinity) + } + } + + var titleAndDescription: some View { + VStack(alignment: .leading) { + Text("Manage Homebrew") + .font(.appliteMediumTitle) + .padding(.bottom, 2) + + Text("This application uses the [Homebrew](https://brew.sh/) (brew for short) package manager to download apps. Homebrew is a free and open source command line utility that can download useful developer tools as well as desktop applications.") + } + } +} + +struct BrewManagementView_Previews: PreviewProvider { + static var previews: some View { + BrewManagementView(modifyingBrew: .constant(false)) + } +} diff --git a/Applite/Views/Detail Views/BrewManagementView.swift b/Applite/Views/Detail Views/BrewManagementView.swift deleted file mode 100755 index 745e687..0000000 --- a/Applite/Views/Detail Views/BrewManagementView.swift +++ /dev/null @@ -1,261 +0,0 @@ -// -// BrewManagementView.swift -// Applite -// -// Created by Milán Várady on 2023. 06. 09.. -// - -import SwiftUI -import OSLog - -/// Displays info and provides tools to manage brew installation -struct BrewManagementView: View { - @Binding var modifyingBrew: Bool - - static let logger = Logger() - - var body: some View { - ScrollView { - VStack { - VStack(alignment: .leading) { - // Title - Text("Manage Homebrew") - .font(.appliteSmallTitle) - - Text("This application uses the [Homebrew](https://brew.sh/) (brew for short) package manager to download apps. Homebrew is a free and open source command line utility that can download useful developer tools as well as desktop applications.") - .padding(.bottom) - - section(title: "Info") { - InfoView() - } - .padding(.bottom) - - section(title: "Actions") { - ActionsView(modifyingBrew: $modifyingBrew) - } - .padding(.bottom) - - Spacer() - } - .frame(maxWidth: 800) - .padding(12) - } - .frame(maxWidth: .infinity) - } - } - - func section(title: LocalizedStringKey, @ViewBuilder content: ()->Content) -> some View { - VStack(alignment: .leading, spacing: 6) { - Text(title) - .font(.title) - - VStack(alignment: .leading) { - content() - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding() - .background(.quinary) - .clipShape(RoundedRectangle(cornerRadius: 10)) - } - } - - private enum NoticeType: String { - case note = "Note" - case warning = "Warning" - } - - static private func notice(type: NoticeType, _ body: LocalizedStringKey) -> some View { - Group { - Text(LocalizedStringKey(type.rawValue)) - .bold() - .foregroundColor(type == .note ? .blue : .orange) - + - Text(": ") - .bold() - .foregroundColor(type == .note ? .blue : .orange) - + - Text(body) - } - } - - struct InfoView: View { - // These will be loaded in asynchronously - @State var homebrewVersion = "loading..." - @State var numberOfCasks = "loading..." - - var body: some View { - // Show Homebrew version and number of installed casks - VStack(alignment: .leading) { - Group { - Text("Homebrew version: ") + - Text(homebrewVersion).fontWeight(.light) - } - - Group { - Text("Number of apps installed: ") + - Text(numberOfCasks).fontWeight(.light) - } - } - .task { - // Get version - guard let versionOutput = try? await Shell.runAsync("\(BrewPaths.currentBrewExecutable) --version"), - let version = versionOutput.firstMatch(of: /Homebrew ([\d\.]+)/), - let casksInstalled = try? await Shell.runAsync("\(BrewPaths.currentBrewExecutable) list --cask | wc -w") else { - homebrewVersion = "N/a" - numberOfCasks = "N/a" - return - } - - homebrewVersion = String(version.1) - numberOfCasks = casksInstalled.trimmingCharacters(in: .whitespacesAndNewlines) - } - } - } - - struct ActionsView: View { - @Binding var modifyingBrew: Bool - - @State var updateDone = false - @State var reinstallDone = false - - @State var isAppBrewInstalled = false - - @State var isPresentingReinstallConfirm = false - - @State var updateFailed = false - @State var reinstallFailed = false - - var body: some View { - HStack { - // Update brew button - updateButton - - // Checkmark if success - if updateDone { - Image(systemName: "checkmark") - .font(.system(size: 18, weight: .bold)) - .foregroundColor(.green) - } - } - - notice(type: .warning, "All other app functions will be disabled during the update!") - - Divider() - .padding(.vertical, 8) - - // Reinstall brew button - HStack { - reinstallButton - .task { - // Check if brew is installed in application support - isAppBrewInstalled = await isBrewPathValid(path: BrewPaths.getBrewExectuablePath(for: .appPath)) - } - - if reinstallDone { - Image(systemName: "checkmark") - .font(.system(size: 18, weight: .bold)) - .foregroundColor(.green) - } - } - - notice(type: .note, "This will (re)install \(Bundle.main.appName)'s Homebrew installation at: ~/Library/Application Support/\(Bundle.main.appName)/homebrew") - .padding(.bottom, 3) - - notice(type: .warning, "After reinstalling, all currently installed apps will be unlinked from \(Bundle.main.appName). They won't be deleted, but you won't be able to update or uninstall them via \(Bundle.main.appName).") - - // Progress indicator - if modifyingBrew { - HStack { - Text("In progress...") - .bold() - SmallProgressView() - } - } - } - - private var updateButton: some View { - Button { - withAnimation { - modifyingBrew = true - } - - Task { - logger.info("Updating brew started") - - do { - try await Shell.runAsync("\(BrewPaths.currentBrewExecutable) update") - } catch { - await MainActor.run { - logger.error("Brew update failed. Error: \(error.localizedDescription)") - updateFailed = true - } - } - - logger.info("Brew update successful") - - await MainActor.run { - updateDone = true - - withAnimation { - modifyingBrew = false - } - } - } - } label: { - Label("Update Homebrew", systemImage: "arrow.uturn.down.circle") - } - .disabled(modifyingBrew) - .padding(.trailing, 3) - .alert("Update failed", isPresented: $updateFailed, actions: {}) - } - - private var reinstallButton: some View { - Button { - isPresentingReinstallConfirm = true - } label: { - Label(isAppBrewInstalled ? "Reinstall Homebrew" : "Install Separate Brew", systemImage: "wrench.and.screwdriver") - } - .disabled(modifyingBrew) - .confirmationDialog("Are you sure you want to \(isAppBrewInstalled ? "re" : "")install Homebrew?", isPresented: $isPresentingReinstallConfirm) { - Button("Yes") { - withAnimation { - modifyingBrew = true - } - - Task { - do { - try await DependencyManager.installHomebrew() - } catch { - reinstallFailed = true - } - - if !reinstallFailed { - reinstallDone = true - } - - withAnimation { - modifyingBrew = false - } - } - } - - Button("Cancel", role: .cancel) { } - } message: { - if isAppBrewInstalled { - Text("All currently installed apps will be unlinked from \(Bundle.main.appName).") - } else { - Text("A new Homebrew installation will be installed into ~/Library/Application Support/\(Bundle.main.appName)") - } - } - .alert("Reinstall failed", isPresented: $reinstallFailed, actions: { - Button("OK", role: .cancel) { } - }) - } - } -} - -struct BrewManagementView_Previews: PreviewProvider { - static var previews: some View { - BrewManagementView(modifyingBrew: .constant(false)) - } -} diff --git a/Localizable.xcstrings b/Localizable.xcstrings index da773a3..4f91e31 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -1036,6 +1036,9 @@ } } } + }, + "Apps Installed" : { + }, "Apps with few downloads are hidden, consider turning off this filter" : { "localizations" : { @@ -2736,8 +2739,12 @@ } } } + }, + "Homebrew Version" : { + }, "Homebrew version: " : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -3674,6 +3681,7 @@ } }, "Number of apps installed: " : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : {