Skip to content

Persist app icon mode on the app bundle - #2884

Merged
lawrencecchen merged 7 commits into
mainfrom
task-fix-app-icon-persistence
Apr 15, 2026
Merged

lawrencecchen merged 7 commits into
mainfrom
task-fix-app-icon-persistence

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Apr 14, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • keep runtime icon swaps in the app process for immediate feedback while the app is running
  • move persistent light and dark icon writes into CmuxDockTilePlugin, which is the Dock-side source of truth
  • clear the custom bundle icon from the plugin when returning to Automatic so stale overrides do not survive

Verification

  • ./scripts/setup.sh
  • ./scripts/reload.sh --tag task-fix-app-icon-persistence
  • killall Dock once to make Dock load the updated plugin binary
  • defaults write com.cmuxterm.app.debug.task.fix.app.icon.persistence appIconMode -string dark
  • launch tagged app, verify the built .app gains Icon
  • defaults write com.cmuxterm.app.debug.task.fix.app.icon.persistence appIconMode -string automatic
  • relaunch tagged app, verify the built .app no longer has Icon

Notes

  • The previous app-process NSWorkspace.setIcon path was removed. The plugin now owns persistence because Dock icon state after force-kill is resolved from the Dock side, not the app process.

Summary by CodeRabbit

  • Bug Fixes
    • Dock icon now respects light/dark appearance and updates automatically when appearance changes.
    • Properly observes and stops observing appearance changes to ensure timely updates and cleanup.
    • More reliable fallback to the default Dock icon when app resources are missing or cannot be loaded.
    • Finder/application icon handling and system registration improved so icon changes are reflected promptly.
    • Suppress dock-tile updates and skip icon initialization during XCTest runs to avoid test interference.

@vercel

vercel Bot commented Apr 14, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Apr 15, 2026 1:32am

@coderabbitai

coderabbitai Bot commented Apr 14, 2026 •

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

CmuxDockTilePlugin now observes NSApp.effectiveAppearance, selects dark/light-aware app icon names, updates or clears the Finder/application bundle icon via NSWorkspace.shared.setIcon(...) and noteFileSystemChanged(), and schedules/invalidation of the appearance observer; Dock-tile update returns early if appBundleURL is missing.

Changes

Cohort / File(s) Summary
Dock tile & Finder icon handling
Sources/AppIconDockTilePlugin.swift
Replaced DockTileAppIconMode.imageName with imageName(isDarkAppearance:); added appearanceObservation for NSApp.effectiveAppearance, invalidated on deinit and when setDockTile resets; updateDockTile skips when appBundleURL is missing; on icon load success or failure calls NSWorkspace.shared.setIcon(...), noteFileSystemChanged(), registers the bundle URL, and updates the Dock tile accordingly.
App startup XCTest guard
Sources/AppDelegate.swift
Guarded ensureApplicationIcon() so it runs only when not under XCTest (!isRunningUnderXCTest).
XCTest detection & suppression
Sources/cmuxApp.swift
Added AppIconSettings.isRunningUnderXCTest(_:) checking XCTest-related env vars and CMUX_UI_TEST_*; early-return from live dock-tile notification dispatch when running under XCTest to avoid posting icon-change notifications.

Sequence Diagram(s)

sequenceDiagram
    participant NSApp
    participant CmuxPlugin as CmuxDockTilePlugin
    participant ImageResolver
    participant NSWorkspace
    participant DockTile
    NSApp->>CmuxPlugin: effectiveAppearance change (observed)
    CmuxPlugin->>CmuxPlugin: schedule updateDockTile on main queue
    CmuxPlugin->>ImageResolver: request imageName(isDarkAppearance:)
    ImageResolver-->>CmuxPlugin: icon or nil
    alt icon available
        CmuxPlugin->>NSWorkspace: setIcon(icon, forFile: appBundle.path)
        NSWorkspace-->>CmuxPlugin: ok
        CmuxPlugin->>NSWorkspace: noteFileSystemChanged(appBundle.path)
        CmuxPlugin->>DockTile: show custom icon
    else icon missing
        CmuxPlugin->>NSWorkspace: setIcon(nil, forFile: appBundle.path)
        NSWorkspace-->>CmuxPlugin: ok
        CmuxPlugin->>NSWorkspace: noteFileSystemChanged(appBundle.path)
        CmuxPlugin->>DockTile: revert to default icon
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Poem

🐰 I hopped at dawn to check the Dock,

