# macOS Persistent Icon System ## Problem Flow Browser has an icon customization system that lets users pick from 14 app icons. It was disabled on macOS because `app.dock.setIcon()` overrides the Liquid Glass icon from `Assets.car`, and the change doesn't persist across app restarts anyway. ## Goals 1. **Default icon = zero intervention.** When the icon is `"default"`, the main process does nothing -- no `app.dock.setIcon()`, no `NSWorkspace` calls. macOS renders the dynamic `Assets.car` Liquid Glass icon natively. 2. **Custom icons persist everywhere** -- Dock, Finder, Spotlight, Launchpad -- even when the app isn't running. 3. **Resetting to default restores Liquid Glass** with no stale cached icons. ## Prior Art Granola documented this problem: https://www.granola.ai/blog/so-you-think-its-easy-to-change-an-app-icon Their solution uses three native macOS techniques: - `NSWorkspace.shared.setIcon(_:forFile:options:)` -- persists icon on the `.app` bundle (Finder/Spotlight/Launchpad) - `NSDockTilePlugIn` -- a native plugin the Dock loads even when the app isn't running - `SLSIconAppearanceConfiguration` private API -- forces the Dock to flush its icon cache on reset ## Architecture ``` +-----------------------------+ | User selects icon | +--------------+--------------+ | +--------------v--------------+ | Is it "default"? | +--+-----------------------+--+ | YES | NO (custom) +-------------v-----------+ +---------v----------------+ | 1. Clear Finder icon | | 1. app.dock.setIcon() | | (NSWorkspace) | | (running Dock) | | 2. Reset NSApp icon | | 2. NSWorkspace.setIcon | | (setApplicationIcon | | (Finder/Spotlight) | | Image: nil) | | 3. Write icon path to | | 3. Invalidate Dock cache | | shared file | | (SLSIconAppearance | | (for DockTilePlugin) | | Configuration) | | 4. Notify plugin | | 4. Clear shared file | | | | 5. Liquid Glass works! | | | +---------------------------+ +--------------------------+ | +-----------------------------+ | On next launch (even if app isn't running): | NSDockTilePlugIn reads shared file, | sets Dock tile content view +--------------------------------------------- ``` ## Implementation ### 1. Dependencies - Add `objcjs-types` as a direct dependency (`bun add objcjs-types`). - `objcjs-types` is bundled (not externalized) so Vite can tree-shake it. Add it to `externalizeDeps.exclude` in `electron.vite.config.ts`. - `objc-js` (the native addon it wraps) stays external. ### 2. Re-enable macOS Uncomment `"darwin"` in `supportedPlatforms` in `src/main/modules/icons.ts:15`. ### 3. Native macOS helper module New file: `src/main/modules/macos-icon.ts` Uses `objc-js` + `objcjs-types` for type-safe Objective-C bridging. | Function | API Used | Purpose | | ----------------------------------------------- | --------------------------------------------------------------------------------- | -------------------------------------------------------- | | `setFinderIcon(pngPath, appBundlePath)` | `NSWorkspace.sharedWorkspace().setIcon$forFile$options$(image, path, 0)` | Set icon on `.app` bundle for Finder/Spotlight/Launchpad | | `clearFinderIcon(appBundlePath)` | `NSWorkspace.sharedWorkspace().setIcon$forFile$options$(null, path, 0)` | Remove custom icon from `.app` bundle | | `resetAppIconImage()` | `NSApplication.sharedApplication().setApplicationIconImage$(nil)` | Reset running Dock icon to bundle icon (Liquid Glass) | | `invalidateDockCache()` | `SLSIconAppearanceConfiguration.fetchCurrentIconAppearanceConfiguration().save()` | Force Dock to recompute all icons (private API) | | `getAppBundlePath()` | Resolve from `app.getAppPath()` up to `.app` | Get the path to the running `.app` bundle | | `writeIconChoiceToSharedFile(iconPath or null)` | Write to `~/Library/Application Support/Flow/dock-tile-icon-path` | Shared file the DockTilePlugin reads | ### 4. Modified icon system logic (`src/main/modules/icons.ts`) **`setAppIcon(iconId)` -- modified for macOS:** - If `iconId === "default"`: - Call `resetAppIconImage()` (clears running Dock icon, restores Liquid Glass) - Call `clearFinderIcon(appBundlePath)` (clears Finder icon) - Call `invalidateDockCache()` (forces Dock to re-render from bundle) - Call `writeIconChoiceToSharedFile(null)` (tells plugin to use default) - Do NOT call `app.dock.setIcon()` - Return early - If custom `iconId`: - Transform icon with `sharp` (existing) - Call `app.dock.setIcon(nativeImage)` (existing) - Save transformed icon PNG to a persistent path (`/icons/.png`) - Call `setFinderIcon(savedPngPath, appBundlePath)` (NEW) - Call `writeIconChoiceToSharedFile(savedPngPath)` (NEW) **`updateAppIcon()` -- modified:** - If `currentIconId === "default"` on macOS: return (no-op, let Liquid Glass work) **Startup (`app.whenReady()`) -- modified:** - If saved icon is `"default"`: skip `setAppIcon("default")` entirely on macOS - If saved icon is custom: run existing logic ### 5. NSDockTilePlugIn A native macOS plugin bundle that the Dock loads even when the app isn't running. **Source:** `build/dock-tile-plugin/FlowDockTilePlugin.m` (Objective-C) The plugin: 1. Implements `NSDockTilePlugIn` protocol 2. In `setDockTile:`, reads the shared file at `~/Library/Application Support/Flow/dock-tile-icon-path` 3. If file is empty/missing: `setContentView:nil` (Liquid Glass renders) 4. If file contains an icon path: load `NSImage`, create `NSImageView`, set as `contentView`, call `display` **Bundle structure:** ``` DockTilePlugIn.plugin/ Contents/ Info.plist (NSPrincipalClass = FlowDockTilePlugin) MacOS/ FlowDockTilePlugin (compiled binary) ``` **Build integration:** - Compiled in `afterPack.js` hook using `clang` (available on all Macs with Xcode CLI tools) - Placed in `.app/Contents/PlugIns/` - `NSDockTilePlugIn = "DockTilePlugIn.plugin"` added to Info.plist via electron-builder `extendInfo` ### 6. electron-builder config (`electron-builder.ts`) - Add `NSDockTilePlugIn: "DockTilePlugIn.plugin"` to `mac.extendInfo` - Ensure `objc-js` native prebuilds are handled (may need `asarUnpack` entry) ### 7. Shared state: plugin communication The app writes the custom icon's absolute file path to `~/Library/Application Support/Flow/dock-tile-icon-path`. The DockTilePlugin reads this file. This is simpler than NSUserDefaults because the plugin runs in the Dock's process. - File present with a path -> custom icon - File absent or empty -> default (Liquid Glass) ### 8. Dock cache invalidation Uses the `SLSIconAppearanceConfiguration` private API via `objc-js` runtime: ```typescript // Access via NSClassFromString since it's private const cls = runtime.NSClassFromString("SLSIconAppearanceConfiguration"); const config = cls.fetchCurrentIconAppearanceConfiguration(); config.save(); ``` This tells macOS "something about icon appearance changed, recompute everything." The Dock throws away its cache and picks up the real icon. No visual disruption (unlike `killall Dock`). ## Files | File | Action | | --------------------------------------------- | -------------------------------------------------------------------------- | | `src/main/modules/icons.ts` | Modify -- re-enable macOS, change set/reset logic | | `src/main/modules/macos-icon.ts` | **Create** -- native macOS API calls via objc-js | | `build/dock-tile-plugin/FlowDockTilePlugin.m` | **Create** -- NSDockTilePlugIn source | | `build/dock-tile-plugin/Info.plist` | **Create** -- plugin bundle Info.plist | | `build/hooks/afterPack.js` | Modify -- add plugin compilation step | | `build/hooks/components/dock-tile-plugin.js` | **Create** -- compilation logic | | `electron-builder.ts` | Modify -- add NSDockTilePlugIn to extendInfo | | `electron.vite.config.ts` | Modify -- add `objcjs-types` to `externalizeDeps.exclude` for tree-shaking | | `package.json` | Modify -- add `objcjs-types` dependency |