跪拜 Guibai
← Back to the summary

One Line of Code Fixes macOS Tray Icon Color for Every Monitor

Cause: The tray icon color is wrong

Here's the thing. I've been working on PinWall — a macOS desktop transparent sticky note app built on Tauri v2.

One time I opened the app and noticed the tray icon in the menu bar had the wrong color. Other apps' icons were white, but mine was still black. The difference was immediately visible, and my OCD kicked in hard.

Yeah, that feeling of "everyone else is doing it right, but I'm not."

Many people's first reaction: Manually detect light/dark mode

Since the system has light and dark modes, why not just detect which mode is active and swap the icon accordingly?

That's what I did before. The idea was simple, and the code was straightforward:

#[cfg(target_os = "macos")]
fn is_dark_mode() -> bool {
    match std::process::Command::new("defaults")
        .args(["read", "-g", "AppleAquaColorVariant"])
        .output()
    {
        Ok(out) if out.status.success() => {
            // AppleAquaColorVariant == 6 means dark mode
            String::from_utf8_lossy(&out.stdout).trim() == "6"
        }
        _ => false,
    }
}

fn tray_icon_bytes() -> &'static [u8] {
    if cfg!(target_os = "macos") && is_dark_mode() {
        include_bytes!("../icons/tray/icon_white_32.png")
    } else {
        include_bytes!("../icons/tray/icon_32.png")
    }
}

At startup, read AppleAquaColorVariant. If it's 6, use the white icon; otherwise, use the black icon. Looks fine, right?

Then the problem hit.

AppleAquaColorVariant reads the global preference setting — that is, the value of the "Dark/Light" toggle in System Settings. It doesn't care whether you have an external monitor, nor does it care about each monitor's individual mode. It only tells you: "The user selected dark mode in global settings."

So when my laptop is in dark mode and the external monitor is in light mode, is_dark_mode() always returns true, the tray icon is always white — and it looks wrong on the light monitor.

Second attempt: Listen for system appearance changes

What if I listen for appearance changes in the program? For example, when the user unplugs the external monitor or switches a monitor's mode, I could receive a notification and swap the icon?

After digging through the documentation, macOS does have notifications like NSWorkspace.didChangeScreenParametersNotification, but the problems are:

  1. These notifications are Cocoa/AppKit layer APIs
  2. I'm using Tauri (Rust backend), so I'd have to call them through an objc bridge
  3. Most critically — even if I could listen, I still couldn't tell whether each individual monitor is in dark or light mode. macOS does not expose an API like "which mode is this specific monitor currently in"

After all that effort, I realized manual adaptation was a dead end. Manual detection will always leave edge cases uncovered.

The correct solution: Let macOS handle it itself

Manual adaptation doesn't work, so let's change the approach — don't adapt manually, hand it over to the system.

macOS natively provides an icon handling method called Template Image. When you mark an icon as a template, macOS no longer renders it as a regular color image, but treats it as a template (or "mask"). It extracts the transparent pixel outline from the image and automatically colors it based on the current environment:

And most importantly: this coloring is done automatically by the system, calculated per monitor. You don't need to manually detect, listen for changes, or worry about how many monitors the user has or what mode each one is in. macOS handles it all by itself.

How to do it specifically? Tauri provides an API:

/// Load tray icon — white template image
fn tray_icon() -> Image<'static> {
    Image::from_bytes(include_bytes!("../icons/tray/icon_template_32.png")).unwrap()
}

pub fn setup_tray(app: &tauri::App) -> tauri::Result<()> {
    let menu = build_tray_menu(&app.handle(), lang)?;
    let icon = tray_icon();

    let _tray = TrayIconBuilder::with_id(TRAY_ID)
        .menu(&menu)
        .icon(icon)
        .icon_as_template(true)  // ← Key: mark as template
        .on_menu_event(|app, event| { /* ... */ })
        .build(app)?;

    Ok(())
}

Just that one line .icon_as_template(true) solves all the headaches of manual adaptation.

What does the icon look like?

The icon itself is also very simple — it's just a white PNG, with the exact same outline as the original black icon. Because in template mode, macOS only cares about the outline of transparent pixels; it doesn't care whether you're black or white.

The white icon gets rendered as black in dark environments and white in light environments — perfect adaptation.

Some reflections

This experience made me realize two things:

1. Many "solutions" are already part of the platform

macOS has had the concept of Template Image for a very long time (iOS's UIImageTemplate works the same way). It's just that I've always been a web frontend developer and didn't know much about these native system features. This time, developing a desktop app with Tauri, I flipped through the documentation and discovered that Tauri has already wrapped this underlying API for us.

Many times, the platform already has a solution for the problems we encounter — we just don't know about it.

2. Manual adaptation is never as reliable as native system solutions

The fundamental reason my previous solution failed is that the "manual detection" approach itself was wrong. The system's light/dark mode is not a global boolean; it's a per-monitor independent state. Unless you handle it the way the system provides, there will always be edge cases you can't cover.

Summary

If you're also writing a macOS app with Tauri, remember to add .icon_as_template(true) to your tray icon. One line of code saves a ton of trouble.

Project address: PinWall


If this article helped you, a like is the greatest encouragement for me 😄