Files
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

1311 lines
47 KiB
Swift

import AppKit
import AVFoundation
import Combine
import Foundation
import ImageIO
import UniformTypeIdentifiers
enum MenuBarIconAppearance: String, CaseIterable, Identifiable, Codable {
case light
case dark
var id: String { rawValue }
var title: String {
switch self {
case .light:
return AppL10n.settings("menuBarIcon.appearance.light", defaultValue: "浅色模式")
case .dark:
return AppL10n.settings("menuBarIcon.appearance.dark", defaultValue: "深色模式")
}
}
}
enum MenuBarIconAnimationSpeedMode: String, CaseIterable, Identifiable, Codable {
case manual
case adaptiveSystemLoad
var id: String { rawValue }
var title: String {
switch self {
case .manual:
return AppL10n.settings("menuBarIcon.animationSpeed.manual", defaultValue: "手动速度")
case .adaptiveSystemLoad:
return AppL10n.settings("menuBarIcon.animationSpeed.adaptiveSystemLoad", defaultValue: "随系统负载")
}
}
}
struct MenuBarIconAnimationSystemLoad: Equatable {
var cpuUsage: Double?
var gpuUsage: Double?
var memoryUsage: Double?
}
enum MenuBarIconAnimationSpeedPolicy {
static let minimumMultiplier = 0.5
static let maximumMultiplier = 5.0
static let defaultManualMultiplier = 1.0
static func normalizedManualMultiplier(_ value: Double) -> Double {
min(max(value, minimumMultiplier), maximumMultiplier)
}
static func multiplier(
mode: MenuBarIconAnimationSpeedMode,
manualMultiplier: Double,
systemLoad: MenuBarIconAnimationSystemLoad?
) -> Double {
switch mode {
case .manual:
return normalizedManualMultiplier(manualMultiplier)
case .adaptiveSystemLoad:
let loadValues = [
systemLoad?.cpuUsage,
systemLoad?.gpuUsage,
systemLoad?.memoryUsage
].compactMap { $0 }.map { min(max($0, 0), 1) }
guard !loadValues.isEmpty else {
return defaultManualMultiplier
}
let averageLoad = loadValues.reduce(0, +) / Double(loadValues.count)
return min(max(0.75 + (averageLoad * 1.25), minimumMultiplier), maximumMultiplier)
}
}
}
enum MenuBarIconMediaKind: String, Codable, Equatable {
case image
case animation
}
struct MenuBarIconRecentItem: Identifiable, Codable, Equatable {
let id: UUID
var fileName: String
var frameFileNames: [String]
var displayName: String
var addedAt: Date
var mediaKind: MenuBarIconMediaKind
var frameDuration: TimeInterval
var thumbnailFileName: String?
init(
id: UUID,
fileName: String,
frameFileNames: [String],
displayName: String,
addedAt: Date,
mediaKind: MenuBarIconMediaKind,
frameDuration: TimeInterval,
thumbnailFileName: String? = nil
) {
self.id = id
self.fileName = fileName
self.frameFileNames = frameFileNames
self.displayName = displayName
self.addedAt = addedAt
self.mediaKind = mediaKind
self.frameDuration = frameDuration
self.thumbnailFileName = thumbnailFileName
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(UUID.self, forKey: .id)
fileName = try container.decode(String.self, forKey: .fileName)
frameFileNames = try container.decodeIfPresent([String].self, forKey: .frameFileNames) ?? [fileName]
displayName = try container.decode(String.self, forKey: .displayName)
addedAt = try container.decode(Date.self, forKey: .addedAt)
mediaKind = try container.decodeIfPresent(MenuBarIconMediaKind.self, forKey: .mediaKind) ?? .image
frameDuration = try container.decodeIfPresent(TimeInterval.self, forKey: .frameDuration) ?? 1.0 / 6.0
thumbnailFileName = try container.decodeIfPresent(String.self, forKey: .thumbnailFileName)
}
}
struct MenuBarIconImagePayload: Equatable {
let image: NSImage
let isTemplate: Bool
let animationFrames: [NSImage]
let frameDuration: TimeInterval
let speedMode: MenuBarIconAnimationSpeedMode
let manualSpeedMultiplier: Double
var isAnimated: Bool {
animationFrames.count > 1
}
}
struct MenuBarIconContrastReport: Equatable {
var lightContrast: Double?
var darkContrast: Double?
var warningText: String? {
let threshold = 2.4
let lightIsLow = lightContrast.map { $0 < threshold } ?? false
let darkIsLow = darkContrast.map { $0 < threshold } ?? false
switch (lightIsLow, darkIsLow) {
case (true, true):
return AppL10n.settings("menuBarIcon.contrast.warningBoth", defaultValue: "当前图标在深浅色菜单栏里都可能不够清晰。")
case (true, false):
return AppL10n.settings("menuBarIcon.contrast.warningLight", defaultValue: "当前图标在浅色菜单栏里对比度可能偏低。")
case (false, true):
return AppL10n.settings("menuBarIcon.contrast.warningDark", defaultValue: "当前图标在深色菜单栏里对比度可能偏低。")
case (false, false):
return nil
}
}
}
enum MenuBarIconProcessing {
static let maxAnimationFileSize = 5 * 1024 * 1024
static let maxAnimationFrames = 24
static let maxAnimationSourceFrames = 120
static let animationFramesPerSecond: TimeInterval = 6
static let maxSourcePixelArea = 1_600 * 1_600
static let standardIconPointSize: CGFloat = 18
private static let standardIconContentPointSize: CGFloat = 16
static let supportedImageContentTypes: [UTType] = [
.png,
.jpeg,
.webP,
.icns,
.image
].compactMap { $0 }
static let supportedAnimationContentTypes: [UTType] = [
.gif,
.mpeg4Movie,
.quickTimeMovie,
.movie
].compactMap { $0 }
static func renderedImage(from image: NSImage) -> NSImage? {
guard let source = cgImage(from: image) else {
return nil
}
return renderedImage(from: source, visibleBounds: nil)
}
static func renderedImages(from images: [NSImage]) -> [NSImage] {
let sources = images.compactMap { image -> CGImage? in
cgImage(from: image)
}
guard !sources.isEmpty else {
return []
}
let sharedVisibleBounds = sources
.compactMap { alphaBounds(in: $0) }
.reduce(CGRect?.none) { partialBounds, bounds in
partialBounds?.union(bounds) ?? bounds
}
return sources.compactMap { source in
renderedImage(from: source, visibleBounds: sharedVisibleBounds)
}
}
private static func renderedImage(from source: CGImage, visibleBounds: CGRect?) -> NSImage? {
let pointSize = standardIconPointSize
let scaleFactor: CGFloat = 2
let canvasHeightPixels = Int((pointSize * scaleFactor).rounded())
let contentHeightPixels = Int((standardIconContentPointSize * scaleFactor).rounded())
let paddingPixels = max(0, (canvasHeightPixels - contentHeightPixels) / 2)
guard canvasHeightPixels > 0, contentHeightPixels > 0 else {
return nil
}
// Normalize artwork bounds before fitting so transparent source padding does not change its visual size.
let visibleSource: CGImage
let sourceBounds = CGRect(x: 0, y: 0, width: source.width, height: source.height)
let cropBounds = (visibleBounds ?? alphaBounds(in: source))?.intersection(sourceBounds)
if let cropBounds, let cropped = source.cropping(to: cropBounds) {
visibleSource = cropped
} else {
visibleSource = source
}
let sourceWidth = CGFloat(visibleSource.width)
let sourceHeight = CGFloat(visibleSource.height)
let renderScale = CGFloat(contentHeightPixels) / sourceHeight
let drawWidth = sourceWidth * renderScale
let drawHeight = sourceHeight * renderScale
let canvasWidthPixels = max(
contentHeightPixels + (paddingPixels * 2),
Int(ceil(drawWidth)) + (paddingPixels * 2)
)
let canvasSize = CGSize(width: canvasWidthPixels, height: canvasHeightPixels)
guard let context = CGContext(
data: nil,
width: canvasWidthPixels,
height: canvasHeightPixels,
bitsPerComponent: 8,
bytesPerRow: 0,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else {
return nil
}
context.clear(CGRect(origin: .zero, size: canvasSize))
context.interpolationQuality = .high
let originX = (canvasSize.width - drawWidth) / 2
let originY = (canvasSize.height - drawHeight) / 2
let drawSize = CGSize(width: drawWidth, height: drawHeight)
let origin = CGPoint(
x: originX,
y: originY
)
context.draw(visibleSource, in: CGRect(origin: origin, size: drawSize))
guard let rendered = context.makeImage() else {
return nil
}
let output = NSImage(
cgImage: rendered,
size: NSSize(
width: CGFloat(canvasWidthPixels) / scaleFactor,
height: pointSize
)
)
output.size = NSSize(width: CGFloat(canvasWidthPixels) / scaleFactor, height: pointSize)
return output
}
private static func alphaBounds(in image: CGImage) -> CGRect? {
let width = image.width
let height = image.height
var pixels = [UInt8](repeating: 0, count: width * height * 4)
guard let context = CGContext(
data: &pixels,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: width * 4,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else {
return nil
}
context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
var minX = width
var minY = height
var maxX = 0
var maxY = 0
var hasVisiblePixel = false
for y in 0..<height {
for x in 0..<width {
let alpha = pixels[((y * width) + x) * 4 + 3]
guard alpha > 8 else {
continue
}
hasVisiblePixel = true
minX = min(minX, x)
minY = min(minY, y)
maxX = max(maxX, x + 1)
maxY = max(maxY, y + 1)
}
}
guard hasVisiblePixel else {
return nil
}
return CGRect(
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY
)
}
static func pngData(from image: NSImage) -> Data? {
guard
let tiffData = image.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: tiffData)
else {
return nil
}
return bitmap.representation(using: .png, properties: [:])
}
@MainActor
static func animationFrameImages(from url: URL) async throws -> [NSImage] {
guard isFileSizeAcceptable(url) else {
throw MenuBarIconImportError.animationTooLarge
}
let contentType = contentType(for: url)
if contentType?.conforms(to: .movie) == true || contentType?.conforms(to: .audiovisualContent) == true {
return try await videoFrameImages(from: url)
}
if contentType?.conforms(to: .gif) == true || url.pathExtension.lowercased() == "gif" {
return try imageSourceFrameImages(from: url)
}
throw MenuBarIconImportError.unsupportedAnimation
}
static func contrastReport(for image: NSImage?) -> MenuBarIconContrastReport {
guard let image, let cgImage = cgImage(from: image) else {
return MenuBarIconContrastReport(lightContrast: nil, darkContrast: nil)
}
guard let averageLuminance = averageVisibleLuminance(in: cgImage) else {
return MenuBarIconContrastReport(lightContrast: nil, darkContrast: nil)
}
let lightMenuBarLuminance = 0.94
let darkMenuBarLuminance = 0.08
return MenuBarIconContrastReport(
lightContrast: contrastRatio(averageLuminance, lightMenuBarLuminance),
darkContrast: contrastRatio(averageLuminance, darkMenuBarLuminance)
)
}
private static func averageVisibleLuminance(in image: CGImage) -> Double? {
let width = 16
let height = 16
var pixels = [UInt8](repeating: 0, count: width * height * 4)
guard
let context = CGContext(
data: &pixels,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: width * 4,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
)
else {
return nil
}
context.interpolationQuality = .medium
context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
var weightedLuminance = 0.0
var visibleWeight = 0.0
for index in stride(from: 0, to: pixels.count, by: 4) {
let alpha = Double(pixels[index + 3]) / 255
guard alpha > 0.08 else {
continue
}
let red = Double(pixels[index]) / 255
let green = Double(pixels[index + 1]) / 255
let blue = Double(pixels[index + 2]) / 255
let luminance = (0.2126 * red) + (0.7152 * green) + (0.0722 * blue)
weightedLuminance += luminance * alpha
visibleWeight += alpha
}
guard visibleWeight > 0 else {
return nil
}
return weightedLuminance / visibleWeight
}
private static func contrastRatio(_ first: Double, _ second: Double) -> Double {
let lighter = max(first, second)
let darker = min(first, second)
return (lighter + 0.05) / (darker + 0.05)
}
private static func cgImage(from image: NSImage) -> CGImage? {
var proposedRect = NSRect(origin: .zero, size: image.size)
return image.cgImage(forProposedRect: &proposedRect, context: nil, hints: nil)
}
private static func isFileSizeAcceptable(_ url: URL) -> Bool {
guard
let values = try? url.resourceValues(forKeys: [.fileSizeKey]),
let fileSize = values.fileSize
else {
return true
}
return fileSize <= maxAnimationFileSize
}
private static func contentType(for url: URL) -> UTType? {
guard let values = try? url.resourceValues(forKeys: [.contentTypeKey]) else {
return nil
}
return values.contentType ?? UTType(filenameExtension: url.pathExtension)
}
private static func imageSourceFrameImages(from url: URL) throws -> [NSImage] {
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else {
throw MenuBarIconImportError.cannotDecodeAnimation
}
let frameCount = CGImageSourceGetCount(source)
guard frameCount > 1 else {
throw MenuBarIconImportError.notAnimated
}
guard frameCount <= maxAnimationSourceFrames else {
throw MenuBarIconImportError.animationTooComplex
}
if let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
let width = properties[kCGImagePropertyPixelWidth] as? Int,
let height = properties[kCGImagePropertyPixelHeight] as? Int,
width * height > maxSourcePixelArea {
throw MenuBarIconImportError.animationTooLarge
}
let targetCount = min(frameCount, maxAnimationFrames)
let frameStep = max(1, Int(ceil(Double(frameCount) / Double(targetCount))))
var frames: [NSImage] = []
for index in stride(from: 0, to: frameCount, by: frameStep) where frames.count < maxAnimationFrames {
guard let frame = CGImageSourceCreateImageAtIndex(source, index, nil) else {
continue
}
frames.append(NSImage(cgImage: frame, size: .zero))
}
guard frames.count > 1 else {
throw MenuBarIconImportError.cannotDecodeAnimation
}
return frames
}
private static func imageSourceFrameDelay(source: CGImageSource, index: Int) -> TimeInterval {
guard
let properties = CGImageSourceCopyPropertiesAtIndex(source, index, nil) as? [CFString: Any],
let gifProperties = properties[kCGImagePropertyGIFDictionary] as? [CFString: Any]
else {
return 1.0 / animationFramesPerSecond
}
let unclampedDelay = gifProperties[kCGImagePropertyGIFUnclampedDelayTime] as? TimeInterval
let delay = unclampedDelay ?? gifProperties[kCGImagePropertyGIFDelayTime] as? TimeInterval
return max(delay ?? (1.0 / animationFramesPerSecond), 0.02)
}
@MainActor
private static func videoFrameImages(from url: URL) async throws -> [NSImage] {
let asset = AVURLAsset(url: url)
let durationTime = try await asset.load(.duration)
let duration = CMTimeGetSeconds(durationTime)
guard duration.isFinite, duration > 0 else {
throw MenuBarIconImportError.cannotDecodeAnimation
}
if let track = try await asset.loadTracks(withMediaType: .video).first {
let naturalSize = try await track.load(.naturalSize)
let preferredTransform = try await track.load(.preferredTransform)
let transformedSize = naturalSize.applying(preferredTransform)
let pixelArea = abs(transformedSize.width * transformedSize.height)
guard pixelArea <= CGFloat(maxSourcePixelArea) else {
throw MenuBarIconImportError.animationTooLarge
}
}
let generator = AVAssetImageGenerator(asset: asset)
generator.appliesPreferredTrackTransform = true
generator.maximumSize = CGSize(width: 256, height: 256)
let frameCount = min(
maxAnimationFrames,
max(2, Int(ceil(duration * animationFramesPerSecond)))
)
var frames: [NSImage] = []
for index in 0..<frameCount {
let progress = frameCount == 1 ? 0 : Double(index) / Double(frameCount)
let seconds = min(duration, progress * duration)
let time = CMTime(seconds: seconds, preferredTimescale: 600)
guard let frame = try? generator.copyCGImage(at: time, actualTime: nil) else {
continue
}
frames.append(NSImage(cgImage: frame, size: .zero))
}
guard frames.count > 1 else {
throw MenuBarIconImportError.cannotDecodeAnimation
}
return frames
}
}
enum MenuBarIconImportError: Error {
case animationTooLarge
case animationTooComplex
case cannotDecodeAnimation
case notAnimated
case unsupportedAnimation
var userMessage: String {
switch self {
case .animationTooLarge:
return AppL10n.settings("menuBarIcon.importError.animationTooLarge", defaultValue: "动画文件过大或分辨率过高,请选择 5 MB 以内、画面更简单的文件。")
case .animationTooComplex:
return AppL10n.settings("menuBarIcon.importError.animationTooComplex", defaultValue: "动画帧数过多,请选择更简单的短动画。")
case .cannotDecodeAnimation:
return AppL10n.settings("menuBarIcon.importError.cannotDecodeAnimation", defaultValue: "无法解析这个动画文件。")
case .notAnimated:
return AppL10n.settings("menuBarIcon.importError.notAnimated", defaultValue: "所选文件不是可循环播放的动画。")
case .unsupportedAnimation:
return AppL10n.settings("menuBarIcon.importError.unsupportedAnimation", defaultValue: "暂不支持这个动画格式,请选择 GIF 或 MP4。")
}
}
}
@MainActor
final class MenuBarIconSettings: ObservableObject {
private enum DefaultsKey {
static let storage = "menubar.icon.settings"
}
private struct StoredState: Codable, Equatable {
var animationSpeedMode: MenuBarIconAnimationSpeedMode = .manual
var manualAnimationSpeedMultiplier: Double = MenuBarIconAnimationSpeedPolicy.defaultManualMultiplier
var lightIconFileName: String?
var darkIconFileName: String?
var remoteAssetSelection: MenuBarIconRemoteAssetSelection?
var recentItems: [MenuBarIconRecentItem] = []
}
private struct RenderedFramesCacheKey: Hashable {
let id: UUID
let fileName: String
let frameFileNames: [String]
}
private struct RecentPreviewCacheKey: Hashable {
let id: UUID
let fileName: String
}
private struct RemoteFramesCacheKey: Hashable {
let id: String
let version: String
let frameFileNames: [String]
}
private static let defaultIconName = NSImage.Name("MenuBarIcon")
private static let maxRecentItems = 6
@Published private var storedState: StoredState
@Published private(set) var lastErrorMessage: String?
@Published private(set) var settingsRevision: Int = 0
private let userDefaults: UserDefaults
private let fileManager: FileManager
private let rootDirectory: URL
private let remoteAssetStore: MenuBarIconRemoteAssetStore
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
private var imagePayloadCache: [MenuBarIconAppearance: MenuBarIconImagePayload] = [:]
private var renderedFramesCache: [RenderedFramesCacheKey: [NSImage]] = [:]
private var remoteFramesCache: [RemoteFramesCacheKey: [NSImage]] = [:]
private var recentPreviewCache: [RecentPreviewCacheKey: NSImage] = [:]
private var contrastReportCache: [MenuBarIconAppearance: MenuBarIconContrastReport] = [:]
init(
userDefaults: UserDefaults = .standard,
fileManager: FileManager = .default,
rootDirectory: URL? = nil,
remoteAssetStore: MenuBarIconRemoteAssetStore? = nil
) {
self.userDefaults = userDefaults
self.fileManager = fileManager
self.rootDirectory = rootDirectory ?? Self.defaultRootDirectory(fileManager: fileManager)
self.remoteAssetStore = remoteAssetStore ?? MenuBarIconRemoteAssetStore(
rootDirectory: self.rootDirectory
.appendingPathComponent("MenuBarIcons", isDirectory: true)
.appendingPathComponent("RemoteAssets", isDirectory: true),
fileManager: fileManager
)
self.storedState = Self.loadState(userDefaults: userDefaults)
pruneMissingRecentItems()
pruneMissingRemoteAssetSelection()
}
var recentItems: [MenuBarIconRecentItem] {
storedState.recentItems
}
var animationSpeedMode: MenuBarIconAnimationSpeedMode {
get { storedState.animationSpeedMode }
set {
guard storedState.animationSpeedMode != newValue else {
return
}
storedState.animationSpeedMode = newValue
invalidateImagePayloadCache()
persist()
}
}
var manualAnimationSpeedMultiplier: Double {
get { storedState.manualAnimationSpeedMultiplier }
set {
let normalizedValue = MenuBarIconAnimationSpeedPolicy.normalizedManualMultiplier(newValue)
guard storedState.manualAnimationSpeedMultiplier != normalizedValue else {
return
}
storedState.manualAnimationSpeedMultiplier = normalizedValue
invalidateImagePayloadCache()
persist()
}
}
var hasCustomIcon: Bool {
storedState.lightIconFileName != nil
|| storedState.darkIconFileName != nil
|| storedState.remoteAssetSelection != nil
}
var selectedRemoteAsset: MenuBarIconRemoteAssetSelection? {
storedState.remoteAssetSelection
}
func remoteAssetSelection(forRecentItem item: MenuBarIconRecentItem) -> MenuBarIconRemoteAssetSelection? {
remoteAssetSelection(for: item)
}
func isRemoteAssetCached(for item: MenuBarIconRecentItem) -> Bool {
guard let selection = remoteAssetSelection(for: item) else {
return false
}
return remoteAssetStore.hasFrames(for: selection)
}
func importIcon(from sourceURL: URL, for _: MenuBarIconAppearance) {
importIcon(from: sourceURL)
}
func importIcon(from sourceURL: URL) {
clearError()
guard let sourceImage = NSImage(contentsOf: sourceURL) else {
lastErrorMessage = AppL10n.settings("menuBarIcon.error.readSelectedImage", defaultValue: "无法读取所选图片。")
return
}
let processedImage = MenuBarIconBackgroundRemover.removingBackground(from: sourceImage) ?? sourceImage
let displayName = sourceURL.deletingPathExtension().lastPathComponent
let recentFileName = "recent-\(UUID().uuidString).png"
let recentURL = recentsDirectory.appendingPathComponent(recentFileName)
guard saveOriginalImage(processedImage, to: recentURL) else {
lastErrorMessage = AppL10n.settings("menuBarIcon.error.saveSelectedImage", defaultValue: "无法保存所选图片。")
return
}
let recentItem = MenuBarIconRecentItem(
id: UUID(),
fileName: recentFileName,
frameFileNames: [recentFileName],
displayName: displayName.isEmpty
? AppL10n.settings("menuBarIcon.recent.customIcon", defaultValue: "自定义图标")
: displayName,
addedAt: Date(),
mediaKind: .image,
frameDuration: 1.0 / MenuBarIconProcessing.animationFramesPerSecond
)
storeRecentItem(recentItem)
clearRemoteAssetSelection()
pruneUnusedRecentFiles()
setIconFileNameForAllAppearances(recentFileName)
invalidateAllIconCaches()
persist()
}
func importAnimation(from sourceURL: URL, for _: MenuBarIconAppearance) async {
await importAnimation(from: sourceURL)
}
func importAnimation(from sourceURL: URL) async {
clearError()
let sourceFrames: [NSImage]
do {
sourceFrames = try await MenuBarIconProcessing.animationFrameImages(from: sourceURL)
} catch let error as MenuBarIconImportError {
lastErrorMessage = error.userMessage
return
} catch {
lastErrorMessage = AppL10n.settings("menuBarIcon.importError.cannotDecodeAnimation", defaultValue: "无法解析这个动画文件。")
return
}
let animationID = UUID().uuidString
let fileNames = sourceFrames.indices.map { index in
"animation-\(animationID)-frame-\(index).png"
}
let processedFrames = sourceFrames.map { frame in
MenuBarIconBackgroundRemover.removingBackground(from: frame) ?? frame
}
guard saveAnimationFrames(processedFrames, fileNames: fileNames) else {
lastErrorMessage = AppL10n.settings("menuBarIcon.error.saveAnimationIcon", defaultValue: "无法保存动画图标。")
return
}
let displayName = sourceURL.deletingPathExtension().lastPathComponent
let recentItem = MenuBarIconRecentItem(
id: UUID(),
fileName: fileNames[0],
frameFileNames: fileNames,
displayName: displayName.isEmpty
? AppL10n.settings("menuBarIcon.recent.animationIcon", defaultValue: "动画图标")
: displayName,
addedAt: Date(),
mediaKind: .animation,
frameDuration: 1.0 / MenuBarIconProcessing.animationFramesPerSecond
)
storeRecentItem(recentItem)
clearRemoteAssetSelection()
pruneUnusedRecentFiles()
setIconFileNameForAllAppearances(recentItem.fileName)
invalidateAllIconCaches()
persist()
}
func useRemoteAsset(_ selection: MenuBarIconRemoteAssetSelection) {
clearError()
guard remoteAssetStore.hasFrames(for: selection) else {
lastErrorMessage = AppL10n.settings("menuBarIcon.error.remoteAssetNotReady", defaultValue: "在线图标文件尚未下载完成。")
return
}
storedState.remoteAssetSelection = selection
storedState.lightIconFileName = nil
storedState.darkIconFileName = nil
storeRemoteRecentItem(selection)
pruneUnusedRecentFiles()
invalidateAllIconCaches()
remoteAssetStore.pruneRemoteAssets(keeping: selection)
persist()
}
func useRecentIcon(_ item: MenuBarIconRecentItem, for _: MenuBarIconAppearance) {
useRecentIcon(item)
}
func useRecentIcon(_ item: MenuBarIconRecentItem) {
clearError()
if let remoteSelection = remoteAssetSelection(for: item) {
guard remoteAssetStore.hasFrames(for: remoteSelection) else {
lastErrorMessage = AppL10n.settings("menuBarIcon.error.recentRemoteMissing", defaultValue: "最近使用的在线图标文件已不存在。")
pruneMissingRecentItems()
return
}
storedState.remoteAssetSelection = remoteSelection
storedState.lightIconFileName = nil
storedState.darkIconFileName = nil
storeRemoteRecentItem(remoteSelection)
pruneUnusedRecentFiles()
invalidateSelectedIconCaches()
remoteAssetStore.pruneRemoteAssets(keeping: remoteSelection)
persist()
return
}
let requiredFileNames = item.mediaKind == .animation ? item.frameFileNames : [item.fileName]
guard requiredFileNames.allSatisfy({ fileManager.fileExists(atPath: recentsDirectory.appendingPathComponent($0).path) }) else {
lastErrorMessage = AppL10n.settings("menuBarIcon.error.recentIconMissing", defaultValue: "最近使用的图标文件已不存在。")
pruneMissingRecentItems()
return
}
setIconFileNameForAllAppearances(item.fileName)
clearRemoteAssetSelection()
invalidateSelectedIconCaches()
persist()
}
func resetToDefault() {
storedState.lightIconFileName = nil
storedState.darkIconFileName = nil
storedState.remoteAssetSelection = nil
remoteAssetStore.pruneRemoteAssets(keeping: nil)
invalidateAllIconCaches()
persist()
}
func imagePayload(for appearance: NSAppearance? = nil) -> MenuBarIconImagePayload {
let resolvedAppearance = resolvedAppearance(from: appearance)
return imagePayload(for: resolvedAppearance)
}
func previewImage(for appearance: MenuBarIconAppearance) -> NSImage {
imagePayload(for: appearance).image
}
func previewImage(for recentItem: MenuBarIconRecentItem) -> NSImage {
let cacheKey = RecentPreviewCacheKey(id: recentItem.id, fileName: recentItem.fileName)
if let cachedImage = recentPreviewCache[cacheKey] {
return cachedImage
}
let image: NSImage
if let thumbnailFileName = recentItem.thumbnailFileName,
let thumbnail = renderedImage(fileName: thumbnailFileName) {
image = thumbnail
} else if let remoteSelection = remoteAssetSelection(for: recentItem) {
image = renderedImages(for: remoteSelection).first ?? Self.defaultImage()
} else {
image = renderedImage(fileName: recentItem.fileName) ?? Self.defaultImage()
}
recentPreviewCache[cacheKey] = image
return image
}
func contrastReport(for appearance: MenuBarIconAppearance) -> MenuBarIconContrastReport {
if let cachedReport = contrastReportCache[appearance] {
return cachedReport
}
let report = MenuBarIconProcessing.contrastReport(for: previewImage(for: appearance))
contrastReportCache[appearance] = report
return report
}
func clearError() {
lastErrorMessage = nil
}
func reportError(_ message: String) {
lastErrorMessage = message
}
private func imagePayload(for resolvedAppearance: MenuBarIconAppearance) -> MenuBarIconImagePayload {
if let cachedPayload = imagePayloadCache[resolvedAppearance] {
return cachedPayload
}
let payload = makeImagePayload(for: resolvedAppearance)
imagePayloadCache[resolvedAppearance] = payload
return payload
}
private func makeImagePayload(for resolvedAppearance: MenuBarIconAppearance) -> MenuBarIconImagePayload {
guard let item = selectedRecentItem(for: resolvedAppearance) else {
if let selection = storedState.remoteAssetSelection,
remoteAssetStore.hasFrames(for: selection) {
let frames = renderedImages(for: selection)
if let image = frames.first {
image.isTemplate = false
for frame in frames {
frame.isTemplate = false
}
return MenuBarIconImagePayload(
image: image,
isTemplate: false,
animationFrames: frames,
frameDuration: selection.frameDuration,
speedMode: animationSpeedMode,
manualSpeedMultiplier: manualAnimationSpeedMultiplier
)
}
}
let image = Self.defaultImage()
image.isTemplate = true
return MenuBarIconImagePayload(
image: image,
isTemplate: true,
animationFrames: [image],
frameDuration: 1.0 / MenuBarIconProcessing.animationFramesPerSecond,
speedMode: animationSpeedMode,
manualSpeedMultiplier: manualAnimationSpeedMultiplier
)
}
let frames = renderedImages(for: item)
guard let image = frames.first else {
let image = Self.defaultImage()
image.isTemplate = true
return MenuBarIconImagePayload(
image: image,
isTemplate: true,
animationFrames: [image],
frameDuration: 1.0 / MenuBarIconProcessing.animationFramesPerSecond,
speedMode: animationSpeedMode,
manualSpeedMultiplier: manualAnimationSpeedMultiplier
)
}
image.isTemplate = false
for frame in frames {
frame.isTemplate = image.isTemplate
}
return MenuBarIconImagePayload(
image: image,
isTemplate: image.isTemplate,
animationFrames: frames,
frameDuration: item.frameDuration,
speedMode: animationSpeedMode,
manualSpeedMultiplier: manualAnimationSpeedMultiplier
)
}
private var iconsDirectory: URL {
rootDirectory.appendingPathComponent("MenuBarIcons", isDirectory: true)
}
private var recentsDirectory: URL {
iconsDirectory.appendingPathComponent("Recents", isDirectory: true)
}
private static func defaultRootDirectory(fileManager: FileManager) -> URL {
AppStorageScope.applicationSupportRoot(fileManager: fileManager)
}
private static func loadState(userDefaults: UserDefaults) -> StoredState {
guard let data = userDefaults.data(forKey: DefaultsKey.storage) else {
return StoredState()
}
do {
return try JSONDecoder().decode(StoredState.self, from: data)
} catch {
userDefaults.removeObject(forKey: DefaultsKey.storage)
return StoredState()
}
}
private static func defaultImage() -> NSImage {
let imageSize = NSSize(
width: MenuBarIconProcessing.standardIconPointSize,
height: MenuBarIconProcessing.standardIconPointSize
)
let image = NSImage(named: defaultIconName) ?? NSImage(size: imageSize)
image.size = imageSize
return image
}
private func persist() {
guard let data = try? encoder.encode(storedState) else {
return
}
userDefaults.set(data, forKey: DefaultsKey.storage)
settingsRevision += 1
}
private func selectedRecentItem(for appearance: MenuBarIconAppearance) -> MenuBarIconRecentItem? {
guard let selection = iconSelection(for: appearance) else {
return nil
}
return storedState.recentItems.first { item in
item.fileName == selection.fileName
}
}
private func renderedImages(for item: MenuBarIconRecentItem) -> [NSImage] {
let cacheKey = RenderedFramesCacheKey(
id: item.id,
fileName: item.fileName,
frameFileNames: item.frameFileNames
)
if let cachedImages = renderedFramesCache[cacheKey] {
return cachedImages
}
let frameFileNames = item.mediaKind == .animation ? item.frameFileNames : [item.fileName]
let sourceImages = frameFileNames.compactMap { fileName in
let url = recentsDirectory.appendingPathComponent(fileName)
return NSImage(contentsOf: url)
}
let images = item.mediaKind == .animation
? MenuBarIconProcessing.renderedImages(from: sourceImages)
: sourceImages.compactMap { MenuBarIconProcessing.renderedImage(from: $0) }
renderedFramesCache[cacheKey] = images
return images
}
private func renderedImages(for selection: MenuBarIconRemoteAssetSelection) -> [NSImage] {
let cacheKey = RemoteFramesCacheKey(
id: selection.id,
version: selection.version,
frameFileNames: selection.frameFileNames
)
if let cachedImages = remoteFramesCache[cacheKey] {
return cachedImages
}
let sourceImages = remoteAssetStore.frameURLs(for: selection).compactMap { url in
NSImage(contentsOf: url)
}
let images = MenuBarIconProcessing.renderedImages(from: sourceImages)
remoteFramesCache[cacheKey] = images
return images
}
private func renderedImage(fileName: String) -> NSImage? {
let url = recentsDirectory.appendingPathComponent(fileName)
guard let sourceImage = NSImage(contentsOf: url) else {
return nil
}
return MenuBarIconProcessing.renderedImage(from: sourceImage)
}
private func iconSelection(for appearance: MenuBarIconAppearance) -> (fileName: String, appearance: MenuBarIconAppearance)? {
switch appearance {
case .light:
return storedState.lightIconFileName.map { (fileName: $0, appearance: .light) }
case .dark:
if let darkIconFileName = storedState.darkIconFileName {
return (fileName: darkIconFileName, appearance: .dark)
}
return storedState.lightIconFileName.map { (fileName: $0, appearance: .light) }
}
}
private func storeRecentItem(_ recentItem: MenuBarIconRecentItem) {
storedState.recentItems.removeAll { item in
item.fileName == recentItem.fileName || item.displayName == recentItem.displayName
}
storedState.recentItems.insert(recentItem, at: 0)
storedState.recentItems = Array(storedState.recentItems.prefix(Self.maxRecentItems))
}
private func storeRemoteRecentItem(_ selection: MenuBarIconRemoteAssetSelection) {
let referenceFileName = MenuBarIconRemoteAssetReference.fileName(
assetID: selection.id,
version: selection.version
)
let thumbnailFileName = saveRemoteThumbnailIfNeeded(for: selection)
let recentItem = MenuBarIconRecentItem(
id: UUID(),
fileName: referenceFileName,
frameFileNames: selection.frameFileNames,
displayName: selection.displayName,
addedAt: Date(),
mediaKind: .animation,
frameDuration: selection.frameDuration,
thumbnailFileName: thumbnailFileName
)
storedState.recentItems.removeAll { item in
item.fileName == referenceFileName
|| item.displayName == selection.displayName
}
storedState.recentItems.insert(recentItem, at: 0)
storedState.recentItems = Array(storedState.recentItems.prefix(Self.maxRecentItems))
}
private func remoteAssetSelection(for item: MenuBarIconRecentItem) -> MenuBarIconRemoteAssetSelection? {
guard let reference = MenuBarIconRemoteAssetReference.parse(item.fileName) else {
return nil
}
return MenuBarIconRemoteAssetSelection(
id: reference.assetID,
version: reference.version,
displayName: item.displayName,
frameFileNames: item.frameFileNames,
frameDuration: item.frameDuration
)
}
private func setIconFileNameForAllAppearances(_ fileName: String?) {
storedState.lightIconFileName = fileName
storedState.darkIconFileName = fileName
}
private func clearRemoteAssetSelection() {
storedState.remoteAssetSelection = nil
remoteAssetStore.pruneRemoteAssets(keeping: nil)
}
private func saveRemoteThumbnailIfNeeded(for selection: MenuBarIconRemoteAssetSelection) -> String? {
guard let thumbnail = renderedImages(for: selection).first,
let data = MenuBarIconProcessing.pngData(from: thumbnail)
else {
return nil
}
let fileName = "remote-thumbnail-\(UUID().uuidString).png"
let destinationURL = recentsDirectory.appendingPathComponent(fileName, isDirectory: false)
do {
try fileManager.createDirectory(at: recentsDirectory, withIntermediateDirectories: true)
try data.write(to: destinationURL, options: .atomic)
return fileName
} catch {
return nil
}
}
private func saveOriginalImage(_ image: NSImage, to destinationURL: URL) -> Bool {
guard let data = MenuBarIconProcessing.pngData(from: image) else {
return false
}
do {
try fileManager.createDirectory(at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true)
try data.write(to: destinationURL, options: .atomic)
return true
} catch {
lastErrorMessage = AppL10n.settings("menuBarIcon.error.saveSettings", defaultValue: "无法保存图标设置。")
return false
}
}
private func saveAnimationFrames(_ frames: [NSImage], fileNames: [String]) -> Bool {
guard frames.count == fileNames.count, !frames.isEmpty else {
return false
}
do {
try fileManager.createDirectory(at: recentsDirectory, withIntermediateDirectories: true)
for (frame, fileName) in zip(frames, fileNames) {
guard let data = MenuBarIconProcessing.pngData(from: frame) else {
return false
}
let destinationURL = recentsDirectory.appendingPathComponent(fileName)
try data.write(to: destinationURL, options: .atomic)
}
return true
} catch {
lastErrorMessage = AppL10n.settings("menuBarIcon.error.saveAnimationIcon", defaultValue: "无法保存动画图标。")
return false
}
}
private func resolvedAppearance(from appearance: NSAppearance?) -> MenuBarIconAppearance {
let bestMatch = (appearance ?? NSApp.effectiveAppearance).bestMatch(from: [.darkAqua, .aqua])
return bestMatch == .darkAqua ? .dark : .light
}
private func pruneMissingRecentItems() {
let items = storedState.recentItems.filter { item in
if let selection = remoteAssetSelection(for: item) {
if let thumbnailFileName = item.thumbnailFileName,
fileManager.fileExists(atPath: recentsDirectory.appendingPathComponent(thumbnailFileName).path) {
return true
}
return remoteAssetStore.hasFrames(for: selection)
}
let requiredFileNames = item.mediaKind == .animation ? item.frameFileNames : [item.fileName]
return requiredFileNames.allSatisfy { fileName in
fileManager.fileExists(atPath: recentsDirectory.appendingPathComponent(fileName).path)
}
}
guard items != storedState.recentItems else {
return
}
storedState.recentItems = items
invalidateAllIconCaches()
persist()
}
private func pruneUnusedRecentFiles() {
guard let fileURLs = try? fileManager.contentsOfDirectory(
at: recentsDirectory,
includingPropertiesForKeys: [.isRegularFileKey],
options: [.skipsHiddenFiles]
) else {
return
}
let referencedFileNames = referencedRecentFileNames()
for fileURL in fileURLs where !referencedFileNames.contains(fileURL.lastPathComponent) {
try? fileManager.removeItem(at: fileURL)
}
}
private func referencedRecentFileNames() -> Set<String> {
var fileNames = Set<String>()
for item in storedState.recentItems {
if let thumbnailFileName = item.thumbnailFileName {
fileNames.insert(thumbnailFileName)
}
guard remoteAssetSelection(for: item) == nil else {
continue
}
if item.mediaKind == .animation {
fileNames.formUnion(item.frameFileNames)
} else {
fileNames.insert(item.fileName)
}
}
return fileNames
}
private func pruneMissingRemoteAssetSelection() {
guard let selection = storedState.remoteAssetSelection,
!remoteAssetStore.hasFrames(for: selection)
else {
return
}
storedState.remoteAssetSelection = nil
invalidateAllIconCaches()
remoteAssetStore.pruneRemoteAssets(keeping: nil)
persist()
}
private func invalidateImagePayloadCache() {
imagePayloadCache.removeAll()
}
private func invalidateSelectedIconCaches() {
imagePayloadCache.removeAll()
contrastReportCache.removeAll()
}
private func invalidateAllIconCaches() {
invalidateSelectedIconCaches()
renderedFramesCache.removeAll()
remoteFramesCache.removeAll()
recentPreviewCache.removeAll()
}
}