mirror of https://github.com/buresdv/Cork
+ Data caching + Adoptable app icons
This commit is contained in:
parent
a1ec1f26bb
commit
b64c9003f1
|
|
@ -7604,6 +7604,18 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"action.reveal-%@-in-finder" : {
|
||||
"comment" : "A button that reveals the location of a specific adoptable package in the Finder. The argument is the name of the adoptable package.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Reveal %@ in Finder"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"action.reveal-applications-folder-in-finder" : {
|
||||
"localizations" : {
|
||||
"cs" : {
|
||||
|
|
@ -15145,7 +15157,7 @@
|
|||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Adoptable packages"
|
||||
"value" : "Adoptable apps"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,11 @@ enum DataDownloadingError: LocalizedError
|
|||
}
|
||||
}
|
||||
|
||||
func downloadDataFromURL(_ url: URL, parameters: [URLQueryItem]? = nil) async throws(DataDownloadingError) -> Data
|
||||
func downloadDataFromURL(
|
||||
_ url: URL,
|
||||
parameters: [URLQueryItem]? = nil,
|
||||
cachingPolicy: URLRequest.CachePolicy = .useProtocolCachePolicy
|
||||
) async throws(DataDownloadingError) -> Data
|
||||
{
|
||||
let sessionConfiguration: URLSessionConfiguration = .default
|
||||
if AppConstants.shared.proxySettings != nil
|
||||
|
|
@ -57,7 +61,7 @@ func downloadDataFromURL(_ url: URL, parameters: [URLQueryItem]? = nil) async th
|
|||
throw DataDownloadingError.invalidURL
|
||||
}
|
||||
|
||||
var request: URLRequest = .init(url: modifiedURL, cachePolicy: .useProtocolCachePolicy, timeoutInterval: 10)
|
||||
var request: URLRequest = .init(url: modifiedURL, cachePolicy: cachingPolicy, timeoutInterval: 10)
|
||||
|
||||
request.httpMethod = "GET"
|
||||
|
||||
|
|
|
|||
|
|
@ -18,12 +18,30 @@ extension BrewPackagesTracker
|
|||
case couldNotGetContentsOfApplicationsFolder(error: String)
|
||||
}
|
||||
|
||||
enum HomebrewDataCacheUsePolicy
|
||||
{
|
||||
case useCachedData
|
||||
case forceDataFetch
|
||||
|
||||
var cachePolicy: URLRequest.CachePolicy
|
||||
{
|
||||
switch self {
|
||||
case .useCachedData:
|
||||
return .returnCacheDataElseLoad
|
||||
case .forceDataFetch:
|
||||
return .reloadIgnoringLocalCacheData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a list of casks that can be adopted into the Homebrew updating mechanism
|
||||
func getAdoptableCasks() async throws(AdoptableCasksLoadingError) -> Set<AdoptableCaskComparable>
|
||||
func getAdoptableCasks(
|
||||
cacheUsePolicy: HomebrewDataCacheUsePolicy
|
||||
) async throws(AdoptableCasksLoadingError) -> Set<AdoptableCaskComparable>
|
||||
{
|
||||
do
|
||||
{
|
||||
let allCasksJson: Data = try await self.loadAllCasksJson()
|
||||
let allCasksJson: Data = try await self.loadAllCasksJson(cachingPolicy: cacheUsePolicy)
|
||||
|
||||
AppConstants.shared.logger.debug("Successfully loaded all Casks JSON")
|
||||
|
||||
|
|
@ -66,9 +84,11 @@ extension BrewPackagesTracker
|
|||
}
|
||||
|
||||
/// Download a JSON list of all available casks
|
||||
private func loadAllCasksJson() async throws(DataDownloadingError) -> Data
|
||||
private func loadAllCasksJson(
|
||||
cachingPolicy: HomebrewDataCacheUsePolicy
|
||||
) async throws(DataDownloadingError) -> Data
|
||||
{
|
||||
return try await downloadDataFromURL(.init(string: "https://formulae.brew.sh/api/cask.json")!)
|
||||
return try await downloadDataFromURL(.init(string: "https://formulae.brew.sh/api/cask.json")!, cachingPolicy: cachingPolicy.cachePolicy)
|
||||
}
|
||||
|
||||
enum CasksJsonParsingError: Error
|
||||
|
|
|
|||
|
|
@ -0,0 +1,124 @@
|
|||
//
|
||||
// Application.swift
|
||||
// Cork
|
||||
//
|
||||
// Created by David Bureš - P on 07.10.2025.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import CorkShared
|
||||
|
||||
// TODO: Move this over to the `ApplicationInspector` external library once we figure out how to use Tuist projects as external dependencies
|
||||
|
||||
public struct Application
|
||||
{
|
||||
public let name: String
|
||||
|
||||
public let iconPath: URL?
|
||||
|
||||
public let iconImage: Image?
|
||||
|
||||
public enum ApplicationInitializationError: LocalizedError
|
||||
{
|
||||
public enum MandatoryAppInformation: String, Sendable
|
||||
{
|
||||
case name
|
||||
|
||||
public var description: String
|
||||
{
|
||||
switch self {
|
||||
case .name:
|
||||
return "Name"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case applicationExecutableNotReadable(checkedPath: String)
|
||||
case couldNotAccessApplicationExecutable(error: Error)
|
||||
case couldNotReadBundle(applicationPath: String)
|
||||
case couldNotGetInfoDictionary
|
||||
case couldNotGetMandatoryAppInformation(_ mandatoryInformation: MandatoryAppInformation)
|
||||
|
||||
public var errorDescription: String?
|
||||
{
|
||||
switch self {
|
||||
case .applicationExecutableNotReadable(let checkedPath):
|
||||
return "Couldn't read application executable at \(checkedPath)"
|
||||
case .couldNotAccessApplicationExecutable(let error):
|
||||
return "Couldn't read application executable: \(error)"
|
||||
case .couldNotReadBundle(let applicationPath):
|
||||
return "Couldn't read application bundle at \(applicationPath)"
|
||||
case .couldNotGetInfoDictionary:
|
||||
return "Couldn't read application info.plist"
|
||||
case .couldNotGetMandatoryAppInformation(let mandatoryInformation):
|
||||
return "Couldn't read mandatory app information: \(mandatoryInformation.description)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public init(from appURL: URL) throws(ApplicationInitializationError)
|
||||
{
|
||||
do
|
||||
{
|
||||
guard FileManager.default.isReadableFile(atPath: appURL.path) == true else
|
||||
{
|
||||
throw ApplicationInitializationError.applicationExecutableNotReadable(checkedPath: appURL.path)
|
||||
}
|
||||
|
||||
guard let appBundle: Bundle = .init(url: appURL)
|
||||
else
|
||||
{
|
||||
throw ApplicationInitializationError.couldNotReadBundle(applicationPath: appURL.absoluteString)
|
||||
}
|
||||
|
||||
AppConstants.shared.logger.debug("Will try to initialize and App object form bundle \(appBundle)")
|
||||
|
||||
guard let appBundleInfoDictionary: [String: Any] = appBundle.infoDictionary
|
||||
else
|
||||
{
|
||||
throw ApplicationInitializationError.couldNotGetInfoDictionary
|
||||
}
|
||||
|
||||
guard let appName: String = Application.getAppName(fromInfoDictionary: appBundleInfoDictionary) else
|
||||
{
|
||||
throw ApplicationInitializationError.couldNotGetMandatoryAppInformation(.name)
|
||||
}
|
||||
|
||||
self.name = appName
|
||||
|
||||
self.iconPath = Application.getAppIconPath(fromInfoDictionary: appBundleInfoDictionary, appBundle: appBundle)
|
||||
|
||||
if let iconPath = self.iconPath
|
||||
{
|
||||
self.iconImage = .init(
|
||||
nsImage: .init(byReferencing: iconPath)
|
||||
)
|
||||
}
|
||||
else
|
||||
{
|
||||
self.iconImage = nil
|
||||
}
|
||||
}
|
||||
catch let applicationDirectoryAccessError
|
||||
{
|
||||
throw .couldNotAccessApplicationExecutable(error: applicationDirectoryAccessError)
|
||||
}
|
||||
}
|
||||
|
||||
private static func getAppName(fromInfoDictionary infoDictionary: [String: Any]) -> String?
|
||||
{
|
||||
return infoDictionary["CFBundleName"] as? String
|
||||
}
|
||||
|
||||
private static func getAppIconPath(fromInfoDictionary infoDictionary: [String: Any], appBundle: Bundle) -> URL?
|
||||
{
|
||||
guard let iconFileName: String = infoDictionary["CFBundleIconFile"] as? String
|
||||
else
|
||||
{
|
||||
return nil
|
||||
}
|
||||
|
||||
return appBundle.resourceURL?.appendingPathComponent(iconFileName, conformingTo: .icns)
|
||||
}
|
||||
}
|
||||
|
|
@ -116,6 +116,7 @@ struct InstallationInitialView: View
|
|||
))
|
||||
}
|
||||
.disabled(foundPackageSelection == nil)
|
||||
.labelStyle(.titleOnly)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ struct PreviewPackageButton: View
|
|||
|
||||
openWindow(value: packageToPreview)
|
||||
} label: {
|
||||
Text("preview-package.action")
|
||||
Label("preview-package.action", systemImage: "scope")
|
||||
}
|
||||
.keyboardShortcut("p", modifiers: [.command, .option])
|
||||
}
|
||||
|
|
@ -37,7 +37,7 @@ struct PreviewPackageButtonWithCustomAction: View
|
|||
{
|
||||
action()
|
||||
} label: {
|
||||
Text("preview-package.action")
|
||||
Label("preview-package.action", systemImage: "scope")
|
||||
}
|
||||
.keyboardShortcut("p", modifiers: [.command, .option])
|
||||
}
|
||||
|
|
@ -57,7 +57,7 @@ struct PreviewPackageButtonWithCustomLabel: View
|
|||
{
|
||||
openWindow(value: packageToPreview)
|
||||
} label: {
|
||||
Text(label)
|
||||
Label(label, systemImage: "scope")
|
||||
}
|
||||
.keyboardShortcut("p", modifiers: [.command, .option])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,13 +133,20 @@ struct StartPage: View
|
|||
.transition(.push(from: .top))
|
||||
.task
|
||||
{
|
||||
do
|
||||
if brewPackagesTracker.adoptableCasks.isEmpty
|
||||
{
|
||||
brewPackagesTracker.adoptableCasks = try await brewPackagesTracker.getAdoptableCasks()
|
||||
do
|
||||
{
|
||||
brewPackagesTracker.adoptableCasks = try await brewPackagesTracker.getAdoptableCasks(cacheUsePolicy: .useCachedData)
|
||||
}
|
||||
catch let adoptablePackagesLoadingError
|
||||
{
|
||||
AppConstants.shared.logger.error("Failed to load adoptable casks: \(adoptablePackagesLoadingError)")
|
||||
}
|
||||
}
|
||||
catch let adoptablePackagesLoadingError
|
||||
else
|
||||
{
|
||||
AppConstants.shared.logger.error("Failed to load adoptable casks: \(adoptablePackagesLoadingError)")
|
||||
AppConstants.shared.logger.debug("Adoptable casks are already loaded, will not reload")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,24 +34,7 @@ struct AdoptablePackagesBox: View
|
|||
{
|
||||
List(brewPackagesTracker.adoptableCasks.sorted(by: { $0.caskName < $1.caskName }))
|
||||
{ adoptableCask in
|
||||
HStack(alignment: .firstTextBaseline, spacing: 5)
|
||||
{
|
||||
Text(adoptableCask.caskExecutable)
|
||||
|
||||
Text("(\(adoptableCask.caskName))")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.contextMenu
|
||||
{
|
||||
|
||||
PreviewPackageButtonWithCustomLabel(label: "action.preview-package-app-would-be-adopted-as.\(adoptableCask.caskName)", packageToPreview: .init(name: adoptableCask.caskName, type: .cask, installedIntentionally: true))
|
||||
|
||||
RevealInFinderButtonWithArbitraryAction
|
||||
{
|
||||
URL.applicationDirectory.appendingPathComponent(adoptableCask.caskExecutable, conformingTo: .executable).revealInFinder(.openParentDirectoryAndHighlightTarget)
|
||||
}
|
||||
}
|
||||
AdoptablePackageListItem(adoptableCask: adoptableCask)
|
||||
}
|
||||
.listStyle(.bordered(alternatesRowBackgrounds: true))
|
||||
}
|
||||
|
|
@ -99,3 +82,54 @@ struct AdoptablePackagesBox: View
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AdoptablePackageListItem: View
|
||||
{
|
||||
let adoptableCask: BrewPackagesTracker.AdoptableCaskComparable
|
||||
|
||||
var adoptableCaskAppLocation: URL
|
||||
{
|
||||
return URL.applicationDirectory.appendingPathComponent(adoptableCask.caskExecutable, conformingTo: .application)
|
||||
}
|
||||
|
||||
var adoptableCaskApp: Application?
|
||||
{
|
||||
return try? .init(from: adoptableCaskAppLocation)
|
||||
}
|
||||
|
||||
var body: some View
|
||||
{
|
||||
HStack(alignment: .center, spacing: 5)
|
||||
{
|
||||
if let adoptableCaskApp
|
||||
{
|
||||
if let adoptableCaskIcon = adoptableCaskApp.iconImage
|
||||
{
|
||||
adoptableCaskIcon
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 35)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(alignment: .firstTextBaseline, spacing: 5)
|
||||
{
|
||||
Text(adoptableCask.caskExecutable)
|
||||
|
||||
Text("(\(adoptableCask.caskName))")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.contextMenu
|
||||
{
|
||||
PreviewPackageButtonWithCustomLabel(label: "action.preview-package-app-would-be-adopted-as.\(adoptableCask.caskName)", packageToPreview: .init(name: adoptableCask.caskName, type: .cask, installedIntentionally: true))
|
||||
|
||||
Button {
|
||||
adoptableCaskAppLocation.revealInFinder(.openParentDirectoryAndHighlightTarget)
|
||||
} label: {
|
||||
Label("action.reveal-\(adoptableCask.caskExecutable)-in-finder", systemImage: "finder")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,8 @@ func corkTarget(configureWithSelfCompiled: Bool) -> ProjectDescription.Target {
|
|||
.external(name: "ButtonKit"),
|
||||
.package(product: "SwiftLintBuildToolPlugin", type: .plugin),
|
||||
.external(name: "Defaults"),
|
||||
.external(name: "DefaultsMacros")
|
||||
.external(name: "DefaultsMacros"),
|
||||
.external(name: "ApplicationInspector")
|
||||
], settings: .settings(configurations: [
|
||||
.debug(
|
||||
name: "Debug",
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ let package = Package(
|
|||
.package(url: "https://github.com/sindresorhus/LaunchAtLogin-Modern", .upToNextMajor(from: "1.0.0")),
|
||||
.package(url: "https://github.com/sindresorhus/Defaults", .upToNextMajor(from: "9.0.2")),
|
||||
.package(url: "https://github.com/buresdv/DavidFoundation", .upToNextMajor(from: "2.0.1")),
|
||||
.package(url: "https://github.com/buresdv/ApplicationInspector", branch: "master"),
|
||||
.package(url: "https://github.com/Dean151/ButtonKit", .upToNextMajor(from: "0.6.1")),
|
||||
.package(url: "https://github.com/SimplyDanny/SwiftLintPlugins", .upToNextMajor(from: "0.56.1")),
|
||||
],
|
||||
|
|
|
|||
Loading…
Reference in New Issue