Switched dark to light with a cheerful knock.
Finder cleared, then set anew,
Icons danced — a tiny hullabaloo.
Carrot cheers for changes true!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Persist app icon mode on the app bundle' directly reflects the main objective of moving persistent icon writes into CmuxDockTilePlugin as the source of truth for bundle icons.
Description check ✅ Passed The description includes Summary and Verification sections with clear details about changes and testing steps, but lacks Testing section with explicit test coverage and is missing bot review trigger and checklist.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch task-fix-app-icon-persistence

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps

greptile-apps Bot commented Apr 14, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Persists the user's chosen app icon by writing it directly to the app bundle via NSWorkspace.setIcon(_:forFile:options:) on every mode change, and clears it when returning to Automatic. Finder and LaunchServices are refreshed after each write via noteFileSystemChanged and an async LSRegisterURL call.

Confidence Score: 5/5

  • Safe to merge; all findings are minor style/coverage suggestions with no correctness or data-integrity impact.
  • The implementation is structurally sound — NSWorkspace.setIcon is called on the main thread via existing UI paths, the Bool return value gates the Finder notification, and the .automatic path properly delegates runtime icon updates to the existing appearance observer. The two P2 findings (unnecessary Finder notification on every automatic-mode launch, and missing call-count assertions in tests) do not affect correctness or user data.
  • No files require special attention.

Important Files Changed

Filename Overview
Sources/cmuxApp.swift Adds setBundleIcon (writes/clears icon on disk via NSWorkspace) and notifyBundleIconSystem (triggers Finder + LSRegisterURL) to the Environment struct, wiring them into applyIcon. In the .automatic case setBundleIcon(nil) is called unconditionally on every launch, which can fire noteFileSystemChanged even when no custom icon was ever written.
cmuxTests/NotificationAndMenuBarTests.swift Stubs out the two new environment closures with always-succeeding no-ops. Neither test tracks notifyBundleIconSystem call counts or asserts that setBundleIcon received the expected argument, leaving the new persistence path partially unverified.

Sequence Diagram

sequenceDiagram
    participant App as App (launch / settings change)
    participant AI as applyIcon()
    participant NW as NSWorkspace
    participant Finder as Finder
    participant LS as LaunchServices (bg)

    App->>AI: applyIcon(.dark / .light)
    AI->>AI: stopAppearanceObservation()
    AI->>AI: setApplicationIconImage(icon)
    AI->>NW: setIcon(icon, forFile: bundlePath)
    NW-->>AI: true
    AI->>Finder: noteFileSystemChanged(bundlePath)
    AI->>LS: LSRegisterURL(bundleURL) [async]
    AI->>App: notifyDockTilePlugin()

    App->>AI: applyIcon(.automatic)
    AI->>NW: setIcon(nil, forFile: bundlePath)
    NW-->>AI: true
    AI->>Finder: noteFileSystemChanged(bundlePath)
    AI->>LS: LSRegisterURL(bundleURL) [async]
    AI->>AI: startAppearanceObservation()
    Note over AI: applyIconForCurrentAppearance()<br/>sets runtime icon for current theme
    AI->>App: notifyDockTilePlugin()
Loading

Reviews (1): Last reviewed commit: "Persist app icon mode on the app bundle" | Re-trigger Greptile

Comment thread Sources/cmuxApp.swift Outdated
Comment thread cmuxTests/NotificationAndMenuBarTests.swift Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
Sources/cmuxApp.swift (1)

3853-3855: Optional dedupe: extract shared fixed-mode icon apply path.

The .light and .dark branches now share identical bundle-update logic; a helper would reduce drift risk.

♻️ Proposed refactor
 static func applyIcon(_ mode: AppIconMode, environment: Environment = .live()) {
+    func applyFixedModeIcon(_ mode: AppIconMode) {
+        guard let icon = environment.imageForMode(mode) else { return }
+        environment.setApplicationIconImage(icon)
+        if environment.setBundleIcon(icon) {
+            environment.notifyBundleIconSystem()
+        }
+    }
+
     switch mode {
     case .automatic:
         if environment.setBundleIcon(nil) {
             environment.notifyBundleIconSystem()
         }
         environment.startAppearanceObservation()
     case .light:
         environment.stopAppearanceObservation()
-        guard let icon = environment.imageForMode(.light) else { return }
-        environment.setApplicationIconImage(icon)
-        if environment.setBundleIcon(icon) {
-            environment.notifyBundleIconSystem()
-        }
+        applyFixedModeIcon(.light)
     case .dark:
         environment.stopAppearanceObservation()
-        guard let icon = environment.imageForMode(.dark) else { return }
-        environment.setApplicationIconImage(icon)
-        if environment.setBundleIcon(icon) {
-            environment.notifyBundleIconSystem()
-        }
+        applyFixedModeIcon(.dark)
     }

     environment.notifyDockTilePlugin()
 }

Also applies to: 3860-3862

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/cmuxApp.swift` around lines 3853 - 3855, The .light and .dark
branches duplicate the same fixed-mode bundle update calls
(environment.setBundleIcon(icon) and environment.notifyBundleIconSystem());
extract that shared logic into a small helper function (e.g.,
applyFixedModeBundleIcon(icon) or a private method on the same scope) and call
it from both branches to avoid duplication and drift, ensuring you move the
environment.setBundleIcon(icon) + environment.notifyBundleIconSystem() sequence
into that helper and replace the duplicated lines in the .light and .dark cases
with a single helper call.
cmuxTests/NotificationAndMenuBarTests.swift (1)

33-34: Assert bundle-icon side effects, not just wire stubs.

These tests now satisfy the new Environment signature, but they don’t verify the new behavior this PR introduces (setBundleIcon argument and notifyBundleIconSystem invocation). Please capture/assert these side effects in both dark and automatic paths to protect persistence/clear regressions.

✅ Suggested test assertions
 func testApplyDarkSetsRuntimeIconAndNotifiesDockTilePlugin() {
     let expectedIcon = NSImage(size: NSSize(width: 16, height: 16))
     var receivedRuntimeIcon: NSImage?
+    var receivedBundleIcon: NSImage?
+    var bundleNotifyCallCount = 0
     var dockTileNotificationCount = 0
     var startObservationCallCount = 0
     var stopObservationCallCount = 0

     let environment = AppIconSettings.Environment(
@@
         setApplicationIconImage: { icon in
             receivedRuntimeIcon = icon
         },
-        setBundleIcon: { _ in true },
-        notifyBundleIconSystem: {},
+        setBundleIcon: { icon in
+            receivedBundleIcon = icon
+            return true
+        },
+        notifyBundleIconSystem: {
+            bundleNotifyCallCount += 1
+        },
@@
     AppIconSettings.applyIcon(.dark, environment: environment)

     XCTAssertTrue(receivedRuntimeIcon === expectedIcon)
+    XCTAssertTrue(receivedBundleIcon === expectedIcon)
+    XCTAssertEqual(bundleNotifyCallCount, 1)
     XCTAssertEqual(dockTileNotificationCount, 1)
     XCTAssertEqual(startObservationCallCount, 0)
     XCTAssertEqual(stopObservationCallCount, 1)
 }

 func testApplyAutomaticStartsObservationAndNotifiesDockTilePlugin() {
+    var bundleIconUpdates: [NSImage?] = []
+    var bundleNotifyCallCount = 0
     var dockTileNotificationCount = 0
     var startObservationCallCount = 0
     var stopObservationCallCount = 0

     let environment = AppIconSettings.Environment(
@@
-        setBundleIcon: { _ in true },
-        notifyBundleIconSystem: {},
+        setBundleIcon: { icon in
+            bundleIconUpdates.append(icon)
+            return true
+        },
+        notifyBundleIconSystem: {
+            bundleNotifyCallCount += 1
+        },
@@
     AppIconSettings.applyIcon(.automatic, environment: environment)

+    XCTAssertEqual(bundleIconUpdates.count, 1)
+    if let firstUpdate = bundleIconUpdates.first {
+        XCTAssertNil(firstUpdate)
+    }
+    XCTAssertEqual(bundleNotifyCallCount, 1)
     XCTAssertEqual(dockTileNotificationCount, 1)
     XCTAssertEqual(startObservationCallCount, 1)
     XCTAssertEqual(stopObservationCallCount, 0)
 }

Based on learnings: In Sources/cmuxApp.swift, AppIconSettings.applyIcon(.automatic) and dock-tile behavior depend on bundle-icon state transitions; tests should verify those transitions, not only runtime icon/delegate calls.

Also applies to: 67-68

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxTests/NotificationAndMenuBarTests.swift` around lines 33 - 34, Update the
tests to assert the new bundle-icon side effects rather than only stubbing the
environment: capture calls to the Environment.setBundleIcon closure and the
notifyBundleIconSystem invocation in NotificationAndMenuBarTests (both dark and
automatic icon-path tests) and assert the expected state transitions (e.g., that
setBundleIcon is called with .dark when applying .dark and called with
.automatic/cleared when applying .automatic) and that notifyBundleIconSystem is
invoked each time; use spies/flags or a mutable capture to record arguments and
invocation counts and add assertions in both the dark-path and automatic-path
test cases to prevent regressions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@cmuxTests/NotificationAndMenuBarTests.swift`:
- Around line 33-34: Update the tests to assert the new bundle-icon side effects
rather than only stubbing the environment: capture calls to the
Environment.setBundleIcon closure and the notifyBundleIconSystem invocation in
NotificationAndMenuBarTests (both dark and automatic icon-path tests) and assert
the expected state transitions (e.g., that setBundleIcon is called with .dark
when applying .dark and called with .automatic/cleared when applying .automatic)
and that notifyBundleIconSystem is invoked each time; use spies/flags or a
mutable capture to record arguments and invocation counts and add assertions in
both the dark-path and automatic-path test cases to prevent regressions.

In `@Sources/cmuxApp.swift`:
- Around line 3853-3855: The .light and .dark branches duplicate the same
fixed-mode bundle update calls (environment.setBundleIcon(icon) and
environment.notifyBundleIconSystem()); extract that shared logic into a small
helper function (e.g., applyFixedModeBundleIcon(icon) or a private method on the
same scope) and call it from both branches to avoid duplication and drift,
ensuring you move the environment.setBundleIcon(icon) +
environment.notifyBundleIconSystem() sequence into that helper and replace the
duplicated lines in the .light and .dark cases with a single helper call.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: aef090f8-92c2-424d-8cab-1960d847626b

📥 Commits

Reviewing files that changed from the base of the PR and between 6a64de4 and f018fa5.

📒 Files selected for processing (2)
  • Sources/cmuxApp.swift
  • cmuxTests/NotificationAndMenuBarTests.swift

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="cmuxTests/NotificationAndMenuBarTests.swift">

<violation number="1" location="cmuxTests/NotificationAndMenuBarTests.swift:33">
P3: Add call-count assertions for `setBundleIcon` and `notifyBundleIconSystem` in these tests so they fail if the persistence/notification path is removed or no longer executed.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread cmuxTests/NotificationAndMenuBarTests.swift Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/AppIconDockTilePlugin.swift`:
- Around line 81-82: The icon-update paths that call
NSWorkspace.shared.setIcon(...) should also re-register the app bundle with
LaunchServices: after each NSWorkspace change (the branches containing
NSWorkspace.shared.setIcon(nil, forFile: appBundleURL.path, ...) and the other
icon-mutation branch), call LSRegisterURL(appBundleURL as CFURL, true) (or
mirror the AppDelegate.scheduleLaunchServicesBundleRegistration() behavior) to
force LaunchServices metadata refresh, and ensure the call is invoked on the
same execution context as the existing NSWorkspace calls.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 84e3c7a2-3c3f-47e6-8d76-62ab3b57db1d

