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" : {