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 themvpaths in step 1 accordingly for non-Brave browsers.
Tested on CachyOS / Plasma 6 / Wayland.
β οΈ ReplaceUSERwith your actual username wherever it appears in file paths below (e.g./home/USER/.local/...β/home/yourname/.local/...). Runwhoamiif unsure. The shell commands use$HOME/~and don't need editing β only the.desktopfiles and the routerExec=line do.
π‘ Fish shell users (CachyOS default): commands shown work as-is. For multi-line file contents (the
.desktopfiles, JSON files, the wrapper script), use your editor of choice β bash heredoc syntax (cat > file << EOF) is bash-only.
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.
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.
Close all browser instances first:
pkill -f brave # or: pkill -f chrome / pkill -f vivaldi / etc.
sleep 2Move 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/DefaultLocal 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.
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=braveStartupWMClass 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 "$@"' _ %UValidate 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/applicationsRecolor 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.
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 queryWindowInfoShould 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.
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.
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.kwinscriptThe 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.
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 trueIf 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.kwinscriptPlasma 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.iniLog 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~/.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"
fichmod +x ~/.local/bin/brave-route~/.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=braveNoDisplay=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.desktopIf 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/nullKWin 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 reconfigureOr 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.
qdbusisqdbus6(inqt6-tools), or skip entirely and usebusctl --user call ....qtpaths: command not foundwarnings fromxdg-mimeare harmless; silence by installingqt6-base.SingletonLockis a symlink with an intentionally invalid target (Chromium-wide quirk).[ -e ]returns false; usepgrep -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}.
# 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.