Give the window its own identity in Linux task bars

AWT names the X11 window class after the thread that first touched the
toolkit, so the client shared java-awt-EventDispatchThread with every other
Swing app. Set it to ts3j-client instead (the jar's manifest opens the AWT
internals this needs), and install a matching .desktop launcher and icon in
the user's data directory: without a launcher claiming the class, Plasma falls
back to the process's command line and files the window under Java.

The window icon is now offered at several sizes instead of 16 px only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-24 14:21:10 +00:00
parent f27cc40755
commit 03ea2c86c3
6 changed files with 176 additions and 2 deletions

View File

@@ -132,6 +132,7 @@
<mainClass>com.ts3client.Main</mainClass>
<manifestEntries>
<Enable-Native-Access>ALL-UNNAMED</Enable-Native-Access>
<Add-Opens>java.desktop/sun.awt.X11</Add-Opens>
</manifestEntries>
</transformer>
<!-- Merge overlapping META-INF/services entries (e.g. java.security.Provider)

View File

@@ -1,10 +1,12 @@
package com.ts3client;
import com.ts3client.config.Settings;
import com.ts3client.ui.DesktopEntry;
import com.ts3client.ui.DisplayScale;
import com.ts3client.ui.IconTheme;
import com.ts3client.ui.LookAndFeelManager;
import com.ts3client.ui.MainFrame;
import com.ts3client.ui.WindowClass;
import javax.swing.SwingUtilities;
@@ -24,10 +26,12 @@ public final class Main {
final Settings settings = Settings.load();
DisplayScale.configure(settings.uiScale);
WindowClass.apply();
SwingUtilities.invokeLater(() -> {
LookAndFeelManager.install(settings.appearance);
IconTheme.get().reload(settings);
DesktopEntry.install();
new MainFrame(settings).setVisible(true);
});
}

View File

@@ -0,0 +1,114 @@
package com.ts3client.ui;
import com.ts3client.Main;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Arrays;
import java.util.Locale;
/**
* Installs a freedesktop launcher for the client in the user's applications folder.
*
* <p>Task bars find the launcher belonging to a window through its {@code WM_CLASS}
* ({@link WindowClass}), matched against {@code StartupWMClass}. When no launcher
* claims the class, Plasma falls back to the process's command line — {@code java} —
* and files the window under whatever launcher runs Java, with its name and icon.
*
* <p>The files are only rewritten when their content changes, e.g. after the jar moved.
* {@code -Dts3j.desktopEntry=false} turns this off.
*/
public final class DesktopEntry {
private static final int ICON_SIZE = 256;
private DesktopEntry() {
}
/** Call on the event thread once the look-and-feel is installed (the icon uses its accent). */
public static void install() {
if (!System.getProperty("os.name", "").toLowerCase(Locale.ROOT).startsWith("linux")
|| !Boolean.parseBoolean(System.getProperty("ts3j.desktopEntry", "true"))) {
return;
}
try {
Path data = dataHome();
Path icon = data.resolve("icons").resolve(WindowClass.NAME + ".png");
writeIfChanged(icon, iconPng());
writeIfChanged(data.resolve("applications").resolve(WindowClass.NAME + ".desktop"),
entry(icon).getBytes(StandardCharsets.UTF_8));
} catch (IOException | RuntimeException e) {
System.err.println("Cannot install the desktop entry: " + e);
}
}
private static Path dataHome() {
String xdg = System.getenv("XDG_DATA_HOME");
if (xdg != null && !xdg.isBlank()) return Path.of(xdg);
return Path.of(System.getProperty("user.home"), ".local", "share");
}
private static String entry(Path icon) {
String exec = launchCommand();
StringBuilder s = new StringBuilder()
.append("[Desktop Entry]\n")
.append("Type=Application\n")
.append("Name=TS3J\n")
.append("GenericName=TeamSpeak 3 Client\n")
.append("Comment=Voice chat on TeamSpeak 3 servers\n")
.append("Icon=").append(icon).append('\n')
.append("Exec=").append(exec == null ? "java" : exec).append('\n')
.append("StartupWMClass=").append(WindowClass.NAME).append('\n')
.append("Categories=Network;InstantMessaging;AudioVideo;\n")
.append("Terminal=false\n");
// Run from classes (an IDE, mvn exec:java) there is nothing sensible to launch;
// the entry is still needed to name the window, but stays out of the menu.
if (exec == null) s.append("NoDisplay=true\n");
return s.toString();
}
/** {@code java -jar <this jar>}, or {@code null} when not running from a jar. */
private static String launchCommand() {
try {
Path jar = Path.of(Main.class.getProtectionDomain().getCodeSource().getLocation().toURI());
if (!jar.toString().endsWith(".jar")) return null;
String java = ProcessHandle.current().info().command().orElse("java");
return quote(java) + " -jar " + quote(jar.toAbsolutePath().toString());
} catch (URISyntaxException | RuntimeException e) {
return null;
}
}
/** Quotes an Exec argument as the Desktop Entry spec asks. */
private static String quote(String arg) {
arg = arg.replace("%", "%%");
if (arg.chars().noneMatch(c -> " \t\"'\\`$;&|<>()*?#~".indexOf(c) >= 0)) return arg;
String escaped = arg.replaceAll("([\"`$\\\\])", "\\\\$1");
// The whole value is also a desktop-file string, where a backslash is escaped again.
return ("\"" + escaped + "\"").replace("\\", "\\\\");
}
private static byte[] iconPng() throws IOException {
BufferedImage img = new BufferedImage(ICON_SIZE, ICON_SIZE, BufferedImage.TYPE_INT_ARGB);
Icons.app(ICON_SIZE).paintIcon(null, img.getGraphics(), 0, 0);
ByteArrayOutputStream out = new ByteArrayOutputStream();
ImageIO.write(img, "png", out);
return out.toByteArray();
}
private static void writeIfChanged(Path file, byte[] content) throws IOException {
if (Files.exists(file) && Arrays.equals(Files.readAllBytes(file), content)) return;
Files.createDirectories(file.getParent());
Path tmp = file.resolveSibling(file.getFileName() + ".tmp");
Files.write(tmp, content);
Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE);
}
}

View File

@@ -6,8 +6,10 @@ import javax.swing.ImageIcon;
import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.Image;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.util.List;
/**
* The user interface's icons, taken from the active {@link IconTheme} pack and
@@ -310,6 +312,13 @@ public final class Icons {
return make(size, Icons::paintApp);
}
/** The window icon at the sizes task bars and switchers pick from. */
public static List<Image> appImages() {
return List.of(16, 24, 32, 48, 64, 128).stream()
.map(size -> app(size).getImage())
.toList();
}
// ---- built-in painters ----
private static void paintServer(Graphics2D g) {

View File

@@ -93,7 +93,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
this.hotkeys = new HotkeyService(new HotkeyActions(this), this::hotkeyArgumentChoices);
contacts.addListener(() -> SwingUtilities.invokeLater(this::contactsChanged));
setIconImage(Icons.app().getImage());
setIconImages(Icons.appImages());
// We tear the connections down ourselves on close, so don't let Swing kill the JVM.
setDefaultCloseOperation(DO_NOTHING_ON_CLOSE);
addWindowListener(new WindowAdapter() {
@@ -310,7 +310,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
/** Rebuilds the icon-bearing chrome after the active icon pack changed. */
private void rebuildIcons() {
setIconImage(Icons.app().getImage());
setIconImages(Icons.appImages());
menuBar = buildMenuBar();
setJMenuBar(menuBar);
remove(toolbar);

View File

@@ -0,0 +1,46 @@
package com.ts3client.ui;
import java.awt.GraphicsEnvironment;
import java.awt.Toolkit;
import java.lang.reflect.Field;
import java.util.Locale;
/**
* Gives the client's windows their own X11 {@code WM_CLASS}.
*
* <p>AWT names it after the class at the bottom of the stack that first touched the
* toolkit — for a Swing app that is usually {@code java-awt-EventDispatchThread},
* which every other Java program shares. Task bars group windows and look up their
* launcher by that class (Plasma does so on Wayland too, through Xwayland), so the
* client ended up filed under "Java" together with unrelated programs.
*
* <p>The name lives in a private field of {@code sun.awt.X11.XToolkit}; reaching it
* needs {@code --add-opens java.desktop/sun.awt.X11=ALL-UNNAMED}, which the jar's
* manifest grants. Without it the default name stays.
*/
public final class WindowClass {
/** Matches {@code StartupWMClass} in a launcher's {@code .desktop} file. */
public static final String NAME = "ts3j-client";
private WindowClass() {
}
/** Call before the first window is created. */
public static void apply() {
if (!System.getProperty("os.name", "").toLowerCase(Locale.ROOT).startsWith("linux")
|| GraphicsEnvironment.isHeadless()) {
return;
}
Toolkit toolkit = Toolkit.getDefaultToolkit();
if (!toolkit.getClass().getName().equals("sun.awt.X11.XToolkit")) return;
try {
Field field = toolkit.getClass().getDeclaredField("awtAppClassName");
field.setAccessible(true);
field.set(null, NAME);
} catch (ReflectiveOperationException | RuntimeException e) {
System.err.println("Cannot set the window class (run with "
+ "--add-opens java.desktop/sun.awt.X11=ALL-UNNAMED): " + e);
}
}
}