900 lines
33 KiB
Swift
900 lines
33 KiB
Swift
import AppKit
|
|
import Foundation
|
|
import SwiftUI
|
|
|
|
struct MenuBarIconGalleryCategory: Identifiable, Codable, Equatable, Hashable {
|
|
let id: String
|
|
let title: String
|
|
}
|
|
|
|
struct MenuBarIconGalleryAsset: Identifiable, Codable, Equatable, Hashable {
|
|
let id: String
|
|
let title: String
|
|
let categoryID: String
|
|
let version: String
|
|
let previewPath: String?
|
|
let framePaths: [String]?
|
|
let framePathPattern: String?
|
|
let archivePath: String?
|
|
let archiveFramePathPattern: String?
|
|
let frameCount: Int
|
|
let frameDuration: TimeInterval
|
|
}
|
|
|
|
struct MenuBarIconGalleryCatalog: Codable, Equatable {
|
|
let schemaVersion: Int
|
|
let generatedAt: Date?
|
|
let baseURL: URL?
|
|
let categories: [MenuBarIconGalleryCategory]
|
|
let assets: [MenuBarIconGalleryAsset]
|
|
}
|
|
|
|
struct MenuBarIconRemoteAssetSelection: Codable, Equatable, Hashable {
|
|
let id: String
|
|
let version: String
|
|
let displayName: String
|
|
let frameFileNames: [String]
|
|
let frameDuration: TimeInterval
|
|
}
|
|
|
|
enum MenuBarIconRemoteAssetReference {
|
|
static let fileNamePrefix = "remote-asset:"
|
|
|
|
static func fileName(assetID: String, version: String) -> String {
|
|
"\(fileNamePrefix)\(assetID)#\(version)"
|
|
}
|
|
|
|
static func parse(_ fileName: String) -> (assetID: String, version: String)? {
|
|
guard fileName.hasPrefix(fileNamePrefix) else {
|
|
return nil
|
|
}
|
|
|
|
let value = fileName.dropFirst(fileNamePrefix.count)
|
|
let parts = value.split(separator: "#", maxSplits: 1).map(String.init)
|
|
guard parts.count == 2, !parts[0].isEmpty, !parts[1].isEmpty else {
|
|
return nil
|
|
}
|
|
|
|
return (assetID: parts[0], version: parts[1])
|
|
}
|
|
}
|
|
|
|
enum MenuBarIconGalleryStatus: Equatable {
|
|
case idle
|
|
case loading
|
|
case loaded(Date)
|
|
case failed(String)
|
|
|
|
var isLoading: Bool {
|
|
if case .loading = self {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
}
|
|
|
|
enum MenuBarIconGalleryAssetState: Equatable {
|
|
case available
|
|
case downloading
|
|
case cached
|
|
case failed(String)
|
|
}
|
|
|
|
enum MenuBarIconGallerySource: Equatable {
|
|
case production(URL)
|
|
case localDevelopment(URL)
|
|
|
|
var url: URL {
|
|
switch self {
|
|
case let .production(url), let .localDevelopment(url):
|
|
return url
|
|
}
|
|
}
|
|
|
|
var allowsFileResources: Bool {
|
|
switch self {
|
|
case .production:
|
|
return false
|
|
case .localDevelopment:
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
struct MenuBarIconGalleryProviderConfiguration {
|
|
static let productionCatalogURL = URL(string: "https://mactools.ggbond.app/icon-gallery/catalog.json")!
|
|
|
|
static func defaultSource(environment: [String: String] = ProcessInfo.processInfo.environment) -> MenuBarIconGallerySource {
|
|
#if DEBUG
|
|
if let rawURL = environment["MACTOOLS_ICON_CATALOG_URL"],
|
|
let url = URL(string: rawURL) {
|
|
return source(for: url)
|
|
}
|
|
|
|
if FileManager.default.fileExists(atPath: defaultLocalDevelopmentCatalogURL.path) {
|
|
return .localDevelopment(defaultLocalDevelopmentCatalogURL)
|
|
}
|
|
#endif
|
|
|
|
return .production(productionCatalogURL)
|
|
}
|
|
|
|
private static func source(for url: URL) -> MenuBarIconGallerySource {
|
|
if url.isFileURL {
|
|
return .localDevelopment(url)
|
|
}
|
|
|
|
return .production(url)
|
|
}
|
|
|
|
static var defaultLocalDevelopmentCatalogURL: URL {
|
|
URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true)
|
|
.appendingPathComponent("build", isDirectory: true)
|
|
.appendingPathComponent("LocalIconGallery", isDirectory: true)
|
|
.appendingPathComponent("catalog.dev.json", isDirectory: false)
|
|
}
|
|
}
|
|
|
|
struct MenuBarIconGallerySnapshot: Equatable {
|
|
let catalog: MenuBarIconGalleryCatalog
|
|
let catalogURL: URL
|
|
let contentBaseURL: URL
|
|
let allowsFileResources: Bool
|
|
let loadedAt: Date
|
|
}
|
|
|
|
@MainActor
|
|
protocol MenuBarIconGalleryProviding {
|
|
func loadCatalog() async throws -> MenuBarIconGallerySnapshot
|
|
}
|
|
|
|
struct MenuBarIconGalleryProvider: MenuBarIconGalleryProviding {
|
|
private let source: MenuBarIconGallerySource
|
|
private let session: URLSession
|
|
private let now: () -> Date
|
|
|
|
init(
|
|
source: MenuBarIconGallerySource = MenuBarIconGalleryProviderConfiguration.defaultSource(),
|
|
session: URLSession = .shared,
|
|
now: @escaping () -> Date = Date.init
|
|
) {
|
|
self.source = source
|
|
self.session = session
|
|
self.now = now
|
|
}
|
|
|
|
func loadCatalog() async throws -> MenuBarIconGallerySnapshot {
|
|
let data: Data
|
|
let catalogURL = source.url
|
|
|
|
if catalogURL.isFileURL {
|
|
data = try Data(contentsOf: catalogURL)
|
|
} else {
|
|
var request = URLRequest(url: catalogURL)
|
|
request.timeoutInterval = 15
|
|
request.cachePolicy = .reloadRevalidatingCacheData
|
|
let (responseData, response) = try await session.data(for: request)
|
|
|
|
if let httpResponse = response as? HTTPURLResponse,
|
|
!(200..<300).contains(httpResponse.statusCode) {
|
|
throw MenuBarIconGalleryError.httpStatus(httpResponse.statusCode)
|
|
}
|
|
|
|
data = responseData
|
|
}
|
|
|
|
let catalog = try MenuBarIconGalleryCoding.decoder.decode(MenuBarIconGalleryCatalog.self, from: data)
|
|
try MenuBarIconGalleryValidator.validate(catalog)
|
|
|
|
let contentBaseURL = catalog.baseURL ?? catalogURL.deletingLastPathComponent().appendingPathComponent("", isDirectory: true)
|
|
return MenuBarIconGallerySnapshot(
|
|
catalog: catalog,
|
|
catalogURL: catalogURL,
|
|
contentBaseURL: contentBaseURL,
|
|
allowsFileResources: source.allowsFileResources,
|
|
loadedAt: now()
|
|
)
|
|
}
|
|
}
|
|
|
|
enum MenuBarIconGalleryCoding {
|
|
static let decoder: JSONDecoder = {
|
|
let decoder = JSONDecoder()
|
|
decoder.dateDecodingStrategy = .iso8601
|
|
return decoder
|
|
}()
|
|
}
|
|
|
|
enum MenuBarIconGalleryValidator {
|
|
static let maximumFrameCount = 120
|
|
|
|
static func validate(_ catalog: MenuBarIconGalleryCatalog) throws {
|
|
guard catalog.schemaVersion == 1 else {
|
|
throw MenuBarIconGalleryError.unsupportedSchemaVersion(catalog.schemaVersion)
|
|
}
|
|
|
|
var categoryIDs = Set<String>()
|
|
for category in catalog.categories {
|
|
guard !category.id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
|
!category.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
else {
|
|
throw MenuBarIconGalleryError.invalidCatalog
|
|
}
|
|
guard categoryIDs.insert(category.id).inserted else {
|
|
throw MenuBarIconGalleryError.invalidCatalog
|
|
}
|
|
}
|
|
|
|
var assetIDs = Set<String>()
|
|
for asset in catalog.assets {
|
|
guard assetIDs.insert(asset.id).inserted else {
|
|
throw MenuBarIconGalleryError.invalidCatalog
|
|
}
|
|
guard categoryIDs.contains(asset.categoryID),
|
|
!asset.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
|
!asset.version.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
|
(1...maximumFrameCount).contains(asset.frameCount),
|
|
asset.frameDuration > 0
|
|
else {
|
|
throw MenuBarIconGalleryError.invalidCatalog
|
|
}
|
|
guard asset.archivePath != nil || asset.framePaths != nil || asset.framePathPattern != nil else {
|
|
throw MenuBarIconGalleryError.invalidCatalog
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
struct MenuBarIconRemoteAssetStore {
|
|
static let maximumFrameFileSize = 1 * 1024 * 1024
|
|
static let maximumArchiveFileSize = 25 * 1024 * 1024
|
|
static let maximumDecodedPixelArea = 512 * 512
|
|
|
|
let rootDirectory: URL
|
|
let fileManager: FileManager
|
|
let session: URLSession
|
|
|
|
init(
|
|
rootDirectory: URL,
|
|
fileManager: FileManager = .default,
|
|
session: URLSession = .shared
|
|
) {
|
|
self.rootDirectory = rootDirectory
|
|
self.fileManager = fileManager
|
|
self.session = session
|
|
}
|
|
|
|
func isAssetCached(_ asset: MenuBarIconGalleryAsset) -> Bool {
|
|
let frameNames = expectedFrameFileNames(frameCount: asset.frameCount)
|
|
return frameNames.allSatisfy { frameName in
|
|
fileManager.fileExists(atPath: framesDirectory(assetID: asset.id, version: asset.version).appendingPathComponent(frameName).path)
|
|
}
|
|
}
|
|
|
|
func installAsset(
|
|
_ asset: MenuBarIconGalleryAsset,
|
|
contentBaseURL: URL,
|
|
allowsFileResources: Bool
|
|
) async throws -> MenuBarIconRemoteAssetSelection {
|
|
let stagingDirectory = rootDirectory
|
|
.appendingPathComponent("Staging", isDirectory: true)
|
|
.appendingPathComponent("\(safePathComponent(asset.id))-\(safePathComponent(asset.version))-\(UUID().uuidString)", isDirectory: true)
|
|
let stagingFramesDirectory = stagingDirectory.appendingPathComponent("frames", isDirectory: true)
|
|
let destinationDirectory = assetDirectory(assetID: asset.id, version: asset.version)
|
|
let destinationFramesDirectory = destinationDirectory.appendingPathComponent("frames", isDirectory: true)
|
|
|
|
try fileManager.createDirectory(at: stagingFramesDirectory, withIntermediateDirectories: true)
|
|
|
|
do {
|
|
if let archivePath = asset.archivePath {
|
|
try await installArchive(
|
|
archivePath: archivePath,
|
|
asset: asset,
|
|
contentBaseURL: contentBaseURL,
|
|
allowsFileResources: allowsFileResources,
|
|
stagingFramesDirectory: stagingFramesDirectory,
|
|
stagingDirectory: stagingDirectory
|
|
)
|
|
} else {
|
|
try await installIndividualFrames(
|
|
asset: asset,
|
|
contentBaseURL: contentBaseURL,
|
|
allowsFileResources: allowsFileResources,
|
|
stagingFramesDirectory: stagingFramesDirectory
|
|
)
|
|
}
|
|
|
|
try validateFrames(in: stagingFramesDirectory, frameCount: asset.frameCount)
|
|
try replaceDirectory(at: destinationDirectory, with: stagingFramesDirectory, destinationFramesDirectory: destinationFramesDirectory)
|
|
try removeEmptyStagingIfNeeded(stagingDirectory)
|
|
|
|
return MenuBarIconRemoteAssetSelection(
|
|
id: asset.id,
|
|
version: asset.version,
|
|
displayName: asset.title,
|
|
frameFileNames: expectedFrameFileNames(frameCount: asset.frameCount),
|
|
frameDuration: asset.frameDuration
|
|
)
|
|
} catch {
|
|
try? fileManager.removeItem(at: stagingDirectory)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func pruneRemoteAssets(keeping selection: MenuBarIconRemoteAssetSelection?) {
|
|
guard let selection else {
|
|
try? fileManager.removeItem(at: rootDirectory)
|
|
return
|
|
}
|
|
|
|
let keepPath = assetDirectory(assetID: selection.id, version: selection.version).standardizedFileURL.path
|
|
let protectedNames = Set(["Staging", safePathComponent(selection.id)])
|
|
|
|
guard let assetDirectories = try? fileManager.contentsOfDirectory(
|
|
at: rootDirectory,
|
|
includingPropertiesForKeys: [.isDirectoryKey],
|
|
options: [.skipsHiddenFiles]
|
|
) else {
|
|
return
|
|
}
|
|
|
|
for assetDirectory in assetDirectories {
|
|
guard assetDirectory.lastPathComponent != "Staging" else {
|
|
try? fileManager.removeItem(at: assetDirectory)
|
|
continue
|
|
}
|
|
guard !protectedNames.contains(assetDirectory.lastPathComponent) else {
|
|
continue
|
|
}
|
|
try? fileManager.removeItem(at: assetDirectory)
|
|
}
|
|
|
|
let selectedAssetRoot = rootDirectory.appendingPathComponent(safePathComponent(selection.id), isDirectory: true)
|
|
guard let versionDirectories = try? fileManager.contentsOfDirectory(
|
|
at: selectedAssetRoot,
|
|
includingPropertiesForKeys: [.isDirectoryKey],
|
|
options: [.skipsHiddenFiles]
|
|
) else {
|
|
return
|
|
}
|
|
|
|
for versionDirectory in versionDirectories where versionDirectory.standardizedFileURL.path != keepPath {
|
|
try? fileManager.removeItem(at: versionDirectory)
|
|
}
|
|
}
|
|
|
|
func frameURLs(for selection: MenuBarIconRemoteAssetSelection) -> [URL] {
|
|
let directory = framesDirectory(assetID: selection.id, version: selection.version)
|
|
return selection.frameFileNames.map { directory.appendingPathComponent($0, isDirectory: false) }
|
|
}
|
|
|
|
func hasFrames(for selection: MenuBarIconRemoteAssetSelection) -> Bool {
|
|
frameURLs(for: selection).allSatisfy { fileManager.fileExists(atPath: $0.path) }
|
|
}
|
|
|
|
func loadPreviewImage(
|
|
for asset: MenuBarIconGalleryAsset,
|
|
contentBaseURL: URL,
|
|
allowsFileResources: Bool
|
|
) async throws -> NSImage {
|
|
let path = asset.previewPath ?? asset.framePaths?.first ?? resolvedFramePath(pattern: asset.framePathPattern, index: 0)
|
|
guard let path else {
|
|
throw MenuBarIconGalleryError.invalidAsset(asset.id)
|
|
}
|
|
|
|
let url = try resolvedResourceURL(path: path, contentBaseURL: contentBaseURL, allowsFileResources: allowsFileResources)
|
|
let data = try await data(from: url)
|
|
guard data.count <= Self.maximumFrameFileSize,
|
|
let sourceImage = NSImage(data: data),
|
|
isDecodedImageSizeAcceptable(sourceImage),
|
|
let image = MenuBarIconProcessing.renderedImage(from: sourceImage)
|
|
else {
|
|
throw MenuBarIconGalleryError.invalidFrame(asset.id)
|
|
}
|
|
|
|
return image
|
|
}
|
|
|
|
func framesDirectory(assetID: String, version: String) -> URL {
|
|
assetDirectory(assetID: assetID, version: version)
|
|
.appendingPathComponent("frames", isDirectory: true)
|
|
}
|
|
|
|
private func assetDirectory(assetID: String, version: String) -> URL {
|
|
rootDirectory
|
|
.appendingPathComponent(safePathComponent(assetID), isDirectory: true)
|
|
.appendingPathComponent(safePathComponent(version), isDirectory: true)
|
|
}
|
|
|
|
private func installArchive(
|
|
archivePath: String,
|
|
asset: MenuBarIconGalleryAsset,
|
|
contentBaseURL: URL,
|
|
allowsFileResources: Bool,
|
|
stagingFramesDirectory: URL,
|
|
stagingDirectory: URL
|
|
) async throws {
|
|
let archiveURL = try resolvedResourceURL(
|
|
path: archivePath,
|
|
contentBaseURL: contentBaseURL,
|
|
allowsFileResources: allowsFileResources
|
|
)
|
|
let archiveFileURL = try await materializeResource(
|
|
archiveURL,
|
|
fileName: "asset.zip",
|
|
in: stagingDirectory,
|
|
maximumSize: Self.maximumArchiveFileSize
|
|
)
|
|
let extractionDirectory = stagingDirectory.appendingPathComponent("Expanded", isDirectory: true)
|
|
try fileManager.createDirectory(at: extractionDirectory, withIntermediateDirectories: true)
|
|
try await MenuBarIconGalleryArchiveExtractor.extract(zipURL: archiveFileURL, destinationURL: extractionDirectory)
|
|
|
|
let pattern = asset.archiveFramePathPattern ?? "frames/frame-%03d.png"
|
|
for index in 0..<asset.frameCount {
|
|
guard let relativePath = resolvedFramePath(pattern: pattern, index: index) else {
|
|
throw MenuBarIconGalleryError.invalidAsset(asset.id)
|
|
}
|
|
let sourceURL = extractionDirectory.appendingPathComponent(relativePath, isDirectory: false)
|
|
try copyFrame(
|
|
from: sourceURL,
|
|
to: stagingFramesDirectory.appendingPathComponent(frameFileName(index: index), isDirectory: false),
|
|
allowedRoot: extractionDirectory
|
|
)
|
|
}
|
|
}
|
|
|
|
private func installIndividualFrames(
|
|
asset: MenuBarIconGalleryAsset,
|
|
contentBaseURL: URL,
|
|
allowsFileResources: Bool,
|
|
stagingFramesDirectory: URL
|
|
) async throws {
|
|
let framePaths = try resolvedFramePaths(for: asset)
|
|
guard framePaths.count == asset.frameCount else {
|
|
throw MenuBarIconGalleryError.invalidAsset(asset.id)
|
|
}
|
|
|
|
for (index, framePath) in framePaths.enumerated() {
|
|
let sourceURL = try resolvedResourceURL(
|
|
path: framePath,
|
|
contentBaseURL: contentBaseURL,
|
|
allowsFileResources: allowsFileResources
|
|
)
|
|
let destinationURL = stagingFramesDirectory.appendingPathComponent(frameFileName(index: index), isDirectory: false)
|
|
|
|
if sourceURL.isFileURL {
|
|
try copyFrame(from: sourceURL, to: destinationURL, allowedRoot: nil)
|
|
} else {
|
|
let temporaryURL = try await materializeResource(
|
|
sourceURL,
|
|
fileName: "download-\(frameFileName(index: index))",
|
|
in: stagingFramesDirectory.deletingLastPathComponent(),
|
|
maximumSize: Self.maximumFrameFileSize
|
|
)
|
|
try copyFrame(from: temporaryURL, to: destinationURL, allowedRoot: nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func resolvedFramePaths(for asset: MenuBarIconGalleryAsset) throws -> [String] {
|
|
if let framePaths = asset.framePaths {
|
|
return framePaths
|
|
}
|
|
|
|
guard let framePathPattern = asset.framePathPattern else {
|
|
throw MenuBarIconGalleryError.invalidAsset(asset.id)
|
|
}
|
|
|
|
return try (0..<asset.frameCount).map { index in
|
|
guard let path = resolvedFramePath(pattern: framePathPattern, index: index) else {
|
|
throw MenuBarIconGalleryError.invalidAsset(asset.id)
|
|
}
|
|
return path
|
|
}
|
|
}
|
|
|
|
private func resolvedFramePath(pattern: String?, index: Int) -> String? {
|
|
guard let pattern else {
|
|
return nil
|
|
}
|
|
|
|
if pattern.contains("%03d") {
|
|
return pattern.replacingOccurrences(of: "%03d", with: String(format: "%03d", index))
|
|
}
|
|
|
|
if pattern.contains("%d") {
|
|
return pattern.replacingOccurrences(of: "%d", with: "\(index)")
|
|
}
|
|
|
|
return index == 0 ? pattern : nil
|
|
}
|
|
|
|
private func resolvedResourceURL(
|
|
path: String,
|
|
contentBaseURL: URL,
|
|
allowsFileResources: Bool
|
|
) throws -> URL {
|
|
guard let url = URL(string: path, relativeTo: contentBaseURL)?.absoluteURL else {
|
|
throw MenuBarIconGalleryError.invalidResourceURL(path)
|
|
}
|
|
|
|
if url.isFileURL {
|
|
guard allowsFileResources else {
|
|
throw MenuBarIconGalleryError.invalidResourceURL(path)
|
|
}
|
|
return url.standardizedFileURL
|
|
}
|
|
|
|
guard url.scheme?.lowercased() == "https" else {
|
|
throw MenuBarIconGalleryError.invalidResourceURL(path)
|
|
}
|
|
guard url.absoluteString.hasPrefix(contentBaseURL.absoluteURL.absoluteString) else {
|
|
throw MenuBarIconGalleryError.invalidResourceURL(path)
|
|
}
|
|
|
|
return url
|
|
}
|
|
|
|
private func materializeResource(
|
|
_ url: URL,
|
|
fileName: String,
|
|
in directory: URL,
|
|
maximumSize: Int
|
|
) async throws -> URL {
|
|
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
let destinationURL = directory.appendingPathComponent(fileName, isDirectory: false)
|
|
|
|
if url.isFileURL {
|
|
try copyFrame(from: url, to: destinationURL, allowedRoot: nil, maximumSize: maximumSize)
|
|
return destinationURL
|
|
}
|
|
|
|
var request = URLRequest(url: url)
|
|
request.timeoutInterval = 60
|
|
request.cachePolicy = .reloadIgnoringLocalCacheData
|
|
let (temporaryURL, response) = try await session.download(for: request)
|
|
|
|
if let httpResponse = response as? HTTPURLResponse,
|
|
!(200..<300).contains(httpResponse.statusCode) {
|
|
throw MenuBarIconGalleryError.httpStatus(httpResponse.statusCode)
|
|
}
|
|
|
|
let fileSize = try fileSize(at: temporaryURL)
|
|
guard fileSize <= maximumSize else {
|
|
throw MenuBarIconGalleryError.resourceTooLarge(fileSize)
|
|
}
|
|
|
|
if fileManager.fileExists(atPath: destinationURL.path) {
|
|
try fileManager.removeItem(at: destinationURL)
|
|
}
|
|
try fileManager.moveItem(at: temporaryURL, to: destinationURL)
|
|
return destinationURL
|
|
}
|
|
|
|
private func data(from url: URL) async throws -> Data {
|
|
if url.isFileURL {
|
|
return try Data(contentsOf: url)
|
|
}
|
|
|
|
var request = URLRequest(url: url)
|
|
request.timeoutInterval = 30
|
|
request.cachePolicy = .returnCacheDataElseLoad
|
|
let (data, response) = try await session.data(for: request)
|
|
|
|
if let httpResponse = response as? HTTPURLResponse,
|
|
!(200..<300).contains(httpResponse.statusCode) {
|
|
throw MenuBarIconGalleryError.httpStatus(httpResponse.statusCode)
|
|
}
|
|
|
|
return data
|
|
}
|
|
|
|
private func copyFrame(
|
|
from sourceURL: URL,
|
|
to destinationURL: URL,
|
|
allowedRoot: URL?,
|
|
maximumSize: Int = maximumFrameFileSize
|
|
) throws {
|
|
if let allowedRoot {
|
|
let sourcePath = sourceURL.resolvingSymlinksInPath().standardizedFileURL.path
|
|
let rootPath = allowedRoot.resolvingSymlinksInPath().standardizedFileURL.path
|
|
guard sourcePath.hasPrefix(rootPath + "/") else {
|
|
throw MenuBarIconGalleryError.archiveEscapedDestination
|
|
}
|
|
}
|
|
|
|
let fileSize = try fileSize(at: sourceURL)
|
|
guard fileSize <= maximumSize else {
|
|
throw MenuBarIconGalleryError.resourceTooLarge(fileSize)
|
|
}
|
|
|
|
if fileManager.fileExists(atPath: destinationURL.path) {
|
|
try fileManager.removeItem(at: destinationURL)
|
|
}
|
|
try fileManager.copyItem(at: sourceURL, to: destinationURL)
|
|
}
|
|
|
|
private func validateFrames(in directory: URL, frameCount: Int) throws {
|
|
for index in 0..<frameCount {
|
|
let frameURL = directory.appendingPathComponent(frameFileName(index: index), isDirectory: false)
|
|
guard let image = NSImage(contentsOf: frameURL),
|
|
isDecodedImageSizeAcceptable(image)
|
|
else {
|
|
throw MenuBarIconGalleryError.invalidFrame(frameURL.lastPathComponent)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func isDecodedImageSizeAcceptable(_ image: NSImage) -> Bool {
|
|
guard let cgImage = cgImage(from: image) else {
|
|
return false
|
|
}
|
|
|
|
return cgImage.width * cgImage.height <= Self.maximumDecodedPixelArea
|
|
}
|
|
|
|
private func cgImage(from image: NSImage) -> CGImage? {
|
|
var proposedRect = NSRect(origin: .zero, size: image.size)
|
|
return image.cgImage(forProposedRect: &proposedRect, context: nil, hints: nil)
|
|
}
|
|
|
|
private func replaceDirectory(
|
|
at destinationDirectory: URL,
|
|
with stagingFramesDirectory: URL,
|
|
destinationFramesDirectory: URL
|
|
) throws {
|
|
let parentDirectory = destinationDirectory.deletingLastPathComponent()
|
|
try fileManager.createDirectory(at: parentDirectory, withIntermediateDirectories: true)
|
|
|
|
if fileManager.fileExists(atPath: destinationDirectory.path) {
|
|
try fileManager.removeItem(at: destinationDirectory)
|
|
}
|
|
|
|
try fileManager.createDirectory(at: destinationDirectory, withIntermediateDirectories: true)
|
|
try fileManager.moveItem(at: stagingFramesDirectory, to: destinationFramesDirectory)
|
|
}
|
|
|
|
private func removeEmptyStagingIfNeeded(_ stagingDirectory: URL) throws {
|
|
if fileManager.fileExists(atPath: stagingDirectory.path) {
|
|
try fileManager.removeItem(at: stagingDirectory)
|
|
}
|
|
}
|
|
|
|
private func fileSize(at url: URL) throws -> Int {
|
|
let values = try url.resourceValues(forKeys: [.fileSizeKey])
|
|
if let fileSize = values.fileSize {
|
|
return fileSize
|
|
}
|
|
|
|
return try Data(contentsOf: url).count
|
|
}
|
|
|
|
private func expectedFrameFileNames(frameCount: Int) -> [String] {
|
|
(0..<frameCount).map(frameFileName(index:))
|
|
}
|
|
|
|
private func frameFileName(index: Int) -> String {
|
|
String(format: "frame-%03d.png", index)
|
|
}
|
|
|
|
private func safePathComponent(_ value: String) -> String {
|
|
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_."))
|
|
let scalars = value.unicodeScalars.map { scalar in
|
|
allowed.contains(scalar) ? Character(scalar) : "-"
|
|
}
|
|
let result = String(scalars).trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
|
return result.isEmpty ? "asset" : result
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class MenuBarIconGalleryLibrary: ObservableObject {
|
|
@Published private(set) var status: MenuBarIconGalleryStatus = .idle
|
|
@Published private(set) var categories: [MenuBarIconGalleryCategory] = []
|
|
@Published private(set) var assets: [MenuBarIconGalleryAsset] = []
|
|
@Published private(set) var assetStates: [String: MenuBarIconGalleryAssetState] = [:]
|
|
@Published private(set) var previewImages: [String: NSImage] = [:]
|
|
@Published private(set) var lastErrorMessage: String?
|
|
|
|
private let provider: any MenuBarIconGalleryProviding
|
|
private let store: MenuBarIconRemoteAssetStore
|
|
private var snapshot: MenuBarIconGallerySnapshot?
|
|
private var didAttemptInitialLoad = false
|
|
|
|
init(
|
|
provider: any MenuBarIconGalleryProviding = MenuBarIconGalleryProvider(),
|
|
store: MenuBarIconRemoteAssetStore? = nil,
|
|
rootDirectory: URL? = nil
|
|
) {
|
|
self.provider = provider
|
|
let root = rootDirectory ?? AppStorageScope.applicationSupportRoot()
|
|
.appendingPathComponent("MenuBarIcons", isDirectory: true)
|
|
.appendingPathComponent("RemoteAssets", isDirectory: true)
|
|
self.store = store ?? MenuBarIconRemoteAssetStore(rootDirectory: root)
|
|
}
|
|
|
|
func loadCatalogIfNeeded() async {
|
|
guard !didAttemptInitialLoad else {
|
|
return
|
|
}
|
|
|
|
didAttemptInitialLoad = true
|
|
await refreshCatalog()
|
|
}
|
|
|
|
func refreshCatalog() async {
|
|
status = .loading
|
|
lastErrorMessage = nil
|
|
|
|
do {
|
|
let snapshot = try await provider.loadCatalog()
|
|
self.snapshot = snapshot
|
|
categories = snapshot.catalog.categories
|
|
assets = snapshot.catalog.assets
|
|
assetStates = Dictionary(
|
|
uniqueKeysWithValues: snapshot.catalog.assets.map { asset in
|
|
(asset.id, store.isAssetCached(asset) ? .cached : .available)
|
|
}
|
|
)
|
|
status = .loaded(snapshot.loadedAt)
|
|
} catch {
|
|
lastErrorMessage = error.localizedDescription
|
|
status = .failed(error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
func state(for asset: MenuBarIconGalleryAsset) -> MenuBarIconGalleryAssetState {
|
|
assetStates[asset.id] ?? (store.isAssetCached(asset) ? .cached : .available)
|
|
}
|
|
|
|
func previewImage(for asset: MenuBarIconGalleryAsset) -> NSImage? {
|
|
previewImages[asset.id]
|
|
}
|
|
|
|
func loadPreviewIfNeeded(for asset: MenuBarIconGalleryAsset) async {
|
|
guard previewImages[asset.id] == nil, let snapshot else {
|
|
return
|
|
}
|
|
|
|
do {
|
|
previewImages[asset.id] = try await store.loadPreviewImage(
|
|
for: asset,
|
|
contentBaseURL: snapshot.contentBaseURL,
|
|
allowsFileResources: snapshot.allowsFileResources
|
|
)
|
|
} catch {
|
|
previewImages[asset.id] = nil
|
|
}
|
|
}
|
|
|
|
func selectRecentItem(_ item: MenuBarIconRecentItem, iconSettings: MenuBarIconSettings) async -> Bool {
|
|
guard let selection = iconSettings.remoteAssetSelection(forRecentItem: item) else {
|
|
iconSettings.useRecentIcon(item)
|
|
return iconSettings.lastErrorMessage == nil
|
|
}
|
|
|
|
if iconSettings.isRemoteAssetCached(for: item) {
|
|
iconSettings.useRecentIcon(item)
|
|
return iconSettings.lastErrorMessage == nil
|
|
}
|
|
|
|
await loadCatalogIfNeeded()
|
|
if snapshot == nil {
|
|
await refreshCatalog()
|
|
}
|
|
|
|
guard let asset = assets.first(where: { asset in
|
|
asset.id == selection.id && asset.version == selection.version
|
|
}) else {
|
|
iconSettings.reportError(lastErrorMessage ?? AppL10n.settings(
|
|
"menuBarIcon.gallery.error.recentAssetMissing",
|
|
defaultValue: "最近使用的在线图标已不在图库中。"
|
|
))
|
|
return false
|
|
}
|
|
|
|
let didSelect = await selectAsset(asset, iconSettings: iconSettings)
|
|
if !didSelect, let lastErrorMessage {
|
|
iconSettings.reportError(lastErrorMessage)
|
|
}
|
|
return didSelect
|
|
}
|
|
|
|
func selectAsset(_ asset: MenuBarIconGalleryAsset, iconSettings: MenuBarIconSettings) async -> Bool {
|
|
guard let snapshot else {
|
|
lastErrorMessage = AppL10n.settings("menuBarIcon.gallery.error.notLoaded", defaultValue: "图标图库尚未加载。")
|
|
return false
|
|
}
|
|
|
|
assetStates[asset.id] = .downloading
|
|
lastErrorMessage = nil
|
|
|
|
do {
|
|
let selection = try await store.installAsset(
|
|
asset,
|
|
contentBaseURL: snapshot.contentBaseURL,
|
|
allowsFileResources: snapshot.allowsFileResources
|
|
)
|
|
iconSettings.useRemoteAsset(selection)
|
|
store.pruneRemoteAssets(keeping: selection)
|
|
assetStates = Dictionary(
|
|
uniqueKeysWithValues: assets.map { item in
|
|
(item.id, item.id == asset.id ? .cached : .available)
|
|
}
|
|
)
|
|
return true
|
|
} catch {
|
|
let message = error.localizedDescription
|
|
assetStates[asset.id] = .failed(message)
|
|
lastErrorMessage = message
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
enum MenuBarIconGalleryError: LocalizedError, Equatable {
|
|
case unsupportedSchemaVersion(Int)
|
|
case invalidCatalog
|
|
case invalidAsset(String)
|
|
case invalidResourceURL(String)
|
|
case httpStatus(Int)
|
|
case resourceTooLarge(Int)
|
|
case invalidFrame(String)
|
|
case archiveEscapedDestination
|
|
case unzipFailed(String)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case let .unsupportedSchemaVersion(version):
|
|
return AppL10n.settingsFormat("menuBarIcon.gallery.error.unsupportedSchemaFormat", defaultValue: "图标图库版本不支持:%d", version)
|
|
case .invalidCatalog:
|
|
return AppL10n.settings("menuBarIcon.gallery.error.invalidCatalog", defaultValue: "图标图库格式无效。")
|
|
case let .invalidAsset(id):
|
|
return AppL10n.settingsFormat("menuBarIcon.gallery.error.invalidAssetFormat", defaultValue: "图标素材无效:%@", id)
|
|
case let .invalidResourceURL(path):
|
|
return AppL10n.settingsFormat("menuBarIcon.gallery.error.invalidResourceURLFormat", defaultValue: "图标资源地址无效:%@", path)
|
|
case let .httpStatus(statusCode):
|
|
return AppL10n.settingsFormat("menuBarIcon.gallery.error.httpStatusFormat", defaultValue: "图标资源下载失败:HTTP %d", statusCode)
|
|
case let .resourceTooLarge(size):
|
|
return AppL10n.settingsFormat("menuBarIcon.gallery.error.resourceTooLargeFormat", defaultValue: "图标资源过大:%d", size)
|
|
case let .invalidFrame(name):
|
|
return AppL10n.settingsFormat("menuBarIcon.gallery.error.invalidFrameFormat", defaultValue: "图标帧无法读取:%@", name)
|
|
case .archiveEscapedDestination:
|
|
return AppL10n.settings("menuBarIcon.gallery.error.unsafeArchivePath", defaultValue: "图标压缩包包含不安全路径。")
|
|
case let .unzipFailed(reason):
|
|
return AppL10n.settingsFormat("menuBarIcon.gallery.error.unzipFailedFormat", defaultValue: "图标压缩包解压失败:%@", reason)
|
|
}
|
|
}
|
|
}
|
|
|
|
private enum MenuBarIconGalleryArchiveExtractor {
|
|
static func extract(zipURL: URL, destinationURL: URL) async throws {
|
|
try await Task.detached(priority: .userInitiated) {
|
|
let process = Process()
|
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto")
|
|
process.arguments = [
|
|
"-x",
|
|
"-k",
|
|
"--sequesterRsrc",
|
|
"--rsrc",
|
|
zipURL.path,
|
|
destinationURL.path
|
|
]
|
|
|
|
let pipe = Pipe()
|
|
process.standardError = pipe
|
|
|
|
try process.run()
|
|
process.waitUntilExit()
|
|
|
|
guard process.terminationStatus == 0 else {
|
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
|
let message = String(data: data, encoding: .utf8) ?? "ditto \(process.terminationStatus)"
|
|
throw MenuBarIconGalleryError.unzipFailed(message)
|
|
}
|
|
}.value
|
|
}
|
|
}
|