feat: disable native decorations on Linux, hide custom TitleBar for tiling WMs

On tiling window managers (Hyprland, Sway, i3, bspwm, AwesomeWM, Openbox, etc.)
     the window has no title bar at all — neither native nor custom. On stacking DEs
     (GNOME, KDE, XFCE) the custom TitleBar can be toggled in settings as before.

     Native decorations are disabled on all Linux at startup. The frontend checks
     is_tiling_wm() to decide whether to render the custom TitleBar:
     - DE (GNOME/KDE): custom TitleBar shown (user-toggleable in settings)
     - Tiling WM: no TitleBar at all, setting hidden from Settings page

     Detection is based on environment variables:
     - HYPRLAND_INSTANCE_SIGNATURE, SWAYSOCK, I3SOCK (direct compositor signatures)
     - XDG_CURRENT_DESKTOP check for known tiling WMs

     Changes:
     - Added is_tiling_wm_cmd() Tauri command for frontend detection
     - Native decorations disabled on all Linux at startup
     - TitleBar not rendered on tiling WMs
     - Custom TitleBar setting hidden in Settings for tiling WMs
This commit is contained in:
kilyabin
2026-04-08 19:32:37 +04:00
parent 099516121e
commit 37799fb861
3 changed files with 71 additions and 7 deletions
+51 -2
View File
@@ -816,6 +816,53 @@ fn stop_audio_engine(app: &tauri::AppHandle) {
if let Some(sink) = cur.sink.take() { sink.stop(); }
}
/// Returns `true` if running under a tiling window manager (Hyprland, Sway, i3,
/// bspwm, AwesomeWM, Openbox, etc.). Detection is based on environment variables
/// set by the compositor / DE.
#[cfg(target_os = "linux")]
fn is_tiling_wm() -> bool {
// Direct compositor signatures (most reliable).
let direct = [
"HYPRLAND_INSTANCE_SIGNATURE", // Hyprland
"SWAYSOCK", // Sway
"I3SOCK", // i3
]
.iter()
.any(|&var| std::env::var_os(var).is_some());
if direct {
return true;
}
// Check XDG_CURRENT_DESKTOP for known tiling WMs.
if let Ok(desktop) = std::env::var("XDG_CURRENT_DESKTOP") {
let desktop = desktop.to_lowercase();
let tiling_wms = [
"hyprland", "sway", "i3", "bspwm", "awesome", "openbox",
"xmonad", "dwm", "qtile", "herbstluftwm", "leftwm",
];
if tiling_wms.iter().any(|&wm| desktop.contains(wm)) {
return true;
}
}
false
}
/// Tauri command: lets the frontend know whether we're running under a tiling
/// WM so it can decide whether to render the custom TitleBar component.
#[tauri::command]
fn is_tiling_wm_cmd() -> bool {
#[cfg(target_os = "linux")]
{
is_tiling_wm()
}
#[cfg(not(target_os = "linux"))]
{
false
}
}
pub fn run() {
let (audio_engine, _audio_thread) = audio::create_engine();
@@ -840,8 +887,9 @@ pub fn run() {
.setup(|app| {
// ── Custom title bar on Linux ─────────────────────────────────
// Remove OS window decorations so the React TitleBar component
// takes over. macOS and Windows keep their native decorations.
// Remove OS window decorations on all Linux so the React TitleBar
// can take over. The frontend checks is_tiling_wm() to decide
// whether to actually render the TitleBar (hidden on tiling WMs).
#[cfg(target_os = "linux")]
{
use tauri::Manager;
@@ -976,6 +1024,7 @@ pub fn run() {
greet,
exit_app,
set_window_decorations,
is_tiling_wm_cmd,
register_global_shortcut,
unregister_global_shortcut,
mpris_set_metadata,
+12 -4
View File
@@ -78,6 +78,12 @@ function AppShell() {
const { t } = useTranslation();
const isMobile = useIsMobile();
const [isWindowFullscreen, setIsWindowFullscreen] = useState(false);
const [isTilingWm, setIsTilingWm] = useState(false);
useEffect(() => {
if (!IS_LINUX) return;
invoke<boolean>('is_tiling_wm_cmd').then(setIsTilingWm).catch(() => {});
}, []);
useEffect(() => {
if (!IS_LINUX) return;
@@ -108,10 +114,12 @@ function AppShell() {
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
// Sync custom titlebar preference with native decorations on Linux
// On tiling WMs decorations are always off (no native title bar to replace).
useEffect(() => {
if (!IS_LINUX) return;
invoke('set_window_decorations', { enabled: !useCustomTitlebar }).catch(() => {});
}, [useCustomTitlebar]);
const enabled = isTilingWm ? false : !useCustomTitlebar;
invoke('set_window_decorations', { enabled }).catch(() => {});
}, [useCustomTitlebar, isTilingWm]);
useEffect(() => {
if (!isLoggedIn || !activeServerId) return;
@@ -288,14 +296,14 @@ function AppShell() {
className="app-shell"
data-mobile={isMobile || undefined}
data-mobile-player={isMobilePlayer || undefined}
data-titlebar={(IS_LINUX && useCustomTitlebar && !isWindowFullscreen) || undefined}
data-titlebar={(IS_LINUX && useCustomTitlebar && !isWindowFullscreen && !isTilingWm) || undefined}
style={{
'--sidebar-width': isMobile ? '0px' : (isSidebarCollapsed ? '72px' : 'clamp(200px, 15vw, 220px)'),
'--queue-width': isMobile ? '0px' : (isQueueVisible ? `${queueWidth}px` : '0px')
} as React.CSSProperties}
onContextMenu={e => e.preventDefault()}
>
{IS_LINUX && useCustomTitlebar && !isWindowFullscreen && <TitleBar />}
{IS_LINUX && useCustomTitlebar && !isWindowFullscreen && !isTilingWm && <TitleBar />}
{!isMobile && (
<Sidebar
isCollapsed={isSidebarCollapsed}
+8 -1
View File
@@ -202,6 +202,13 @@ export default function Settings() {
const clearAllOffline = useOfflineStore(s => s.clearAll);
const clearHotCacheDisk = useHotCacheStore(s => s.clearAllDisk);
const hotCacheEntries = useHotCacheStore(s => s.entries);
const [isTilingWm, setIsTilingWm] = useState(false);
useEffect(() => {
if (!IS_LINUX) return;
invoke<boolean>('is_tiling_wm_cmd').then(setIsTilingWm).catch(() => {});
}, []);
const hotCacheTrackCount = useMemo(() => {
if (!serverId) return 0;
const prefix = `${serverId}:`;
@@ -618,7 +625,7 @@ export default function Settings() {
<span className="toggle-track" />
</label>
</div>
{IS_LINUX && (
{IS_LINUX && !isTilingWm && (
<>
<div className="settings-section-divider" />
<div className="settings-toggle-row">