From 8b633b3ff514aaf39d603e7bb1e3fef92791be70 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Wed, 28 Jan 2026 22:45:53 +0530 Subject: [PATCH] Update AppIcon setting --- DynamicIsland/DynamicIslandApp.swift | 2 + DynamicIsland/Localizable.xcstrings | 9 + .../components/Settings/SettingsView.swift | 223 +++++++++++++++--- DynamicIsland/helpers/AppIcons.swift | 16 ++ DynamicIsland/models/Constants.swift | 28 +++ 5 files changed, 244 insertions(+), 34 deletions(-) diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index c70629a..0bf439e 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -351,6 +351,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Initialize idle animations (load bundled + built-in face) idleAnimationManager.initializeDefaultAnimations() + + applySelectedAppIcon() // Setup SystemHUD Manager SystemHUDManager.shared.setup(coordinator: coordinator) diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index ea6078e..3239f67 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -2061,6 +2061,9 @@ } } } + }, + "Add icon" : { + }, "Add manually" : { "extractionState" : "stale", @@ -7503,6 +7506,9 @@ "Drag the previews to adjust vertical placement. Positive values lift the panel; negative values lower it. Use the width sliders below to narrow the media and timer widgets without exceeding their default size. Changes apply instantly while the widgets are visible." : { "comment" : "A footer text describing the functionality of the lock screen positioning controls.", "isCommentAutoGenerated" : true + }, + "Drop a PNG, JPEG, TIFF, or ICNS file to add it to your icon library." : { + }, "Drop files here" : { "localizations" : { @@ -16039,6 +16045,9 @@ }, "Remove from shelf after dragging" : { + }, + "Remove selected" : { + }, "Request Access" : { "localizations" : { diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 436710f..270da56 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -677,6 +677,7 @@ struct SettingsView: View { SettingsSearchEntry(tab: .appearance, title: "Enable Dynamic mirror", keywords: ["mirror", "reflection"], highlightID: SettingsTab.appearance.highlightID(for: "Enable Dynamic mirror")), SettingsSearchEntry(tab: .appearance, title: "Mirror shape", keywords: ["mirror shape", "circle", "rectangle"], highlightID: SettingsTab.appearance.highlightID(for: "Mirror shape")), SettingsSearchEntry(tab: .appearance, title: "Show cool face animation while inactivity", keywords: ["face animation", "idle"], highlightID: SettingsTab.appearance.highlightID(for: "Show cool face animation while inactivity")), + SettingsSearchEntry(tab: .appearance, title: "App icon", keywords: ["app icon", "custom icon"], highlightID: SettingsTab.appearance.highlightID(for: "App icon")), // Lock Screen SettingsSearchEntry(tab: .lockScreen, title: "Enable lock screen live activity", keywords: ["lock screen", "live activity"], highlightID: SettingsTab.lockScreen.highlightID(for: "Enable lock screen live activity")), @@ -2827,6 +2828,8 @@ struct Appearance: View { @Default(.useMusicVisualizer) var useMusicVisualizer @Default(.customVisualizers) var customVisualizers @Default(.selectedVisualizer) var selectedVisualizer + @Default(.customAppIcons) private var customAppIcons + @Default(.selectedAppIconID) private var selectedAppIconID @Default(.openNotchWidth) var openNotchWidth @Default(.enableMinimalisticUI) var enableMinimalisticUI @Default(.lockScreenGlassCustomizationMode) private var lockScreenGlassCustomizationMode @@ -2838,10 +2841,12 @@ struct Appearance: View { @Default(.lockScreenTimerWidgetUsesBlur) private var timerGlassModeIsGlass @Default(.enableLockScreenMediaWidget) private var enableLockScreenMediaWidget @Default(.enableLockScreenTimerWidget) private var enableLockScreenTimerWidget - let icons: [String] = ["logo2"] - @State private var selectedIcon: String = "logo2" @State private var selectedListVisualizer: CustomVisualizer? = nil + @State private var isIconImporterPresented = false + @State private var isIconDropTarget = false + @State private var iconImportError: String? + @State private var isPresented: Bool = false @State private var name: String = "" @State private var url: String = "" @@ -3185,55 +3190,205 @@ struct Appearance: View { IdleAnimationsSettingsSection() Section { - HStack { - ForEach(icons, id: \.self) { icon in - Spacer() - VStack { - Image(icon) - .resizable() - .frame(width: 80, height: 80) - .background( - RoundedRectangle(cornerRadius: 20, style: .circular) - .strokeBorder( - icon == selectedIcon ? Color.accentColor : .clear, - lineWidth: 2.5 - ) - ) + VStack(alignment: .leading, spacing: 12) { + let columns = [GridItem(.adaptive(minimum: 90), spacing: 12)] + LazyVGrid(columns: columns, spacing: 12) { + appIconCard( + title: "Default", + image: defaultAppIconImage(), + isSelected: selectedAppIconID == nil + ) { + selectedAppIconID = nil + applySelectedAppIcon() + } - Text("Default") - .fontWeight(.medium) - .font(.caption) - .foregroundStyle(icon == selectedIcon ? .white : .secondary) - .padding(.horizontal, 10) - .padding(.vertical, 3) - .background( - Capsule() - .fill(icon == selectedIcon ? Color.accentColor : .clear) - ) - } - .onTapGesture { - withAnimation { - selectedIcon = icon + ForEach(customAppIcons) { icon in + appIconCard( + title: icon.name, + image: customIconImage(for: icon), + isSelected: selectedAppIconID == icon.id.uuidString + ) { + selectedAppIconID = icon.id.uuidString + applySelectedAppIcon() + } + .contextMenu { + Button("Remove") { + removeCustomIcon(icon) + } } - NSApp.applicationIconImage = NSImage(named: icon) } - Spacer() + } + .padding(8) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color.secondary.opacity(isIconDropTarget ? 0.18 : 0.1)) + ) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(Color.accentColor.opacity(isIconDropTarget ? 0.8 : 0), lineWidth: 2) + ) + .onDrop(of: [UTType.fileURL], isTargeted: $isIconDropTarget) { providers in + handleIconDrop(providers) + } + + HStack(spacing: 8) { + Button("Add icon") { + iconImportError = nil + isIconImporterPresented = true + } + .buttonStyle(.borderedProminent) + + Button("Remove selected") { + if let id = selectedAppIconID, + let icon = customAppIcons.first(where: { $0.id.uuidString == id }) { + removeCustomIcon(icon) + } + } + .buttonStyle(.bordered) + .disabled(selectedAppIconID == nil) + } + + if let iconImportError { + Text(iconImportError) + .font(.caption) + .foregroundStyle(.secondary) + } else { + Text("Drop a PNG, JPEG, TIFF, or ICNS file to add it to your icon library.") + .font(.caption) + .foregroundStyle(.secondary) } } - .disabled(true) + .settingsHighlight(id: highlightID("App icon")) } header: { HStack { Text("App icon") - customBadge(text: "Coming soon") } } } .onAppear(perform: enforceLockScreenGlassConsistency) .onChange(of: lockScreenGlassStyle) { _, _ in enforceLockScreenGlassConsistency() } .onChange(of: lockScreenGlassCustomizationMode) { _, _ in enforceLockScreenGlassConsistency() } + .fileImporter( + isPresented: $isIconImporterPresented, + allowedContentTypes: [.png, .jpeg, .tiff, .icns, .image] + ) { result in + switch result { + case .success(let url): + importCustomIcon(from: url) + case .failure: + iconImportError = "Icon import was canceled or failed." + } + } .navigationTitle("Appearance") } + private func defaultAppIconImage() -> NSImage? { + let fallbackName = Bundle.main.iconFileName ?? "AppIcon" + return NSImage(named: fallbackName) + } + + private func customIconImage(for icon: CustomAppIcon) -> NSImage? { + NSImage(contentsOf: icon.fileURL) + } + + private func appIconCard(title: String, image: NSImage?, isSelected: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + VStack(spacing: 6) { + Group { + if let image { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + } else { + Image(systemName: "app.dashed") + .font(.system(size: 28, weight: .medium)) + .foregroundStyle(.secondary) + } + } + .frame(width: 64, height: 64) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color.black.opacity(0.08)) + ) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(isSelected ? Color.accentColor : .clear, lineWidth: 2) + ) + + Text(title) + .font(.caption) + .lineLimit(1) + .foregroundStyle(isSelected ? .white : .secondary) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background( + Capsule().fill(isSelected ? Color.accentColor : .clear) + ) + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.plain) + } + + private func handleIconDrop(_ providers: [NSItemProvider]) -> Bool { + let matching = providers.first { $0.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) } + guard let provider = matching else { return false } + provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, _ in + let url: URL? + if let directURL = item as? URL { + url = directURL + } else if let data = item as? Data { + url = URL(dataRepresentation: data, relativeTo: nil) + } else { + url = nil + } + guard let url else { return } + Task { @MainActor in importCustomIcon(from: url) } + } + return true + } + + private func importCustomIcon(from url: URL) { + guard let image = NSImage(contentsOf: url) else { + iconImportError = "That file could not be loaded as an image." + return + } + let name = url.deletingPathExtension().lastPathComponent + let ext = url.pathExtension.isEmpty ? "png" : url.pathExtension + let id = UUID() + let fileName = "custom-icon-\(id.uuidString).\(ext)" + let destination = CustomAppIcon.iconDirectory.appendingPathComponent(fileName) + + do { + let data = try Data(contentsOf: url) + try data.write(to: destination, options: [.atomic]) + } catch { + iconImportError = "Unable to save the icon file." + return + } + + let newIcon = CustomAppIcon(id: id, name: name.isEmpty ? "Custom Icon" : name, fileName: fileName) + if !customAppIcons.contains(newIcon) { + customAppIcons.append(newIcon) + } + selectedAppIconID = newIcon.id.uuidString + NSApp.applicationIconImage = image + iconImportError = nil + } + + private func removeCustomIcon(_ icon: CustomAppIcon) { + if let index = customAppIcons.firstIndex(of: icon) { + customAppIcons.remove(at: index) + } + if FileManager.default.fileExists(atPath: icon.fileURL.path) { + try? FileManager.default.removeItem(at: icon.fileURL) + } + if selectedAppIconID == icon.id.uuidString { + selectedAppIconID = nil + applySelectedAppIcon() + } + } + func checkVideoInput() -> Bool { if let _ = AVCaptureDevice.default(for: .video) { return true diff --git a/DynamicIsland/helpers/AppIcons.swift b/DynamicIsland/helpers/AppIcons.swift index 5941fc0..af0c6a2 100644 --- a/DynamicIsland/helpers/AppIcons.swift +++ b/DynamicIsland/helpers/AppIcons.swift @@ -7,6 +7,7 @@ import SwiftUI import AppKit +import Defaults struct AppIcons { @@ -58,3 +59,18 @@ func AppIconAsNSImage(for bundleID: String) -> NSImage? { return nil } +func applySelectedAppIcon() { + let customIcons = Defaults[.customAppIcons] + if let selectedID = Defaults[.selectedAppIconID], + let icon = customIcons.first(where: { $0.id.uuidString == selectedID }), + let image = NSImage(contentsOf: icon.fileURL) { + NSApp.applicationIconImage = image + return + } + + let fallbackName = Bundle.main.iconFileName ?? "AppIcon" + if let image = NSImage(named: fallbackName) { + NSApp.applicationIconImage = image + } +} + diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 1202417..47e6228 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -27,6 +27,32 @@ struct CustomVisualizer: Codable, Hashable, Equatable, Defaults.Serializable { var speed: CGFloat = 1.0 } +struct CustomAppIcon: Codable, Hashable, Equatable, Defaults.Serializable, Identifiable { + let id: UUID + var name: String + var fileName: String + var addedAt: Date + + init(id: UUID = UUID(), name: String, fileName: String, addedAt: Date = .now) { + self.id = id + self.name = name + self.fileName = fileName + self.addedAt = addedAt + } + + static let iconDirectory: URL = { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + let dir = base.appendingPathComponent("DynamicIsland", isDirectory: true) + .appendingPathComponent("AppIcons", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + }() + + var fileURL: URL { + Self.iconDirectory.appendingPathComponent(fileName) + } +} + // MARK: - Custom Idle Animation Models struct CustomIdleAnimation: Codable, Hashable, Equatable, Defaults.Serializable, Identifiable { let id: UUID @@ -656,6 +682,8 @@ extension Defaults.Keys { static let useMusicVisualizer = Key("useMusicVisualizer", default: true) static let customVisualizers = Key<[CustomVisualizer]>("customVisualizers", default: []) static let selectedVisualizer = Key("selectedVisualizer", default: nil) + static let customAppIcons = Key<[CustomAppIcon]>("customAppIcons", default: []) + static let selectedAppIconID = Key("selectedAppIconID", default: nil) // MARK: Gestures static let enableGestures = Key("enableGestures", default: true)