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" : {
|
"action.reveal-applications-folder-in-finder" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"cs" : {
|
"cs" : {
|
||||||
|
|
@ -15145,7 +15157,7 @@
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"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
|
let sessionConfiguration: URLSessionConfiguration = .default
|
||||||
if AppConstants.shared.proxySettings != nil
|
if AppConstants.shared.proxySettings != nil
|
||||||
|
|
@ -57,7 +61,7 @@ func downloadDataFromURL(_ url: URL, parameters: [URLQueryItem]? = nil) async th
|
||||||
throw DataDownloadingError.invalidURL
|
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"
|
request.httpMethod = "GET"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,30 @@ extension BrewPackagesTracker
|
||||||
case couldNotGetContentsOfApplicationsFolder(error: String)
|
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
|
/// 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
|
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")
|
AppConstants.shared.logger.debug("Successfully loaded all Casks JSON")
|
||||||
|
|
||||||
|
|
@ -66,9 +84,11 @@ extension BrewPackagesTracker
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download a JSON list of all available casks
|
/// 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
|
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)
|
.disabled(foundPackageSelection == nil)
|
||||||
|
.labelStyle(.titleOnly)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ struct PreviewPackageButton: View
|
||||||
|
|
||||||
openWindow(value: packageToPreview)
|
openWindow(value: packageToPreview)
|
||||||
} label: {
|
} label: {
|
||||||
Text("preview-package.action")
|
Label("preview-package.action", systemImage: "scope")
|
||||||
}
|
}
|
||||||
.keyboardShortcut("p", modifiers: [.command, .option])
|
.keyboardShortcut("p", modifiers: [.command, .option])
|
||||||
}
|
}
|
||||||
|
|
@ -37,7 +37,7 @@ struct PreviewPackageButtonWithCustomAction: View
|
||||||
{
|
{
|
||||||
action()
|
action()
|
||||||
} label: {
|
} label: {
|
||||||
Text("preview-package.action")
|
Label("preview-package.action", systemImage: "scope")
|
||||||
}
|
}
|
||||||
.keyboardShortcut("p", modifiers: [.command, .option])
|
.keyboardShortcut("p", modifiers: [.command, .option])
|
||||||
}
|
}
|
||||||
|
|
@ -57,7 +57,7 @@ struct PreviewPackageButtonWithCustomLabel: View
|
||||||
{
|
{
|
||||||
openWindow(value: packageToPreview)
|
openWindow(value: packageToPreview)
|
||||||
} label: {
|
} label: {
|
||||||
Text(label)
|
Label(label, systemImage: "scope")
|
||||||
}
|
}
|
||||||
.keyboardShortcut("p", modifiers: [.command, .option])
|
.keyboardShortcut("p", modifiers: [.command, .option])
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -133,13 +133,20 @@ struct StartPage: View
|
||||||
.transition(.push(from: .top))
|
.transition(.push(from: .top))
|
||||||
.task
|
.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 }))
|
List(brewPackagesTracker.adoptableCasks.sorted(by: { $0.caskName < $1.caskName }))
|
||||||
{ adoptableCask in
|
{ adoptableCask in
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 5)
|
AdoptablePackageListItem(adoptableCask: adoptableCask)
|
||||||
{
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.listStyle(.bordered(alternatesRowBackgrounds: true))
|
.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"),
|
.external(name: "ButtonKit"),
|
||||||
.package(product: "SwiftLintBuildToolPlugin", type: .plugin),
|
.package(product: "SwiftLintBuildToolPlugin", type: .plugin),
|
||||||
.external(name: "Defaults"),
|
.external(name: "Defaults"),
|
||||||
.external(name: "DefaultsMacros")
|
.external(name: "DefaultsMacros"),
|
||||||
|
.external(name: "ApplicationInspector")
|
||||||
], settings: .settings(configurations: [
|
], settings: .settings(configurations: [
|
||||||
.debug(
|
.debug(
|
||||||
name: "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/LaunchAtLogin-Modern", .upToNextMajor(from: "1.0.0")),
|
||||||
.package(url: "https://github.com/sindresorhus/Defaults", .upToNextMajor(from: "9.0.2")),
|
.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/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/Dean151/ButtonKit", .upToNextMajor(from: "0.6.1")),
|
||||||
.package(url: "https://github.com/SimplyDanny/SwiftLintPlugins", .upToNextMajor(from: "0.56.1")),
|
.package(url: "https://github.com/SimplyDanny/SwiftLintPlugins", .upToNextMajor(from: "0.56.1")),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue