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.. 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.. 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 { var fileNames = Set() 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() } }