From 0064142c5ff6dcaa1463ed56339617c3a0fc659a Mon Sep 17 00:00:00 2001 From: LMC-Karma Date: Tue, 20 Oct 2026 20:22:22 -0700 Subject: [PATCH 1/4] Make Dark the default app icon --- BetterTile.xcodeproj/project.pbxproj | 8 ++++---- Sources/BetterTileApp/BetterTileApp.swift | 5 +++-- Sources/BetterTileApp/SettingsView.swift | 6 +++--- .../AppIconStyleTests.swift | 6 +++--- docs/icon-composer/README.md | 19 +++++++++++++++---- 5 files changed, 28 insertions(+), 16 deletions(-) diff --git a/BetterTile.xcodeproj/project.pbxproj b/BetterTile.xcodeproj/project.pbxproj index 6a3bcce1..58486cc3 100644 --- a/BetterTile.xcodeproj/project.pbxproj +++ b/BetterTile.xcodeproj/project.pbxproj @@ -282,8 +282,8 @@ A00000000000000000000003 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIconDebug; - ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = "AppIcon AppIconIce AppIconIceDebug AppIconDark AppIconDarkDebug"; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIconDarkDebug; + ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = "AppIcon AppIconDebug AppIconIce AppIconIceDebug AppIconDark"; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; BETTERTILE_ENABLE_AUTOMATIC_CHECKS = NO; BETTERTILE_UPDATE_FEED_URL = ""; @@ -313,8 +313,8 @@ A00000000000000000000004 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = "AppIconIce AppIconDark"; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIconDark; + ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = "AppIcon AppIconIce"; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; BETTERTILE_ENABLE_AUTOMATIC_CHECKS = YES; BETTERTILE_UPDATE_FEED_URL = "https://github.com/LMC-Karma/BetterTile/releases/latest/download/appcast.xml"; diff --git a/Sources/BetterTileApp/BetterTileApp.swift b/Sources/BetterTileApp/BetterTileApp.swift index 36dd9ef7..35155153 100644 --- a/Sources/BetterTileApp/BetterTileApp.swift +++ b/Sources/BetterTileApp/BetterTileApp.swift @@ -92,6 +92,7 @@ enum AppAppearance: String, CaseIterable, Identifiable { enum AppIconStyle: String, CaseIterable, Identifiable { static let defaultsKey = "BetterTileAppIcon" + static let defaultStyle: Self = .dark case classic case ice @@ -120,7 +121,7 @@ enum AppIconStyle: String, CaseIterable, Identifiable { } static func selected(in defaults: UserDefaults = .standard) -> Self { - defaults.string(forKey: defaultsKey).flatMap(Self.init(rawValue:)) ?? .classic + defaults.string(forKey: defaultsKey).flatMap(Self.init(rawValue:)) ?? defaultStyle } @MainActor @@ -130,7 +131,7 @@ enum AppIconStyle: String, CaseIterable, Identifiable { static func apply(_ style: Self? = nil) { let selected = style ?? selected() // nil restores the system-rendered primary Icon Composer icon. - NSApp.applicationIconImage = selected == .classic ? nil : selected.image + NSApp.applicationIconImage = selected == defaultStyle ? nil : selected.image NSApp.dockTile.display() } } diff --git a/Sources/BetterTileApp/SettingsView.swift b/Sources/BetterTileApp/SettingsView.swift index 65e7ac84..cf0a4061 100644 --- a/Sources/BetterTileApp/SettingsView.swift +++ b/Sources/BetterTileApp/SettingsView.swift @@ -523,7 +523,7 @@ private struct GeneralSettings: View { struct AppIconPicker: View { @Binding var showDockIcon: Bool - @AppStorage(AppIconStyle.defaultsKey) private var selectedRawValue = AppIconStyle.classic.rawValue + @AppStorage(AppIconStyle.defaultsKey) private var selectedRawValue = AppIconStyle.defaultStyle.rawValue var body: some View { Toggle("Show Dock icon", isOn: $showDockIcon) @@ -535,7 +535,7 @@ struct AppIconPicker: View { } } Text(showDockIcon - ? "Changes the Dock icon while BetterTile is running. Finder uses Classic." + ? "Changes the Dock icon while BetterTile is running. Finder uses Dark." : "Your choice is saved. Turn on Show Dock icon above to display it in the Dock.") .font(.system(size: 12)) .foregroundStyle(.secondary) @@ -544,7 +544,7 @@ struct AppIconPicker: View { } private func iconChoice(_ style: AppIconStyle) -> some View { - let selected = (AppIconStyle(rawValue: selectedRawValue) ?? .classic) == style + let selected = (AppIconStyle(rawValue: selectedRawValue) ?? .defaultStyle) == style return Button { select(style) } label: { diff --git a/Tests/BetterTileAppTests/AppIconStyleTests.swift b/Tests/BetterTileAppTests/AppIconStyleTests.swift index 4eda3933..f9664646 100644 --- a/Tests/BetterTileAppTests/AppIconStyleTests.swift +++ b/Tests/BetterTileAppTests/AppIconStyleTests.swift @@ -2,16 +2,16 @@ import Foundation import Testing @testable import BetterTileApp -@Test func appIconChoiceRestoresAndFallsBackToClassic() { +@Test func appIconChoiceRestoresAndFallsBackToDark() { let defaults = MemoryDefaults() - #expect(AppIconStyle.selected(in: defaults) == .classic) + #expect(AppIconStyle.selected(in: defaults) == .dark) defaults.set("ice", forKey: AppIconStyle.defaultsKey) #expect(AppIconStyle.selected(in: defaults) == .ice) defaults.set("dark", forKey: AppIconStyle.defaultsKey) #expect(AppIconStyle.selected(in: defaults) == .dark) defaults.set("unknown-future-icon", forKey: AppIconStyle.defaultsKey) - #expect(AppIconStyle.selected(in: defaults) == .classic) + #expect(AppIconStyle.selected(in: defaults) == .dark) defaults.set("classic", forKey: AppIconStyle.defaultsKey) #expect(AppIconStyle.selected(in: defaults) == .classic) } diff --git a/docs/icon-composer/README.md b/docs/icon-composer/README.md index c824f1b2..b4fbf858 100644 --- a/docs/icon-composer/README.md +++ b/docs/icon-composer/README.md @@ -1,5 +1,7 @@ # App icon variants +Dark is the default app icon in Debug and Release, including Finder and the +installed app. Debug uses the matching Dark icon with its D badge. Classic uses the existing [AppIcon.icon](../../Resources/AppIcon.icon) document. [Ice Blue](../../Resources/AppIconIce.icon) derives from that document. It keeps the pane outlines, corner radii, canvas, layer order, positions, and scale. @@ -22,8 +24,9 @@ composition, and matching Debug copies. Settings → General → Appearance has three buttons: Classic, Ice Blue, and Dark. Each button contains its centered preview and name. A border and checkmark show the selected choice. Tab moves between choices; Space or Return selects one. -Classic is the default. The local `BetterTileAppIcon` preference saves -the selection across launches. Unknown values use Classic. Debug and Release +Dark is the default. The local `BetterTileAppIcon` preference saves +the selection across launches. Missing and unknown values use Dark. Existing +Classic, Ice Blue, and Dark choices are preserved. Debug and Release keep their existing separate preferences and Debug retains its D badge. Show Dock icon sits directly above the picker. When it is off, the picker @@ -33,12 +36,20 @@ change the Dock visibility preference when selecting an icon. The picker loads icons from Xcode's compiled asset catalog and uses the public AppKit `NSApplication.applicationIconImage` API. It changes the running app's -Dock icon. Finder and the installed app keep the Classic icon. Returning to -Classic restores the system's primary icon. This follows +Dock icon. Finder and the installed app use the Dark icon. Returning to +Dark restores the system's primary icon. This follows [Apple's documented Dock icon behavior](https://developer.apple.com/documentation/appkit/nsapplication/applicationiconimage). No new dependency, permission, networking, private API, or window mutation is introduced. The only new stored value is the local icon preference. +The picker does not set a custom Finder icon on the app bundle. +An isolated signed test app on macOS 27.0.1 accepted the public +`NSWorkspace.setIcon(_:forFile:options:)` call, but strict signature verification +then failed with "resource fork, Finder information, or similar detritus not +allowed". The call added an `Icon` file with a trailing carriage return and +Finder metadata. Applying picker choices to Finder needs a separate design +that preserves signature verification and the update path. + ## Verification - Debug and Release Xcode builds with `CODE_SIGNING_ALLOWED=NO`. From 8830997c12bd793a8e94375933beafa939bc7870 Mon Sep 17 00:00:00 2001 From: LMC-Karma Date: Tue, 20 Oct 2026 20:34:40 -0700 Subject: [PATCH 2/4] Apply app icon choices to Finder with public metadata APIs --- SECURITY.md | 19 +++++ Sources/BetterTileApp/BetterTileApp.swift | 24 +++++- Sources/BetterTileApp/SettingsView.swift | 14 +++- .../AppIconFinderIntegrationTests.swift | 77 +++++++++++++++++++ .../AppIconStyleTests.swift | 51 ++++++++++++ docs/icon-composer/README.md | 73 +++++++++++++----- 6 files changed, 235 insertions(+), 23 deletions(-) create mode 100644 Tests/BetterTileAppTests/AppIconFinderIntegrationTests.swift diff --git a/SECURITY.md b/SECURITY.md index 5e12a0d2..1d58aea7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -265,6 +265,25 @@ Sparkle is the sole current runtime dependency. It verifies BetterTile update archives with an EdDSA signature. Its private signing key remains outside the repository and GitHub Actions. +## App icon customization + +The App icon picker saves a local `BetterTileAppIcon` preference and changes +BetterTile's Dock and Finder icons through public AppKit APIs. A Finder +override writes generated icon data and Finder metadata at the running app +bundle's root. It requires a writable app and never requests elevation. A +failed write is reported in Settings when the user selects an icon; the saved +choice remains available for the Dock and for the next launch. A fresh install +does not replace a custom icon the user set outside BetterTile. + +This operation does not change the executable, Info.plist, signed resources, +or signing identity, and does not re-sign the app. Ordinary signature +verification passes in the isolated integration test. Strict signature +verification rejects the Finder metadata while a custom icon is active; +selecting the default Dark icon clears the override and restores strict +verification. Release packaging retains its existing strict checks. No +permission, private API, dependency, telemetry, or network request is added. +See [icon validation and limits](docs/icon-composer/README.md#finder-metadata-and-signature-checks). + ## Distribution Public beta builds are signed with the maintainer-held, self-signed diff --git a/Sources/BetterTileApp/BetterTileApp.swift b/Sources/BetterTileApp/BetterTileApp.swift index 35155153..ca36fcb6 100644 --- a/Sources/BetterTileApp/BetterTileApp.swift +++ b/Sources/BetterTileApp/BetterTileApp.swift @@ -128,11 +128,33 @@ enum AppIconStyle: String, CaseIterable, Identifiable { var image: NSImage? { Bundle.main.image(forResource: resourceName) } @MainActor - static func apply(_ style: Self? = nil) { + @discardableResult + static func apply(_ style: Self? = nil) -> Bool { let selected = style ?? selected() + // A fresh install already has the primary icon. Only an explicit or saved + // choice should replace an icon the user may have set outside BetterTile. + let updatesFinder = style != nil || UserDefaults.standard.string(forKey: defaultsKey) != nil + let finderUpdated = !updatesFinder || selected.updateFinderIcon() // nil restores the system-rendered primary Icon Composer icon. + // Clear the Finder override first so the Dock cannot pick up the old icon. NSApp.applicationIconImage = selected == defaultStyle ? nil : selected.image NSApp.dockTile.display() + return finderUpdated + } + + @MainActor + func updateFinderIcon( + at bundleURL: URL = Bundle.main.bundleURL, + loadImage: (String) -> NSImage? = { Bundle.main.image(forResource: $0) }, + setIcon: (NSImage?, String) -> Bool = { NSWorkspace.shared.setIcon($0, forFile: $1, options: []) } + ) -> Bool { + guard bundleURL.isFileURL, bundleURL.pathExtension == "app" else { return false } + if self == Self.defaultStyle { + return setIcon(nil, bundleURL.path) + } + // A missing alternate must not erase an existing Finder icon. + guard let image = loadImage(resourceName) else { return false } + return setIcon(image, bundleURL.path) } } diff --git a/Sources/BetterTileApp/SettingsView.swift b/Sources/BetterTileApp/SettingsView.swift index cf0a4061..4ebb4051 100644 --- a/Sources/BetterTileApp/SettingsView.swift +++ b/Sources/BetterTileApp/SettingsView.swift @@ -524,6 +524,7 @@ private struct GeneralSettings: View { struct AppIconPicker: View { @Binding var showDockIcon: Bool @AppStorage(AppIconStyle.defaultsKey) private var selectedRawValue = AppIconStyle.defaultStyle.rawValue + @State private var finderIconUpdateFailed = false var body: some View { Toggle("Show Dock icon", isOn: $showDockIcon) @@ -535,10 +536,15 @@ struct AppIconPicker: View { } } Text(showDockIcon - ? "Changes the Dock icon while BetterTile is running. Finder uses Dark." - : "Your choice is saved. Turn on Show Dock icon above to display it in the Dock.") + ? "Changes BetterTile’s icon in the Dock and Finder." + : "Changes BetterTile’s icon in Finder. Turn on Show Dock icon above to display it in the Dock.") .font(.system(size: 12)) .foregroundStyle(.secondary) + if finderIconUpdateFailed { + Text("Finder’s icon couldn’t be updated. Make sure BetterTile is in a writable folder, then select the icon again.") + .font(.system(size: 12)) + .foregroundStyle(.red) + } } .padding(.vertical, 4) } @@ -590,12 +596,12 @@ struct AppIconPicker: View { } .accessibilityLabel(style.title) .accessibilityAddTraits(selected ? .isSelected : []) - .help("Use the \(style.title) icon in the Dock") + .help("Use the \(style.title) icon in the Dock and Finder") } private func select(_ style: AppIconStyle) { selectedRawValue = style.rawValue - AppIconStyle.apply(style) + finderIconUpdateFailed = !AppIconStyle.apply(style) } } diff --git a/Tests/BetterTileAppTests/AppIconFinderIntegrationTests.swift b/Tests/BetterTileAppTests/AppIconFinderIntegrationTests.swift new file mode 100644 index 00000000..f864f547 --- /dev/null +++ b/Tests/BetterTileAppTests/AppIconFinderIntegrationTests.swift @@ -0,0 +1,77 @@ +import AppKit +import Foundation +import Testing +@testable import BetterTileApp + +/// Uses only a disposable app bundle. No installed app or live window is changed. +@Test(.enabled(if: ProcessInfo.processInfo.environment["BETTERTILE_TEST_FINDER_ICON"] == "1")) +@MainActor func finderIconChangesPreserveSignedContentsAndRestoreThePrimaryIcon() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory.appendingPathComponent("BetterTile-Icon-\(UUID().uuidString)") + defer { try? fileManager.removeItem(at: root) } + let app = root.appendingPathComponent("Probe.app") + let contents = app.appendingPathComponent("Contents") + try fileManager.createDirectory(at: contents.appendingPathComponent("MacOS"), withIntermediateDirectories: true) + try fileManager.createDirectory(at: contents.appendingPathComponent("Resources"), withIntermediateDirectories: true) + try fileManager.copyItem(atPath: "/usr/bin/true", toPath: contents.appendingPathComponent("MacOS/Probe").path) + let info = [ + "CFBundleIdentifier": "com.lmckarma.BetterTile.icon-test", + "CFBundleExecutable": "Probe", + "CFBundlePackageType": "APPL", + ] + try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0) + .write(to: contents.appendingPathComponent("Info.plist")) + + func codesign(_ arguments: [String]) throws -> Int32 { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/codesign") + process.arguments = arguments + [app.path] + let output = Pipe() + process.standardOutput = output + process.standardError = output + try process.run() + // Drain before waiting so diagnostics cannot fill the pipe. + let data = output.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + if process.terminationStatus != 0 { + Issue.record("\(String(decoding: data, as: UTF8.self))") + } + return process.terminationStatus + } + func signedContents() throws -> [String: Data] { + let enumerator = try #require(fileManager.enumerator(at: contents, includingPropertiesForKeys: [.isRegularFileKey])) + var files: [String: Data] = [:] + for case let url as URL in enumerator { + if try url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile == true { + files[url.path] = try Data(contentsOf: url) + } + } + return files + } + func iconIsBlue() throws -> Bool { + let image = NSWorkspace.shared.icon(forFile: app.path) + let data = try #require(image.tiffRepresentation) + let bitmap = try #require(NSBitmapImageRep(data: data)) + let color = try #require(bitmap.colorAt(x: bitmap.pixelsWide / 2, y: bitmap.pixelsHigh / 2)?.usingColorSpace(.deviceRGB)) + return color.blueComponent > 0.9 && color.redComponent < 0.1 + } + + #expect(try codesign(["--force", "--sign", "-"]) == 0) + #expect(try codesign(["--verify", "--deep", "--strict"]) == 0) + let originalContents = try signedContents() + #expect(try !iconIsBlue()) + let blue = NSImage(size: NSSize(width: 128, height: 128), flipped: false) { rect in + NSColor.systemBlue.setFill() + NSBezierPath(roundedRect: rect, xRadius: 24, yRadius: 24).fill() + return true + } + #expect(AppIconStyle.classic.updateFinderIcon(at: app, loadImage: { _ in blue })) + #expect(try iconIsBlue()) + #expect(try signedContents() == originalContents) + #expect(try codesign(["--verify", "--deep"]) == 0) + + #expect(AppIconStyle.dark.updateFinderIcon(at: app)) + #expect(try !iconIsBlue()) + #expect(try signedContents() == originalContents) + #expect(try codesign(["--verify", "--deep", "--strict"]) == 0) +} diff --git a/Tests/BetterTileAppTests/AppIconStyleTests.swift b/Tests/BetterTileAppTests/AppIconStyleTests.swift index f9664646..5753058c 100644 --- a/Tests/BetterTileAppTests/AppIconStyleTests.swift +++ b/Tests/BetterTileAppTests/AppIconStyleTests.swift @@ -1,3 +1,4 @@ +import AppKit import Foundation import Testing @testable import BetterTileApp @@ -16,6 +17,56 @@ import Testing #expect(AppIconStyle.selected(in: defaults) == .classic) } +@Test @MainActor func defaultFinderIconRemovesTheCustomIcon() { + let url = URL(fileURLWithPath: "/Applications/BetterTile.app") + var restored = false + #expect(AppIconStyle.dark.updateFinderIcon(at: url, loadImage: { _ in + Issue.record("Restoring the primary icon must not load a replacement image") + return nil + }, setIcon: { image, path in + restored = image == nil && path == url.path + return true + })) + #expect(restored) +} + +@Test(arguments: [AppIconStyle.classic, .ice]) +@MainActor func alternateFinderIconUsesTheMatchingImage(style: AppIconStyle) { + let url = URL(fileURLWithPath: "/Applications/BetterTile.app") + let image = NSImage(size: NSSize(width: 128, height: 128)) + var updated = false + let result = style.updateFinderIcon(at: url, loadImage: { name in + #expect(name == style.resourceName) + return image + }, setIcon: { selectedImage, path in + updated = selectedImage === image && path == url.path + return true + }) + #expect(result) + #expect(updated) +} + +@Test @MainActor func finderIconFailureDoesNotClearAnExistingIcon() { + let url = URL(fileURLWithPath: "/Applications/BetterTile.app") + #expect(!AppIconStyle.ice.updateFinderIcon(at: url, loadImage: { _ in nil }, setIcon: { _, _ in + Issue.record("A missing alternate must not write Finder metadata") + return true + })) + #expect(!AppIconStyle.ice.updateFinderIcon(at: url, loadImage: { _ in + NSImage(size: NSSize(width: 128, height: 128)) + }, setIcon: { _, _ in false })) + #expect(!AppIconStyle.dark.updateFinderIcon(at: url, setIcon: { _, _ in false })) +} + +@Test @MainActor func finderIconRequiresAnAppBundleURL() { + for url in [URL(fileURLWithPath: "/tmp/BetterTile"), URL(string: "https://example.com/BetterTile.app")!] { + #expect(!AppIconStyle.dark.updateFinderIcon(at: url, setIcon: { _, _ in + Issue.record("Only the local app bundle may receive Finder metadata") + return true + })) + } +} + @Test func appIconVariantsPreserveSourceGeometry() throws { let resources = URL(fileURLWithPath: #filePath) .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() diff --git a/docs/icon-composer/README.md b/docs/icon-composer/README.md index b4fbf858..11f87c53 100644 --- a/docs/icon-composer/README.md +++ b/docs/icon-composer/README.md @@ -29,29 +29,66 @@ the selection across launches. Missing and unknown values use Dark. Existing Classic, Ice Blue, and Dark choices are preserved. Debug and Release keep their existing separate preferences and Debug retains its D badge. -Show Dock icon sits directly above the picker. When it is off, the picker -explains that the choice is saved and the toggle must be on to display it. -Showing the Dock icon reapplies the saved choice. The picker does not silently -change the Dock visibility preference when selecting an icon. - -The picker loads icons from Xcode's compiled asset catalog and uses the public -AppKit `NSApplication.applicationIconImage` API. It changes the running app's -Dock icon. Finder and the installed app use the Dark icon. Returning to -Dark restores the system's primary icon. This follows +Show Dock icon sits directly above the picker. The picker updates Finder even +when the Dock icon is hidden. Showing the Dock icon reapplies the saved choice. +Selecting an icon does not change Dock visibility. + +The picker loads icons from Xcode's compiled asset catalog. The public AppKit +`NSApplication.applicationIconImage` API changes the running app's Dock icon. +The public `NSWorkspace.setIcon(_:forFile:options:)` API sets a custom Finder +icon on the running app's bundle. Selecting Dark removes that custom icon and +restores the built-in icon in Finder and the Dock. Dock behavior follows [Apple's documented Dock icon behavior](https://developer.apple.com/documentation/appkit/nsapplication/applicationiconimage). -No new dependency, permission, networking, private API, or window mutation is -introduced. The only new stored value is the local icon preference. -The picker does not set a custom Finder icon on the app bundle. -An isolated signed test app on macOS 27.0.1 accepted the public -`NSWorkspace.setIcon(_:forFile:options:)` call, but strict signature verification -then failed with "resource fork, Finder information, or similar detritus not -allowed". The call added an `Icon` file with a trailing carriage return and -Finder metadata. Applying picker choices to Finder needs a separate design -that preserves signature verification and the update path. +An explicit or saved icon choice updates Finder. A fresh install leaves any +custom icon set outside BetterTile alone. Launch reapplies a saved choice, +including after an update replaces the app. Finder updates require a writable +app bundle. A failed write keeps the saved choice and Dock behavior; Settings +shows an error after a failed selection. BetterTile never requests elevated +access or edits the app's signed `Contents` directory. + +### Finder metadata and signature checks + +An isolated signed test app on macOS 27.0.1 confirmed that custom Finder icons +add an `Icon` file with a trailing carriage return and Finder metadata at the +bundle root. Ordinary `codesign --verify --deep` still passes, and every file +under `Contents` remains byte-for-byte unchanged. Strict verification rejects +the Finder metadata with "resource fork, Finder information, or similar +detritus not allowed" while a custom icon is active. Clearing the icon through +`NSWorkspace.setIcon(nil, ...)` restores strict verification. Removing only the +root Finder metadata also removes the effective custom icon, so it cannot +preserve both the custom icon and strict verification. + +This is a runtime customization. Release packaging and its strict signature +checks remain unchanged. BetterTile does not re-sign itself, replace icon +resources, edit Info.plist, or change Sparkle's validation. Sparkle 2.9.5's +`SUCodeSigningVerifier` uses ordinary all-architecture signature validation; +its delta hash in `SUBinaryDeltaCommon` excludes custom-icon resource data. +These source checks do not substitute for a full signed update test. +Finder custom icons are static images generated by AppKit; they do not retain +Icon Composer's live glass layers. + +No new dependency, permission, networking, private API, or window mutation is +introduced. The existing local icon preference and generated Finder icon data +stay on the Mac. See [the security disclosure](../../SECURITY.md#app-icon-customization). ## Verification +The Finder integration test uses a disposable ad-hoc-signed app. It verifies +the actual icon change, unchanged signed contents, ordinary signature +validation, and restoration of strict validation after selecting Dark: + +```sh +BETTERTILE_TEST_FINDER_ICON=1 swift test \ + --filter finderIconChangesPreserveSignedContentsAndRestoreThePrimaryIcon +``` + +It never launches the probe app or changes an installed app. The test is opt-in +because it uses native icon services. Unit tests inject the Finder operation +to cover missing resources, failed writes, restoration, and invalid targets. + +The earlier icon-picker validation below predates Finder customization: + - Debug and Release Xcode builds with `CODE_SIGNING_ALLOWED=NO`. - Swift tests with `--scratch-path /tmp/bettertile-picker-tests`: preference restoration (including Dark), fallback, geometry, and matching Debug copies. From 331d04c89b71c3806fdc328e48f2ad43ad68079c Mon Sep 17 00:00:00 2001 From: LMC-Karma Date: Thu, 22 Oct 2026 16:36:49 -0700 Subject: [PATCH 3/4] Test icon application decisions and tolerate Finder color rendering --- Sources/BetterTileApp/BetterTileApp.swift | 21 ++++--- .../AppIconFinderIntegrationTests.swift | 5 +- .../AppIconStyleTests.swift | 60 +++++++++++++++++++ 3 files changed, 78 insertions(+), 8 deletions(-) diff --git a/Sources/BetterTileApp/BetterTileApp.swift b/Sources/BetterTileApp/BetterTileApp.swift index ca36fcb6..d4303cbe 100644 --- a/Sources/BetterTileApp/BetterTileApp.swift +++ b/Sources/BetterTileApp/BetterTileApp.swift @@ -129,16 +129,23 @@ enum AppIconStyle: String, CaseIterable, Identifiable { @MainActor @discardableResult - static func apply(_ style: Self? = nil) -> Bool { - let selected = style ?? selected() + static func apply( + _ style: Self? = nil, + defaults: UserDefaults = .standard, + updateFinder: @MainActor (Self) -> Bool = { $0.updateFinderIcon() }, + updateDock: @MainActor (Self) -> Void = { selected in + // nil restores the system-rendered primary Icon Composer icon. + NSApp.applicationIconImage = selected == defaultStyle ? nil : selected.image + NSApp.dockTile.display() + } + ) -> Bool { + let selected = style ?? selected(in: defaults) // A fresh install already has the primary icon. Only an explicit or saved // choice should replace an icon the user may have set outside BetterTile. - let updatesFinder = style != nil || UserDefaults.standard.string(forKey: defaultsKey) != nil - let finderUpdated = !updatesFinder || selected.updateFinderIcon() - // nil restores the system-rendered primary Icon Composer icon. + let updatesFinder = style != nil || defaults.string(forKey: defaultsKey) != nil + let finderUpdated = !updatesFinder || updateFinder(selected) // Clear the Finder override first so the Dock cannot pick up the old icon. - NSApp.applicationIconImage = selected == defaultStyle ? nil : selected.image - NSApp.dockTile.display() + updateDock(selected) return finderUpdated } diff --git a/Tests/BetterTileAppTests/AppIconFinderIntegrationTests.swift b/Tests/BetterTileAppTests/AppIconFinderIntegrationTests.swift index f864f547..27ff8d89 100644 --- a/Tests/BetterTileAppTests/AppIconFinderIntegrationTests.swift +++ b/Tests/BetterTileAppTests/AppIconFinderIntegrationTests.swift @@ -53,7 +53,10 @@ import Testing let data = try #require(image.tiffRepresentation) let bitmap = try #require(NSBitmapImageRep(data: data)) let color = try #require(bitmap.colorAt(x: bitmap.pixelsWide / 2, y: bitmap.pixelsHigh / 2)?.usingColorSpace(.deviceRGB)) - return color.blueComponent > 0.9 && color.redComponent < 0.1 + // Finder can lighten the source color when it renders the icon. + // Identify the blue fixture by channel separation from the pale primary icon. + return color.blueComponent - color.redComponent > 0.5 + && color.blueComponent - color.greenComponent > 0.2 } #expect(try codesign(["--force", "--sign", "-"]) == 0) diff --git a/Tests/BetterTileAppTests/AppIconStyleTests.swift b/Tests/BetterTileAppTests/AppIconStyleTests.swift index 5753058c..eb45ca7b 100644 --- a/Tests/BetterTileAppTests/AppIconStyleTests.swift +++ b/Tests/BetterTileAppTests/AppIconStyleTests.swift @@ -17,6 +17,66 @@ import Testing #expect(AppIconStyle.selected(in: defaults) == .classic) } +@Test @MainActor func freshInstallPreservesAnExternalFinderIcon() { + let defaults = MemoryDefaults() + var dockChoices: [AppIconStyle] = [] + let result = AppIconStyle.apply(defaults: defaults, updateFinder: { _ in + Issue.record("A fresh install must preserve an external Finder icon") + return false + }, updateDock: { dockChoices.append($0) }) + #expect(result) + #expect(dockChoices == [.dark]) + #expect(defaults.string(forKey: AppIconStyle.defaultsKey) == nil) +} + +@Test(arguments: ["classic", "ice", "dark", "unknown-future-icon"]) +@MainActor func launchReappliesASavedIconChoice(rawValue: String) { + let defaults = MemoryDefaults() + defaults.set(rawValue, forKey: AppIconStyle.defaultsKey) + let expected = AppIconStyle(rawValue: rawValue) ?? .defaultStyle + var operations: [String] = [] + let result = AppIconStyle.apply(defaults: defaults, updateFinder: { style in + #expect(style == expected) + operations.append("Finder") + return true + }, updateDock: { style in + #expect(style == expected) + operations.append("Dock") + }) + #expect(result) + #expect(operations == ["Finder", "Dock"]) + #expect(defaults.string(forKey: AppIconStyle.defaultsKey) == rawValue) +} + +@Test(arguments: AppIconStyle.allCases) +@MainActor func explicitIconSelectionUpdatesFinderWithoutASavedChoice(style: AppIconStyle) { + let defaults = MemoryDefaults() + var finderChoices: [AppIconStyle] = [] + var dockChoices: [AppIconStyle] = [] + let result = AppIconStyle.apply(style, defaults: defaults, updateFinder: { + finderChoices.append($0) + return true + }, updateDock: { dockChoices.append($0) }) + #expect(result) + #expect(finderChoices == [style]) + #expect(dockChoices == [style]) +} + +@Test @MainActor func explicitIconSelectionOverridesASavedChoiceEvenWhenFinderFails() { + let defaults = MemoryDefaults() + defaults.set("classic", forKey: AppIconStyle.defaultsKey) + var finderChoices: [AppIconStyle] = [] + var dockChoices: [AppIconStyle] = [] + let result = AppIconStyle.apply(.ice, defaults: defaults, updateFinder: { + finderChoices.append($0) + return false + }, updateDock: { dockChoices.append($0) }) + #expect(!result) + #expect(finderChoices == [.ice]) + #expect(dockChoices == [.ice]) + #expect(defaults.string(forKey: AppIconStyle.defaultsKey) == "classic") +} + @Test @MainActor func defaultFinderIconRemovesTheCustomIcon() { let url = URL(fileURLWithPath: "/Applications/BetterTile.app") var restored = false From 037dc6856486ca2ec060ad51900d32dd30d06e23 Mon Sep 17 00:00:00 2001 From: LMC-Karma Date: Thu, 22 Oct 2026 16:44:13 -0700 Subject: [PATCH 4/4] Verify strict signature rejection while a custom Finder icon is active --- .../AppIconFinderIntegrationTests.swift | 23 +++++++++++-------- docs/icon-composer/README.md | 3 ++- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/Tests/BetterTileAppTests/AppIconFinderIntegrationTests.swift b/Tests/BetterTileAppTests/AppIconFinderIntegrationTests.swift index 27ff8d89..7c988aa3 100644 --- a/Tests/BetterTileAppTests/AppIconFinderIntegrationTests.swift +++ b/Tests/BetterTileAppTests/AppIconFinderIntegrationTests.swift @@ -22,7 +22,7 @@ import Testing try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0) .write(to: contents.appendingPathComponent("Info.plist")) - func codesign(_ arguments: [String]) throws -> Int32 { + func codesign(_ arguments: [String]) throws -> (status: Int32, output: String) { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/codesign") process.arguments = arguments + [app.path] @@ -33,10 +33,11 @@ import Testing // Drain before waiting so diagnostics cannot fill the pipe. let data = output.fileHandleForReading.readDataToEndOfFile() process.waitUntilExit() - if process.terminationStatus != 0 { - Issue.record("\(String(decoding: data, as: UTF8.self))") - } - return process.terminationStatus + return (process.terminationStatus, String(decoding: data, as: UTF8.self)) + } + func expectCodesignSuccess(_ arguments: [String]) throws { + let result = try codesign(arguments) + #expect(result.status == 0, "\(result.output)") } func signedContents() throws -> [String: Data] { let enumerator = try #require(fileManager.enumerator(at: contents, includingPropertiesForKeys: [.isRegularFileKey])) @@ -59,8 +60,8 @@ import Testing && color.blueComponent - color.greenComponent > 0.2 } - #expect(try codesign(["--force", "--sign", "-"]) == 0) - #expect(try codesign(["--verify", "--deep", "--strict"]) == 0) + try expectCodesignSuccess(["--force", "--sign", "-"]) + try expectCodesignSuccess(["--verify", "--deep", "--strict"]) let originalContents = try signedContents() #expect(try !iconIsBlue()) let blue = NSImage(size: NSSize(width: 128, height: 128), flipped: false) { rect in @@ -71,10 +72,14 @@ import Testing #expect(AppIconStyle.classic.updateFinderIcon(at: app, loadImage: { _ in blue })) #expect(try iconIsBlue()) #expect(try signedContents() == originalContents) - #expect(try codesign(["--verify", "--deep"]) == 0) + try expectCodesignSuccess(["--verify", "--deep"]) + let strictOverride = try codesign(["--verify", "--deep", "--strict"]) + #expect(strictOverride.status != 0, "Strict verification must reject custom Finder metadata") + #expect(strictOverride.output.contains("resource fork, Finder information, or similar detritus not allowed"), + "\(strictOverride.output)") #expect(AppIconStyle.dark.updateFinderIcon(at: app)) #expect(try !iconIsBlue()) #expect(try signedContents() == originalContents) - #expect(try codesign(["--verify", "--deep", "--strict"]) == 0) + try expectCodesignSuccess(["--verify", "--deep", "--strict"]) } diff --git a/docs/icon-composer/README.md b/docs/icon-composer/README.md index 11f87c53..61e16a94 100644 --- a/docs/icon-composer/README.md +++ b/docs/icon-composer/README.md @@ -76,7 +76,8 @@ stay on the Mac. See [the security disclosure](../../SECURITY.md#app-icon-custom The Finder integration test uses a disposable ad-hoc-signed app. It verifies the actual icon change, unchanged signed contents, ordinary signature -validation, and restoration of strict validation after selecting Dark: +validation, the expected strict-verification rejection of custom Finder +metadata, and restoration of strict validation after selecting Dark: ```sh BETTERTILE_TEST_FINDER_ICON=1 swift test \