Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions BetterTile.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "";
Expand Down Expand Up @@ -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";
Expand Down
19 changes: 19 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 36 additions & 6 deletions Sources/BetterTileApp/BetterTileApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}

Expand Down
18 changes: 12 additions & 6 deletions Sources/BetterTileApp/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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: {
Expand Down Expand Up @@ -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)
}
}

Expand Down
85 changes: 85 additions & 0 deletions Tests/BetterTileAppTests/AppIconFinderIntegrationTests.swift
Original file line number Diff line number Diff line change
@@ -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"])
}
117 changes: 114 additions & 3 deletions Tests/BetterTileAppTests/AppIconStyleTests.swift
Original file line number Diff line number Diff line change
@@ -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()
Expand Down
Loading
Loading