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/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 36dd9ef7..d4303cbe 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,18 +121,47 @@ 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 var image: NSImage? { Bundle.main.image(forResource: resourceName) } @MainActor - 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.dockTile.display() + @discardableResult + 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 || defaults.string(forKey: defaultsKey) != nil + let finderUpdated = !updatesFinder || updateFinder(selected) + // Clear the Finder override first so the Dock cannot pick up the old icon. + updateDock(selected) + 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 65e7ac84..4ebb4051 100644 --- a/Sources/BetterTileApp/SettingsView.swift +++ b/Sources/BetterTileApp/SettingsView.swift @@ -523,7 +523,8 @@ 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 + @State private var finderIconUpdateFailed = false var body: some View { Toggle("Show Dock icon", isOn: $showDockIcon) @@ -535,16 +536,21 @@ struct AppIconPicker: View { } } Text(showDockIcon - ? "Changes the Dock icon while BetterTile is running. Finder uses Classic." - : "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) } 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: { @@ -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..7c988aa3 --- /dev/null +++ b/Tests/BetterTileAppTests/AppIconFinderIntegrationTests.swift @@ -0,0 +1,85 @@ +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 -> (status: Int32, output: String) { + 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() + 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])) + 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)) + // 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 + } + + 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 + 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) + 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) + try expectCodesignSuccess(["--verify", "--deep", "--strict"]) +} diff --git a/Tests/BetterTileAppTests/AppIconStyleTests.swift b/Tests/BetterTileAppTests/AppIconStyleTests.swift index 4eda3933..eb45ca7b 100644 --- a/Tests/BetterTileAppTests/AppIconStyleTests.swift +++ b/Tests/BetterTileAppTests/AppIconStyleTests.swift @@ -1,21 +1,132 @@ +import AppKit 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) } +@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 + #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 c824f1b2..61e16a94 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,25 +24,72 @@ 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 -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. +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 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 +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). + +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 only new stored value is the local icon preference. +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, 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 \ + --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.