Skip to content

Instantly share code, notes, and snippets.

@pannal
Last active April 25, 2026 10:40
Show Gist options
  • Select an option

  • Save pannal/d398ee9801a8c6d0e44b1027af193610 to your computer and use it in GitHub Desktop.

Select an option

Save pannal/d398ee9801a8c6d0e44b1027af193610 to your computer and use it in GitHub Desktop.
Separate Chromium-browser (Brave) profiles as independent taskbar apps on CachyOS (Plasma 6)

Separate Chromium-browser (Brave) profiles as independent taskbar apps on CachyOS (Plasma 6)

KDE groups Chromium-based browser windows by app_id. Launching a second profile with --profile-directory=… doesn't help β€” the browser's IPC merges everything into one process, so all windows share the same app_id and KDE has no choice but to group them under one icon.

The fix is to give each profile its own --user-data-dir, which forces fully independent process trees. Then --class and StartupWMClass start working, and KDE treats the profiles as separate apps that each group their own windows.

Works for any Chromium-based browser (Brave, Chrome, Chromium, Vivaldi, Edge, Opera, …). The concrete examples below use Brave; substitute according to this table:

binary config root stock icon
Brave brave ~/.config/BraveSoftware/Brave-Browser brave-desktop
Chrome google-chrome-stable ~/.config/google-chrome google-chrome
Chromium chromium ~/.config/chromium chromium
Vivaldi vivaldi ~/.config/vivaldi vivaldi
Edge microsoft-edge-stable ~/.config/microsoft-edge microsoft-edge

πŸ’‘ Brave note: Brave is the only one that nests the data root under an extra BraveSoftware/ parent folder. Everything else puts profiles directly under ~/.config/<browser>/. Adjust the mv paths in step 1 accordingly for non-Brave browsers.

Tested on CachyOS / Plasma 6 / Wayland.

⚠️ Replace USER with your actual username wherever it appears in file paths below (e.g. /home/USER/.local/... β†’ /home/yourname/.local/...). Run whoami if unsure. The shell commands use $HOME / ~ and don't need editing β€” only the .desktop files and the router Exec= line do.

πŸ’‘ Fish shell users (CachyOS default): commands shown work as-is. For multi-line file contents (the .desktop files, JSON files, the wrapper script), use your editor of choice β€” bash heredoc syntax (cat > file << EOF) is bash-only.


1. Migrate existing profiles to separate user-data-dirs

Chromium browsers normally store all profiles inside a single config dir with Default, Profile 2, etc. subfolders β€” that's the layout that causes the grouping problem. Each profile needs its own user-data-dir, with a folder literally named Default inside.

Before you touch anything

Export your open tabs. Chromium stores open tabs in Sessions/Current Tabs (binary format) and these are the easiest thing to lose during migration β€” even when the profile data survives, "Continue where you left off" can fail to repopulate them. Install Session Buddy in each profile, save the current window(s) as a named session, and optionally export to JSON. Takes 30 seconds, saves hours.

Make a full backup of the config root. Cheap insurance β€” the folder is typically a few hundred MB and the mv operations later are easy to get wrong:

# Brave
cp -a ~/.config/BraveSoftware ~/.config/BraveSoftware.backup-$(date +%Y%m%d)

# Other Chromium browsers, e.g.:
# cp -a ~/.config/google-chrome ~/.config/google-chrome.backup-$(date +%Y%m%d)

-a preserves permissions, timestamps, and symlinks. To roll back at any point: close the browser, rm -rf the active config, then rename the backup back. Delete the backup once both profiles run cleanly for a few days.

Migrate

Close all browser instances first:

pkill -f brave    # or: pkill -f chrome  /  pkill -f vivaldi  / etc.
sleep 2

Move the existing profiles. Adjust the source folder names (Default, Profile 2) to match what you actually have, and decide which becomes Work vs Private. Paths shown are Brave-specific β€” for Chrome/Chromium/Vivaldi strip the BraveSoftware/ parent and use the matching config root from the table at the top.

# Private (was the original Default profile)
mkdir -p ~/.config/BraveSoftware/Brave-Browser-Private
mv ~/.config/BraveSoftware/Brave-Browser/Default \
   ~/.config/BraveSoftware/Brave-Browser-Private/Default

# Work (was Profile 2 β€” gets renamed to Default in its new home)
mkdir -p ~/.config/BraveSoftware/Brave-Browser-Work
mv "~/.config/BraveSoftware/Brave-Browser/Profile 2" \
   ~/.config/BraveSoftware/Brave-Browser-Work/Default

Critical: copy Local State to preserve settings

Local State lives at the user-data-dir root (not inside Default/) and holds the HMAC key Chromium uses to validate Secure Preferences, plus the OS key for cookie/password encryption. If a profile loads against a fresh Local State with a different GUID, the browser wipes extension settings, default search engine, theme state, etc. as a tamper-protection measure.

Copy the original to both new locations before first launch:

cp ~/.config/BraveSoftware/Brave-Browser/"Local State" \
   ~/.config/BraveSoftware/Brave-Browser-Private/"Local State"
cp ~/.config/BraveSoftware/Brave-Browser/"Local State" \
   ~/.config/BraveSoftware/Brave-Browser-Work/"Local State"

The original Brave-Browser/ folder can be deleted once both new profiles launch and look correct.


2. Per-profile .desktop files

Examples shown for Brave. For other browsers, swap the binary (Exec=/TryExec=), icon (Icon=), and config path (--user-data-dir=) per the table at the top. The --class= and StartupWMClass= values are arbitrary user-chosen identifiers (they just have to match each other) β€” rename if you prefer e.g. chrome-work over brave-work.

Replace USER with your actual username. %h is not a valid .desktop field code β€” use the absolute path or the sh -c trick at the bottom of this section.

~/.local/share/applications/brave-private.desktop:

[Desktop Entry]
Version=1.0
Name=Brave (Private)
Exec=brave --user-data-dir=/home/USER/.config/BraveSoftware/Brave-Browser-Private --class=brave-private %U
Icon=brave-desktop
Type=Application
Categories=Network;WebBrowser;
StartupWMClass=brave-private
StartupNotify=true
TryExec=brave

~/.local/share/applications/brave-work.desktop:

[Desktop Entry]
Version=1.0
Name=Brave (Work)
Exec=brave --user-data-dir=/home/USER/.config/BraveSoftware/Brave-Browser-Work --class=brave-work %U
Icon=/home/USER/.local/share/icons/brave-work.png
Type=Application
Categories=Network;WebBrowser;
StartupWMClass=brave-work
StartupNotify=true
TryExec=brave

StartupWMClass must match --class exactly β€” otherwise KDE won't associate the windows with the .desktop entry and grouping breaks.

For a portable path that resolves $HOME per user:

Exec=sh -c 'exec brave --user-data-dir="$HOME/.config/BraveSoftware/Brave-Browser-Work" --class=brave-work "$@"' _ %U

Validate and refresh:

desktop-file-validate ~/.local/share/applications/brave-private.desktop
desktop-file-validate ~/.local/share/applications/brave-work.desktop
update-desktop-database ~/.local/share/applications

3. Distinct icon for Work

Recolor the stock browser icon with ImageMagick β€” keeps the silhouette (instantly recognizable as the same browser) but a tint makes Work vs Private trivial to distinguish:

mkdir -p ~/.local/share/icons
convert /usr/share/icons/hicolor/256x256/apps/brave-desktop.png \
  -modulate 100,100,40 \
  ~/.local/share/icons/brave-work.png

-modulate brightness,saturation,hue β€” hue=40 shifts Brave's orange to blue. Try 70 (green), 130 (purple), etc. For other browsers, find the source icon with find /usr/share/icons/hicolor -name '<browser>*.png' and adjust the hue based on the browser's primary color.


4. Pin to taskbar

Launch each entry from KRunner (Alt+Space β†’ "Brave (Work)" / "Brave (Private)"), right-click the running taskbar icon β†’ Pin to Task Manager. Unpin the old generic Brave icon.

Verify each window's app_id:

busctl --user call org.kde.KWin /KWin org.kde.KWin queryWindowInfo

Should report resourceClass = "brave-work" or "brave-private". If both show "brave-browser", --class isn't being honored β€” add --ozone-platform=wayland to the Exec= line.


5. Default browser routing (last-active)

Single xdg-mime default can't route to the most-recently-used profile. The solution: a small wrapper script + a KWin script that records which browser-profile window was last active. Examples below assume Brave with brave-work / brave-private class names β€” rename references throughout if you used different identifiers in step 2.

5a. Build the KWin tracker script as a .kwinscript bundle