📥 Commits

Reviewing files that changed from the base of the PR and between f018fa5 and 0ca96cb.

📒 Files selected for processing (1)
  • Sources/AppIconDockTilePlugin.swift

Comment thread Sources/AppIconDockTilePlugin.swift Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 718052e054

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/AppIconDockTilePlugin.swift

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/AppIconDockTilePlugin.swift`:
- Around line 16-19: DockTileAppIconMode.imageName(isDarkAppearance:) currently
returns concrete NSImage names for the .automatic case which causes the dock
plugin to persist custom icons; change the .automatic branch in
DockTileAppIconMode.imageName(isDarkAppearance:) to return nil (matching
cmuxApp.swift's AppIconMode.automatic) so the guard that checks imageName fails
and the code path that calls setIcon(nil, ...) is executed to clear bundle
overrides instead of persisting icons; update any other imageName
implementations in DockTileAppIconMode (e.g., the similar branches referenced
around lines 62-69 and 88-105) to follow the same nil-return behavior for
.automatic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 79ed1159-6247-459c-9de6-1791b2dc4ad9

📥 Commits

Reviewing files that changed from the base of the PR and between 25c9088 and 718052e.

📒 Files selected for processing (1)
  • Sources/AppIconDockTilePlugin.swift

Comment thread Sources/AppIconDockTilePlugin.swift

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b4b92e80e9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/AppIconDockTilePlugin.swift
@lawrencecchen
lawrencecchen merged commit 0de573e into main Apr 15, 2026
16 checks passed
@lawrencecchen
lawrencecchen deleted the task-fix-app-icon-persistence branch April 15, 2026 02:01
rodchristiansen pushed a commit to rodchristiansen/cmux that referenced this pull request Sep 2, 2026
* Persist app icon mode on the app bundle

* Move app icon persistence into the dock tile plugin

* Make automatic app icon follow Dock appearance

* Refresh LaunchServices after dock icon changes

* Skip app icon sync during test launches

* Skip bundle icon persistence for untagged debug app

---------

Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>

This branch was successfully deployed

1 active deployment
Preview — 6a826e63 Deployed Apr 15, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant