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(); } 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() { pub fn run() {
let (audio_engine, _audio_thread) = audio::create_engine(); let (audio_engine, _audio_thread) = audio::create_engine();
@@ -840,8 +887,9 @@ pub fn run() {
.setup(|app| { .setup(|app| {
// ── Custom title bar on Linux ───────────────────────────────── // ── Custom title bar on Linux ─────────────────────────────────
// Remove OS window decorations so the React TitleBar component // Remove OS window decorations on all Linux so the React TitleBar
// takes over. macOS and Windows keep their native decorations. // 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")] #[cfg(target_os = "linux")]
{ {
use tauri::Manager; use tauri::Manager;
@@ -976,6 +1024,7 @@ pub fn run() {
greet, greet,
exit_app, exit_app,
set_window_decorations, set_window_decorations,
is_tiling_wm_cmd,
register_global_shortcut, register_global_shortcut,
unregister_global_shortcut, unregister_global_shortcut,
mpris_set_metadata, mpris_set_metadata,
+12 -4
View File
@@ -78,6 +78,12 @@ function AppShell() {
const { t } = useTranslation(); const { t } = useTranslation();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const [isWindowFullscreen, setIsWindowFullscreen] = useState(false); 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(() => { useEffect(() => {
if (!IS_LINUX) return; if (!IS_LINUX) return;
@@ -108,10 +114,12 @@ function AppShell() {
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId); const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
// Sync custom titlebar preference with native decorations on Linux // Sync custom titlebar preference with native decorations on Linux
// On tiling WMs decorations are always off (no native title bar to replace).
useEffect(() => { useEffect(() => {
if (!IS_LINUX) return; if (!IS_LINUX) return;
invoke('set_window_decorations', { enabled: !useCustomTitlebar }).catch(() => {}); const enabled = isTilingWm ? false : !useCustomTitlebar;
}, [useCustomTitlebar]); invoke('set_window_decorations', { enabled }).catch(() => {});
}, [useCustomTitlebar, isTilingWm]);
useEffect(() => { useEffect(() => {
if (!isLoggedIn || !activeServerId) return; if (!isLoggedIn || !activeServerId) return;
@@ -288,14 +296,14 @@ function AppShell() {
className="app-shell" className="app-shell"
data-mobile={isMobile || undefined} data-mobile={isMobile || undefined}
data-mobile-player={isMobilePlayer || undefined} data-mobile-player={isMobilePlayer || undefined}
data-titlebar={(IS_LINUX && useCustomTitlebar && !isWindowFullscreen) || undefined} data-titlebar={(IS_LINUX && useCustomTitlebar && !isWindowFullscreen && !isTilingWm) || undefined}
style={{ style={{
'--sidebar-width': isMobile ? '0px' : (isSidebarCollapsed ? '72px' : 'clamp(200px, 15vw, 220px)'), '--sidebar-width': isMobile ? '0px' : (isSidebarCollapsed ? '72px' : 'clamp(200px, 15vw, 220px)'),
'--queue-width': isMobile ? '0px' : (isQueueVisible ? `${queueWidth}px` : '0px') '--queue-width': isMobile ? '0px' : (isQueueVisible ? `${queueWidth}px` : '0px')
} as React.CSSProperties} } as React.CSSProperties}
onContextMenu={e => e.preventDefault()} onContextMenu={e => e.preventDefault()}
> >
{IS_LINUX && useCustomTitlebar && !isWindowFullscreen && <TitleBar />} {IS_LINUX && useCustomTitlebar && !isWindowFullscreen && !isTilingWm && <TitleBar />}
{!isMobile && ( {!isMobile && (
<Sidebar <Sidebar
isCollapsed={isSidebarCollapsed} isCollapsed={isSidebarCollapsed}
+8 -1
View File
@@ -202,6 +202,13 @@ export default function Settings() {
const clearAllOffline = useOfflineStore(s => s.clearAll); const clearAllOffline = useOfflineStore(s => s.clearAll);
const clearHotCacheDisk = useHotCacheStore(s => s.clearAllDisk); const clearHotCacheDisk = useHotCacheStore(s => s.clearAllDisk);
const hotCacheEntries = useHotCacheStore(s => s.entries); 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(() => { const hotCacheTrackCount = useMemo(() => {
if (!serverId) return 0; if (!serverId) return 0;
const prefix = `${serverId}:`; const prefix = `${serverId}:`;
@@ -618,7 +625,7 @@ export default function Settings() {
<span className="toggle-track" /> <span className="toggle-track" />
</label> </label>
</div> </div>
{IS_LINUX && ( {IS_LINUX && !isTilingWm && (
<> <>
<div className="settings-section-divider" /> <div className="settings-section-divider" />
<div className="settings-toggle-row"> <div className="settings-toggle-row">