Plasma's Install from File is more reliable than placing files manually (no cache invalidation, no path guessing). Build a .kwinscript bundle once, install it with one click.

Create a staging directory:

mkdir -p /tmp/brave-tracker/contents/code

/tmp/brave-tracker/metadata.json:

{
    "KPackageStructure": "KWin/Script",
    "KPlugin": {
        "Id": "brave-tracker",
        "Name": "Brave Tracker",
        "Description": "Logs which Brave profile window was last activated",
        "Version": "1.0",
        "License": "MIT",
        "EnabledByDefault": false
    },
    "X-Plasma-API": "javascript",
    "X-Plasma-MainScript": "code/main.js"
}

/tmp/brave-tracker/contents/code/main.js:

workspace.windowActivated.connect(function(w) {
    if (!w) return;
    const c = w.resourceClass;
    if (c === "brave-work" || c === "brave-private") {
        print("BRAVE_ACTIVE: " + c);
    }
});

KPackageStructure is required on Plasma 6 β€” without it the script is silently filtered out of System Settings. Only metadata.json is recognized; metadata.desktop was dropped.

Bundle into a .kwinscript (which is just a zip with the files at the archive root β€” not nested inside a brave-tracker/ folder):

cd /tmp/brave-tracker
python3 -c "
import zipfile, os
with zipfile.ZipFile(os.path.expanduser('~/brave-tracker.kwinscript'), 'w') as z:
    z.write('metadata.json')
    for root, _, files in os.walk('contents'):
        for f in files:
            z.write(os.path.join(root, f))
"

# Verify structure
python3 -m zipfile -l ~/brave-tracker.kwinscript

The listing should show metadata.json and contents/code/main.js at the top level. Python's zipfile is used because zip isn't installed on CachyOS by default.

5b. Install and enable

System Settings β†’ Window Management β†’ KWin Scripts β†’ Install from File β†’ pick ~/brave-tracker.kwinscript. The entry appears in the list immediately.

The GUI toggle is unreliable for freshly-installed scripts β€” force-enable via config and reload KWin:

kwriteconfig6 --file kwinrc --group Plugins --key brave-trackerEnabled true
busctl --user call org.kde.KWin /KWin org.kde.KWin reconfigure

# Verify
busctl --user call org.kde.KWin /Scripting org.kde.kwin.Scripting isScriptLoaded s "brave-tracker"
# expect: b true

If isScriptLoaded still returns b false, log out and back in β€” that forces a fresh KWin startup which reliably picks up newly-installed scripts.

Cleanup:

rm -rf /tmp/brave-tracker ~/brave-tracker.kwinscript

5c. Make print() reach the journal

Plasma 6 routes KWin script print() through qCDebug with category kwin_scripting, but Qt logging rules suppress it by default. Enable:

mkdir -p ~/.config/QtProject
printf '[Rules]\nkwin_scripting.debug=true\njs.debug=true\n' >> ~/.config/QtProject/qtlogging.ini

Log out and back in for KWin to pick up the rule. Verify by clicking between Brave windows:

journalctl --user --since "1 minute ago" | grep BRAVE_ACTIVE
# expect: BRAVE_ACTIVE: brave-work / BRAVE_ACTIVE: brave-private lines

5d. Wrapper script

~/.local/bin/brave-route:

#!/usr/bin/env bash
URL="${1:-}"

WORK_DIR="$HOME/.config/BraveSoftware/Brave-Browser-Work"
PRIV_DIR="$HOME/.config/BraveSoftware/Brave-Browser-Private"

work_running() { pgrep -f "user-data-dir=$WORK_DIR" >/dev/null; }
priv_running() { pgrep -f "user-data-dir=$PRIV_DIR" >/dev/null; }

# SingletonLock is a symlink with an invalid target β€” `[ -e ]` returns false
# on it, so use pgrep to check whether each profile's process is alive.

activate_class() {
    local cls="$1"
    busctl --user call org.kde.KWin /Scripting \
        org.kde.kwin.Scripting loadDeclarativeScriptFromText ss "
        const wins = workspace.windowList().filter(w => w.resourceClass === '$cls' && w.normalWindow);
        if (wins.length > 0) {
            wins.sort((a, b) => b.stackingOrder - a.stackingOrder);
            workspace.activeWindow = wins[0];
        }
    " "brave-activator-$$" >/dev/null 2>&1
}

