Skip to content

Commit

Permalink
Merge pull request #83 from milanvarady/App-migration-ui
Browse files Browse the repository at this point in the history
App migration UI
  • Loading branch information
milanvarady authored Jan 1, 2025
2 parents 00cb819 + c898fee commit b6d5a6d
Show file tree
Hide file tree
Showing 59 changed files with 1,571 additions and 1,133 deletions.
128 changes: 102 additions & 26 deletions Applite.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions Applite/AppliteApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -42,7 +42,7 @@ struct AppliteApp: App {
WindowGroup {
if setupComplete {
ContentView()
.environmentObject(caskData)
.environmentObject(caskManager)
.frame(minWidth: 970, minHeight: 520)
.preferredColorScheme(selectedColorScheme)
} else {
Expand Down
14 changes: 14 additions & 0 deletions Applite/Extensions/FontExtension.swift
Original file line number Diff line number Diff line change
@@ -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)
}
31 changes: 31 additions & 0 deletions Applite/Model/Cask Models/Cask Manager/CaskLoadError.swift
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
289 changes: 289 additions & 0 deletions Applite/Model/Cask Models/Cask Manager/CaskManager+BrewFunctions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,289 @@
//
// 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] = ["uninstall", "--cask", cask.info.id]

// Add -- zap argument
if zap {
arguments.append("--zap")
}

var output: String = ""

do {
output = try await Shell.runBrewCommand(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", "--cask", 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", "--cask", 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"
)
}
}

/// 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)
}
}

// 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)
}
}
Loading

0 comments on commit b6d5a6d

Please sign in to comment.