launch_and_activate() {
    local data_dir="$1" cls="$2"
    brave --user-data-dir="$data_dir" "$URL" &
    sleep 0.3
    activate_class "$cls"
}

last=$(journalctl --user --since "8 hours ago" 2>/dev/null \
       | grep -oE 'BRAVE_ACTIVE: brave-(work|private)' \
       | tail -1 | awk '{print $2}')

case "$last" in
  brave-work)    if work_running; then launch_and_activate "$WORK_DIR" "brave-work"; exit; fi ;;
  brave-private) if priv_running; then launch_and_activate "$PRIV_DIR" "brave-private"; exit; fi ;;
esac

if   work_running && ! priv_running; then launch_and_activate "$WORK_DIR" "brave-work"
elif priv_running && ! work_running; then launch_and_activate "$PRIV_DIR" "brave-private"
else                                       launch_and_activate "$PRIV_DIR" "brave-private"
fi
chmod +x ~/.local/bin/brave-route

5e. Router .desktop entry

~/.local/share/applications/brave-router.desktop:

[Desktop Entry]
Version=1.0
Name=Brave (Router)
Exec=/home/USER/.local/bin/brave-route %U
Icon=brave-desktop
Type=Application
Categories=Network;WebBrowser;
NoDisplay=true
MimeType=x-scheme-handler/http;x-scheme-handler/https;x-scheme-handler/ipfs;x-scheme-handler/ipns;text/html;application/xhtml+xml;
StartupNotify=false
TryExec=brave

NoDisplay=true keeps it out of menus β€” it's purely a URL handler. Then:

update-desktop-database ~/.local/share/applications

xdg-mime default brave-router.desktop x-scheme-handler/http
xdg-mime default brave-router.desktop x-scheme-handler/https
xdg-mime default brave-router.desktop text/html
xdg-mime default brave-router.desktop application/xhtml+xml

# Verify
xdg-mime query default x-scheme-handler/https
# expect: brave-router.desktop

If the GUI default-app picker overwrote things, also check:

grep -E '^(text/html|x-scheme-handler/http)' \
  ~/.config/mimeapps.list \
  ~/.config/kde-mimeapps.list 2>/dev/null

5f. Focus-stealing prevention

KWin may refuse to bring the activated window forward. Add per-class rules:

printf '\n[brave-work-focus]\nDescription=Brave Work focus\nfsplevel=0\nfsplevelrule=2\nwmclass=brave-work\nwmclasscomplete=true\nwmclassmatch=1\n\n[brave-private-focus]\nDescription=Brave Private focus\nfsplevel=0\nfsplevelrule=2\nwmclass=brave-private\nwmclasscomplete=true\nwmclassmatch=1\n' >> ~/.config/kwinrulesrc

busctl --user call org.kde.KWin /KWin org.kde.KWin reconfigure

Or via the GUI: System Settings β†’ Window Management β†’ Window Rules β†’ Add new for each class (brave-work, brave-private) β€” Properties tab β†’ set both Focus stealing prevention to Force, None and Accept focus to Force, Yes.

If ~/.config/kwinrulesrc already has a [General] block with count= and rules=, merge by hand β€” appending blindly will leave the new rules inactive.


CachyOS / Plasma 6 gotchas

  • qdbus is qdbus6 (in qt6-tools), or skip entirely and use busctl --user call ....
  • qtpaths: command not found warnings from xdg-mime are harmless; silence by installing qt6-base.
  • SingletonLock is a symlink with an intentionally invalid target (Chromium-wide quirk). [ -e ] returns false; use pgrep -f "user-data-dir=..." to detect running profiles.
  • Stale singleton locks after a crash β€” remove from each user-data-dir, e.g. for Brave: rm -f ~/.config/BraveSoftware/Brave-Browser-{Work,Private}/Singleton{Lock,Cookie,Socket}.

Verification

# Both profiles should report distinct app_ids
busctl --user call org.kde.KWin /KWin org.kde.KWin queryWindowInfo

# Tracker should be loaded
busctl --user call org.kde.KWin /Scripting \
    org.kde.kwin.Scripting isScriptLoaded s "brave-tracker"

# Tracker should log on focus changes
journalctl --user -f | grep BRAVE_ACTIVE

# URL handler should be the router
xdg-mime query default x-scheme-handler/https

# End-to-end: activate Work, then from terminal:
xdg-open https://example.com   # opens in Work, raises window
# Activate Private, repeat β†’ opens in Private.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment