diff --git a/ts3-client/core/src/main/java/com/ts3client/config/Settings.java b/ts3-client/core/src/main/java/com/ts3client/config/Settings.java index d1adc73..933259b 100644 --- a/ts3-client/core/src/main/java/com/ts3client/config/Settings.java +++ b/ts3-client/core/src/main/java/com/ts3client/config/Settings.java @@ -151,6 +151,8 @@ public final class Settings { public double soundVolume = 1.0; /** Extra folder to look for sound packs in, on top of the well-known locations. */ public String soundPackDir = ""; + /** Whether pokes and private messages show a desktop notification while the window is in the background. */ + public boolean desktopNotifications = true; // ---- icons ---- /** File name of the active icon pack; empty uses the built-in icons. */ public String iconPack = "default_colored_2014.zip"; @@ -264,6 +266,7 @@ public final class Settings { soundPack = props.getProperty("soundPack", soundPack); soundVolume = parseD(props.getProperty("soundVolume"), soundVolume); soundPackDir = props.getProperty("soundPackDir", soundPackDir); + desktopNotifications = parseB(props.getProperty("desktopNotifications"), desktopNotifications); iconPack = props.getProperty("iconPack", iconPack); iconPackDir = props.getProperty("iconPackDir", iconPackDir); appearance = parseEnum(Appearance.class, props.getProperty("appearance"), appearance); @@ -317,6 +320,7 @@ public final class Settings { props.setProperty("soundPack", soundPack); props.setProperty("soundVolume", Double.toString(soundVolume)); props.setProperty("soundPackDir", soundPackDir); + props.setProperty("desktopNotifications", Boolean.toString(desktopNotifications)); props.setProperty("iconPack", iconPack); props.setProperty("iconPackDir", iconPackDir); props.setProperty("appearance", appearance.name()); diff --git a/ts3-client/desktop/src/main/java/com/ts3client/notify/desktop/DBusConnection.java b/ts3-client/desktop/src/main/java/com/ts3client/notify/desktop/DBusConnection.java new file mode 100644 index 0000000..408a569 --- /dev/null +++ b/ts3-client/desktop/src/main/java/com/ts3client/notify/desktop/DBusConnection.java @@ -0,0 +1,215 @@ +package com.ts3client.notify.desktop; + +import java.io.EOFException; +import java.io.IOException; +import java.net.StandardProtocolFamily; +import java.net.UnixDomainSocketAddress; +import java.nio.ByteBuffer; +import java.nio.channels.SocketChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicLong; + +/** + * A connection to the user's D-Bus session bus, spoken directly over its Unix socket: + * method calls and the signals a match rule lets through, which is all a client of a + * desktop service needs. Signals and replies are read on a thread of its own. + */ +final class DBusConnection implements AutoCloseable { + + private static final String BUS = "org.freedesktop.DBus"; + private static final long CALL_TIMEOUT_MS = 3_000; + + /** A signal from the bus. */ + interface SignalHandler { + void signal(String iface, String member, List args); + } + + private final SocketChannel channel; + private final AtomicLong serials = new AtomicLong(); + private final Map>> pending = new ConcurrentHashMap<>(); + private volatile SignalHandler signals = (iface, member, args) -> { }; + private volatile boolean closed; + + private DBusConnection(SocketChannel channel) { + this.channel = channel; + } + + /** Connects to the session bus named by {@code DBUS_SESSION_BUS_ADDRESS}, or the usual socket. */ + static DBusConnection session() throws IOException { + Path socket = sessionSocket(); + if (socket == null) throw new IOException("No D-Bus session bus socket"); + SocketChannel channel = SocketChannel.open(StandardProtocolFamily.UNIX); + try { + channel.connect(UnixDomainSocketAddress.of(socket)); + authenticate(channel); + DBusConnection bus = new DBusConnection(channel); + Thread reader = new Thread(bus::readLoop, "dbus-reader"); + reader.setDaemon(true); + reader.start(); + bus.call(BUS, "/org/freedesktop/DBus", BUS, "Hello", ""); + return bus; + } catch (IOException | RuntimeException e) { + channel.close(); + throw e; + } + } + + private static Path sessionSocket() { + String address = System.getenv("DBUS_SESSION_BUS_ADDRESS"); + if (address != null) { + for (String entry : address.split(";")) { + if (!entry.startsWith("unix:")) continue; + for (String pair : entry.substring(5).split(",")) { + // An abstract socket (unix:abstract=) is out of reach of Java's Unix sockets. + if (pair.startsWith("path=")) return Path.of(unescape(pair.substring(5))); + } + } + } + String runtime = System.getenv("XDG_RUNTIME_DIR"); + if (runtime == null) return null; + Path socket = Path.of(runtime, "bus"); + return Files.exists(socket) ? socket : null; + } + + /** Undoes the %xx escaping of D-Bus addresses. */ + private static String unescape(String value) { + StringBuilder out = new StringBuilder(); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c == '%' && i + 2 < value.length()) { + out.append((char) Integer.parseInt(value.substring(i + 1, i + 3), 16)); + i += 2; + } else { + out.append(c); + } + } + return out.toString(); + } + + /** The EXTERNAL mechanism: the bus checks the socket's peer credentials against our uid. */ + private static void authenticate(SocketChannel channel) throws IOException { + Object uid = Files.getAttribute(Path.of("/proc/self"), "unix:uid"); + String hexUid = HexFormat.of().formatHex(uid.toString().getBytes(StandardCharsets.US_ASCII)); + write(channel, "\0AUTH EXTERNAL " + hexUid + "\r\n"); + String reply = readLine(channel); + if (!reply.startsWith("OK ")) throw new IOException("D-Bus refused authentication: " + reply); + write(channel, "BEGIN\r\n"); + } + + private static void write(SocketChannel channel, String line) throws IOException { + ByteBuffer b = ByteBuffer.wrap(line.getBytes(StandardCharsets.US_ASCII)); + while (b.hasRemaining()) channel.write(b); + } + + private static String readLine(SocketChannel channel) throws IOException { + StringBuilder line = new StringBuilder(); + ByteBuffer one = ByteBuffer.allocate(1); + while (!line.toString().endsWith("\r\n")) { + one.clear(); + if (channel.read(one) < 0) throw new EOFException("D-Bus closed during authentication"); + line.append((char) one.get(0)); + } + return line.substring(0, line.length() - 2); + } + + void onSignal(SignalHandler handler) { + this.signals = handler; + } + + /** Lets the bus route the signals a match rule describes to this connection. */ + void addMatch(String rule) throws IOException { + call(BUS, "/org/freedesktop/DBus", BUS, "AddMatch", "s", rule); + } + + /** Calls a method and waits for its reply's arguments. */ + List call(String destination, String path, String iface, String member, String signature, + Object... args) throws IOException { + long serial = serials.incrementAndGet(); + CompletableFuture> reply = new CompletableFuture<>(); + pending.put(serial, reply); + try { + byte[] message = DBusWire.methodCall(serial, destination, path, iface, member, signature, args); + synchronized (channel) { + ByteBuffer b = ByteBuffer.wrap(message); + while (b.hasRemaining()) channel.write(b); + } + return reply.get(CALL_TIMEOUT_MS, TimeUnit.MILLISECONDS); + } catch (ExecutionException e) { + throw new IOException(e.getCause().getMessage(), e.getCause()); + } catch (TimeoutException e) { + throw new IOException(member + " got no reply", e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted", e); + } finally { + pending.remove(serial); + } + } + + private void readLoop() { + try { + ByteBuffer header = ByteBuffer.allocate(DBusWire.FIXED_HEADER); + while (!closed) { + header.clear(); + readFully(header); + ByteBuffer message = ByteBuffer.allocate(DBusWire.messageLength(header)); + message.put(header.flip()); + readFully(message); + dispatch(DBusWire.parse(message.array())); + } + } catch (IOException | RuntimeException e) { + if (!closed) System.err.println("D-Bus connection lost: " + e); + } finally { + closed = true; + pending.values().forEach(f -> f.completeExceptionally(new IOException("D-Bus connection closed"))); + } + } + + private void readFully(ByteBuffer b) throws IOException { + while (b.hasRemaining()) { + if (channel.read(b) < 0) throw new EOFException("D-Bus closed the connection"); + } + } + + private void dispatch(DBusWire.Message m) { + switch (m.type()) { + case DBusWire.METHOD_RETURN -> { + CompletableFuture> reply = pending.get(m.replySerial()); + if (reply != null) reply.complete(m.body()); + } + case DBusWire.ERROR -> { + CompletableFuture> reply = pending.get(m.replySerial()); + String detail = m.body().isEmpty() ? "" : ": " + m.body().getFirst(); + if (reply != null) reply.completeExceptionally(new IOException(m.string(DBusWire.FIELD_ERROR_NAME) + detail)); + } + case DBusWire.SIGNAL -> { + try { + signals.signal(m.string(DBusWire.FIELD_INTERFACE), m.string(DBusWire.FIELD_MEMBER), m.body()); + } catch (RuntimeException e) { + System.err.println("D-Bus signal handler failed: " + e); + } + } + default -> { } + } + } + + @Override + public void close() { + closed = true; + try { + channel.close(); + } catch (IOException ignored) { + // Nothing left to release. + } + } +} diff --git a/ts3-client/desktop/src/main/java/com/ts3client/notify/desktop/DBusWire.java b/ts3-client/desktop/src/main/java/com/ts3client/notify/desktop/DBusWire.java new file mode 100644 index 0000000..652b24b --- /dev/null +++ b/ts3-client/desktop/src/main/java/com/ts3client/notify/desktop/DBusWire.java @@ -0,0 +1,322 @@ +package com.ts3client.notify.desktop; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +/** + * The D-Bus wire format, for the handful of types a notification service speaks: + * basic types, arrays, structs, dictionaries and variants. + * + *

Values map to Java as: {@code y} Integer, {@code b} Boolean, {@code i} Integer, + * {@code u} Long, {@code s o g} String, {@code a} List (or Map for a dictionary), + * a struct Object[], a variant the value it holds. + */ +final class DBusWire { + + static final int METHOD_CALL = 1; + static final int METHOD_RETURN = 2; + static final int ERROR = 3; + static final int SIGNAL = 4; + + static final int FIELD_PATH = 1; + static final int FIELD_INTERFACE = 2; + static final int FIELD_MEMBER = 3; + static final int FIELD_ERROR_NAME = 4; + static final int FIELD_REPLY_SERIAL = 5; + static final int FIELD_DESTINATION = 6; + static final int FIELD_SIGNATURE = 8; + + /** Everything before the header fields: byte order, type, flags, version and three lengths. */ + static final int FIXED_HEADER = 16; + + private DBusWire() { + } + + /** A message as read off the bus. */ + record Message(int type, long serial, Map fields, List body) { + + String string(int field) { + Object value = fields.get(field); + return value instanceof String s ? s : null; + } + + long replySerial() { + return fields.get(FIELD_REPLY_SERIAL) instanceof Long l ? l : -1; + } + } + + /** Builds a method call; the body is written after the header, aligned to 8 as the format wants. */ + static byte[] methodCall(long serial, String destination, String path, String iface, String member, + String signature, Object... args) { + Writer body = new Writer(); + body.values(signature, args); + + Writer out = new Writer(); + out.put('l'); + out.put(METHOD_CALL); + out.put(0); + out.put(1); + out.u32(body.size()); + out.u32(serial); + out.array(8, () -> { + out.field(FIELD_PATH, "o", path); + out.field(FIELD_DESTINATION, "s", destination); + out.field(FIELD_INTERFACE, "s", iface); + out.field(FIELD_MEMBER, "s", member); + if (!signature.isEmpty()) out.field(FIELD_SIGNATURE, "g", signature); + }); + out.align(8); + out.bytes(body.toBytes()); + return out.toBytes(); + } + + /** @return the length of the whole message whose fixed header this is */ + static int messageLength(ByteBuffer fixedHeader) { + ByteBuffer b = fixedHeader.duplicate().order(order(fixedHeader.get(0))); + int body = b.getInt(4); + int fields = b.getInt(12); + return pad8(FIXED_HEADER + fields) + body; + } + + static Message parse(byte[] data) { + Reader r = new Reader(ByteBuffer.wrap(data).order(order(data[0]))); + r.skip(1); + int type = r.buf.get() & 0xff; + r.skip(2); + int bodyLength = r.buf.getInt(); + long serial = Integer.toUnsignedLong(r.buf.getInt()); + + Map fields = new java.util.HashMap<>(); + for (Object field : (List) r.read("a(yv)")) { + Object[] pair = (Object[]) field; + fields.put((Integer) pair[0], pair[1]); + } + r.align(8); + String signature = fields.get(FIELD_SIGNATURE) instanceof String s ? s : ""; + List body = new ArrayList<>(); + int end = r.buf.position() + bodyLength; + for (int i = 0; i < signature.length() && r.buf.position() < end; ) { + int next = typeEnd(signature, i); + body.add(r.read(signature.substring(i, next))); + i = next; + } + return new Message(type, serial, fields, body); + } + + private static ByteOrder order(byte marker) { + return marker == 'B' ? ByteOrder.BIG_ENDIAN : ByteOrder.LITTLE_ENDIAN; + } + + private static int pad8(int n) { + return (n + 7) & ~7; + } + + /** @return the index just past the single complete type starting at {@code start} */ + static int typeEnd(String signature, int start) { + char c = signature.charAt(start); + if (c == 'a') return typeEnd(signature, start + 1); + if (c == '(' || c == '{') { + char close = c == '(' ? ')' : '}'; + int i = start + 1; + while (signature.charAt(i) != close) i = typeEnd(signature, i); + return i + 1; + } + return start + 1; + } + + static int alignment(char type) { + return switch (type) { + case 'y', 'g', 'v' -> 1; + case 'n', 'q' -> 2; + case 'x', 't', 'd', '(', '{' -> 8; + default -> 4; + }; + } + + /** Marshals values; positions count from the start of what it writes. */ + static final class Writer { + private byte[] buf = new byte[256]; + private int pos; + + int size() { + return pos; + } + + byte[] toBytes() { + return Arrays.copyOf(buf, pos); + } + + void put(int b) { + if (pos == buf.length) buf = Arrays.copyOf(buf, buf.length * 2); + buf[pos++] = (byte) b; + } + + void bytes(byte[] b) { + for (byte x : b) put(x); + } + + void align(int n) { + while (pos % n != 0) put(0); + } + + void u32(long v) { + align(4); + for (int i = 0; i < 4; i++) put((int) (v >>> (8 * i))); + } + + private void setU32(int at, long v) { + for (int i = 0; i < 4; i++) buf[at + i] = (byte) (v >>> (8 * i)); + } + + void string(String s) { + byte[] b = s.getBytes(StandardCharsets.UTF_8); + u32(b.length); + bytes(b); + put(0); + } + + void signature(String s) { + byte[] b = s.getBytes(StandardCharsets.US_ASCII); + put(b.length); + bytes(b); + put(0); + } + + /** An array; the length leaves out the padding before its first element. */ + void array(int elementAlignment, Runnable elements) { + u32(0); + int lengthAt = pos - 4; + align(elementAlignment); + int start = pos; + elements.run(); + setU32(lengthAt, pos - start); + } + + void field(int code, String signature, Object value) { + align(8); + put(code); + signature(signature); + value(signature, value); + } + + void values(String signature, Object... values) { + int v = 0; + for (int i = 0; i < signature.length(); v++) { + int next = typeEnd(signature, i); + value(signature.substring(i, next), values[v]); + i = next; + } + } + + void value(String type, Object value) { + char c = type.charAt(0); + align(alignment(c)); + switch (c) { + case 'y' -> put(((Number) value).intValue()); + case 'b' -> u32((Boolean) value ? 1 : 0); + case 'i' -> u32(((Number) value).intValue() & 0xffffffffL); + case 'u' -> u32(((Number) value).longValue()); + case 's', 'o' -> string((String) value); + case 'g' -> signature((String) value); + case 'v' -> { + String inner = variantType(value); + signature(inner); + value(inner, value); + } + case 'a' -> { + String element = type.substring(1); + array(alignment(element.charAt(0)), () -> { + if (element.charAt(0) == '{') { + String key = element.substring(1, typeEnd(element, 1)); + String val = element.substring(1 + key.length(), element.length() - 1); + for (Map.Entry e : ((Map) value).entrySet()) { + align(8); + value(key, e.getKey()); + value(val, e.getValue()); + } + } else { + for (Object item : (List) value) value(element, item); + } + }); + } + default -> throw new IllegalArgumentException("Cannot write D-Bus type " + type); + } + } + + private static String variantType(Object value) { + if (value instanceof String) return "s"; + if (value instanceof Boolean) return "b"; + if (value instanceof Byte) return "y"; + if (value instanceof Integer) return "i"; + if (value instanceof Long) return "u"; + throw new IllegalArgumentException("No D-Bus type for " + value.getClass()); + } + } + + /** Unmarshals values; alignment counts from the start of the message. */ + static final class Reader { + final ByteBuffer buf; + + Reader(ByteBuffer buf) { + this.buf = buf; + } + + void skip(int n) { + buf.position(buf.position() + n); + } + + void align(int n) { + int p = buf.position(); + buf.position((p + n - 1) / n * n); + } + + Object read(String type) { + char c = type.charAt(0); + align(alignment(c)); + return switch (c) { + case 'y' -> buf.get() & 0xff; + case 'b' -> buf.getInt() != 0; + case 'n' -> (int) buf.getShort(); + case 'q' -> buf.getShort() & 0xffff; + case 'i' -> buf.getInt(); + case 'u' -> Integer.toUnsignedLong(buf.getInt()); + case 'x', 't' -> buf.getLong(); + case 'd' -> buf.getDouble(); + case 's', 'o' -> text(buf.getInt()); + case 'g' -> text(buf.get() & 0xff); + case 'v' -> read((String) read("g")); + case 'a' -> { + int length = buf.getInt(); + String element = type.substring(1, typeEnd(type, 1)); + align(alignment(element.charAt(0))); + int end = buf.position() + length; + List items = new ArrayList<>(); + while (buf.position() < end) items.add(read(element)); + yield items; + } + case '(', '{' -> { + List members = new ArrayList<>(); + for (int i = 1; i < type.length() - 1; ) { + int next = typeEnd(type, i); + members.add(read(type.substring(i, next))); + i = next; + } + yield members.toArray(); + } + default -> throw new IllegalArgumentException("Cannot read D-Bus type " + type); + }; + } + + private String text(int length) { + byte[] b = new byte[length]; + buf.get(b); + buf.get(); // the terminating NUL + return new String(b, StandardCharsets.UTF_8); + } + } +} diff --git a/ts3-client/desktop/src/main/java/com/ts3client/notify/desktop/DesktopNotifications.java b/ts3-client/desktop/src/main/java/com/ts3client/notify/desktop/DesktopNotifications.java new file mode 100644 index 0000000..1526e01 --- /dev/null +++ b/ts3-client/desktop/src/main/java/com/ts3client/notify/desktop/DesktopNotifications.java @@ -0,0 +1,36 @@ +package com.ts3client.notify.desktop; + +import java.util.function.Consumer; + +/** + * The desktop's own notifications — the ones its notification centre collects. Calls + * may block on the desktop, so they belong off the event thread; the callbacks come + * on a thread of the implementation's. + */ +public interface DesktopNotifications { + + /** + * @param title the headline, such as who a message is from + * @param body plain text + * @param onOpen run when the user clicks the notification + * @param onReply given the text typed into the notification, where {@link #canReply()}; may be {@code null} + */ + record Notice(String title, String body, Runnable onOpen, Consumer onReply) { + } + + /** + * Shows a notice, in place of an earlier one still on screen. + * + * @param replaces what an earlier call returned, or 0 + * @return the notice's id for {@link #close} and later replacement, or 0 if it could not be shown + */ + int show(Notice notice, int replaces); + + /** Takes a notice away, from the notification centre too. */ + void close(int id); + + /** Whether a notice can take a reply typed into it. */ + boolean canReply(); + + void shutdown(); +} diff --git a/ts3-client/desktop/src/main/java/com/ts3client/notify/desktop/FreedesktopNotifications.java b/ts3-client/desktop/src/main/java/com/ts3client/notify/desktop/FreedesktopNotifications.java new file mode 100644 index 0000000..751538a --- /dev/null +++ b/ts3-client/desktop/src/main/java/com/ts3client/notify/desktop/FreedesktopNotifications.java @@ -0,0 +1,126 @@ +package com.ts3client.notify.desktop; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Notifications through the freedesktop.org notification service, which every Linux + * desktop (Plasma, GNOME, XFCE, a standalone daemon such as dunst) provides on the + * session bus. Plasma also takes a reply typed into the notification. + */ +public final class FreedesktopNotifications implements DesktopNotifications { + + private static final String SERVICE = "org.freedesktop.Notifications"; + private static final String PATH = "/org/freedesktop/Notifications"; + private static final String DEFAULT_ACTION = "default"; + /** Plasma's extension for a reply field in the notification. */ + private static final String REPLY_ACTION = "inline-reply"; + + private final DBusConnection bus; + private final String appName; + private final String desktopEntry; + private final String icon; + private final boolean markup; + private final boolean reply; + /** The notices on screen, by the id the service gave them. */ + private final Map shown = new ConcurrentHashMap<>(); + + /** + * @param desktopEntry the name of the application's {@code .desktop} file, without the + * extension; the desktop takes the application's name and icon from it + * @param icon an icon file, for services that do not look at the desktop entry + * @return the service, or {@code null} where there is none to reach + */ + public static FreedesktopNotifications connect(String appName, String desktopEntry, String icon) { + DBusConnection bus = null; + try { + bus = DBusConnection.session(); + List capabilities = (List) bus.call(SERVICE, PATH, SERVICE, "GetCapabilities", "").getFirst(); + bus.addMatch("type='signal',interface='" + SERVICE + "'"); + return new FreedesktopNotifications(bus, appName, desktopEntry, icon, capabilities); + } catch (IOException | RuntimeException e) { + System.err.println("Desktop notifications unavailable: " + e.getMessage()); + if (bus != null) bus.close(); + return null; + } + } + + private FreedesktopNotifications(DBusConnection bus, String appName, String desktopEntry, String icon, + List capabilities) { + this.bus = bus; + this.appName = appName; + this.desktopEntry = desktopEntry; + this.icon = icon == null ? "" : icon; + this.markup = capabilities.contains("body-markup"); + this.reply = capabilities.contains(REPLY_ACTION); + bus.onSignal(this::signal); + } + + @Override + public boolean canReply() { + return reply; + } + + @Override + public int show(Notice notice, int replaces) { + List actions = new ArrayList<>(List.of(DEFAULT_ACTION, "Open")); + if (reply && notice.onReply() != null) actions.addAll(List.of(REPLY_ACTION, "Reply")); + Map hints = new LinkedHashMap<>(); + hints.put("desktop-entry", desktopEntry); + hints.put("category", "im.received"); + // The client plays the sound pack's own sound for whatever it notifies of. + hints.put("suppress-sound", true); + try { + long id = (Long) bus.call(SERVICE, PATH, SERVICE, "Notify", "susssasa{sv}i", + appName, (long) replaces, icon, notice.title(), + markup ? escape(notice.body()) : notice.body(), actions, hints, -1).getFirst(); + shown.remove((long) replaces); + shown.put(id, notice); + return (int) id; + } catch (IOException | RuntimeException e) { + System.err.println("Cannot show a notification: " + e.getMessage()); + return 0; + } + } + + @Override + public void close(int id) { + if (shown.remove((long) id) == null) return; + try { + bus.call(SERVICE, PATH, SERVICE, "CloseNotification", "u", (long) id); + } catch (IOException e) { + // Gone with the service; nothing to take away. + } + } + + @Override + public void shutdown() { + for (Long id : List.copyOf(shown.keySet())) close(id.intValue()); + bus.close(); + } + + private void signal(String iface, String member, List args) { + if (!SERVICE.equals(iface) || args.isEmpty() || !(args.getFirst() instanceof Long id)) return; + switch (member) { + case "ActionInvoked" -> { + Notice notice = shown.get(id); + if (notice != null && DEFAULT_ACTION.equals(args.get(1))) notice.onOpen().run(); + } + case "NotificationReplied" -> { + Notice notice = shown.get(id); + if (notice != null && notice.onReply() != null) notice.onReply().accept((String) args.get(1)); + } + case "NotificationClosed" -> shown.remove(id); + default -> { } + } + } + + /** The body is markup where the service says so; message text must not turn into any. */ + private static String escape(String text) { + return text.replace("&", "&").replace("<", "<").replace(">", ">"); + } +} diff --git a/ts3-client/desktop/src/test/java/com/ts3client/notify/desktop/DBusWireTest.java b/ts3-client/desktop/src/test/java/com/ts3client/notify/desktop/DBusWireTest.java new file mode 100644 index 0000000..a626b59 --- /dev/null +++ b/ts3-client/desktop/src/test/java/com/ts3client/notify/desktop/DBusWireTest.java @@ -0,0 +1,47 @@ +package com.ts3client.notify.desktop; + +import org.junit.jupiter.api.Test; + +import java.nio.ByteBuffer; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +class DBusWireTest { + + @Test + void methodCallReadsBackAsWritten() { + Map hints = new LinkedHashMap<>(); + hints.put("desktop-entry", "ts3j-client"); + hints.put("suppress-sound", true); + byte[] message = DBusWire.methodCall(7, "org.freedesktop.Notifications", "/org/freedesktop/Notifications", + "org.freedesktop.Notifications", "Notify", "susssasa{sv}i", + "TS3J", 3L, "", "Bob", "Hi ž", List.of("default", "Open"), hints, -1); + + assertEquals(message.length, DBusWire.messageLength(ByteBuffer.wrap(message, 0, DBusWire.FIXED_HEADER))); + DBusWire.Message m = DBusWire.parse(message); + assertEquals(DBusWire.METHOD_CALL, m.type()); + assertEquals(7, m.serial()); + assertEquals("Notify", m.string(DBusWire.FIELD_MEMBER)); + assertEquals("/org/freedesktop/Notifications", m.string(DBusWire.FIELD_PATH)); + assertEquals("TS3J", m.body().get(0)); + assertEquals(3L, m.body().get(1)); + assertEquals("Hi ž", m.body().get(4)); + assertEquals(List.of("default", "Open"), m.body().get(5)); + List entries = (List) m.body().get(6); + assertArrayEquals(new Object[]{"desktop-entry", "ts3j-client"}, (Object[]) entries.get(0)); + assertArrayEquals(new Object[]{"suppress-sound", true}, (Object[]) entries.get(1)); + assertEquals(-1, m.body().get(7)); + } + + @Test + void completeTypesAreDelimited() { + assertEquals(1, DBusWire.typeEnd("su", 0)); + assertEquals(5, DBusWire.typeEnd("a{sv}i", 0)); + assertEquals(5, DBusWire.typeEnd("a(yv)s", 0)); + assertEquals(3, DBusWire.typeEnd("aasu", 1)); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/DesktopEntry.java b/ts3-client/swing/src/main/java/com/ts3client/ui/DesktopEntry.java index d9b9120..3b0781b 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/DesktopEntry.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/DesktopEntry.java @@ -40,7 +40,7 @@ public final class DesktopEntry { } try { Path data = dataHome(); - Path icon = data.resolve("icons").resolve(WindowClass.NAME + ".png"); + Path icon = icon(); writeIfChanged(icon, iconPng()); writeIfChanged(data.resolve("applications").resolve(WindowClass.NAME + ".desktop"), entry(icon).getBytes(StandardCharsets.UTF_8)); @@ -49,6 +49,11 @@ public final class DesktopEntry { } } + /** The application icon the entry names, which desktop notifications show too. */ + static Path icon() { + return dataHome().resolve("icons").resolve(WindowClass.NAME + ".png"); + } + private static Path dataHome() { String xdg = System.getenv("XDG_DATA_HOME"); if (xdg != null && !xdg.isBlank()) return Path.of(xdg); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/DesktopNotifier.java b/ts3-client/swing/src/main/java/com/ts3client/ui/DesktopNotifier.java new file mode 100644 index 0000000..113c53d --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/DesktopNotifier.java @@ -0,0 +1,158 @@ +package com.ts3client.ui; + +import com.ts3client.config.Settings; +import com.ts3client.net.ConnectionListener.ChatScope; +import com.ts3client.notify.desktop.DesktopNotifications; +import com.ts3client.notify.desktop.DesktopNotifications.Notice; +import com.ts3client.notify.desktop.FreedesktopNotifications; +import com.ts3client.text.BBCode; +import com.ts3client.ui.tray.AwtTray; + +import javax.swing.SwingUtilities; +import java.awt.KeyboardFocusManager; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Tells the user about pokes and private messages through the desktop's notifications + * while none of the client's windows has the focus. A private conversation keeps one + * notification, updated as messages come; all of them go once the window is back in + * front, as they have then been seen. + * + *

Called on the event thread. Talking to the desktop may block, so that happens on a + * thread of its own, which also keeps the notifications in order. + */ +final class DesktopNotifier { + + private static final int MAX_LINES = 5; + + private final MainFrame frame; + private final Settings settings; + private final ExecutorService worker = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "notifications"); + t.setDaemon(true); + return t; + }); + + /** Found on first use, on the worker; {@code null} where the desktop offers none. */ + private DesktopNotifications backend; + private boolean resolved; + + /** One notification per private conversation; touched on the worker only. */ + private final Map conversations = new HashMap<>(); + private final List pokes = new ArrayList<>(); + + private record Conversation(ServerTab tab, int clientId) { + } + + private static final class Shown { + final List lines = new ArrayList<>(); + int id; + } + + DesktopNotifier(MainFrame frame, Settings settings) { + this.frame = frame; + this.settings = settings; + } + + void poke(ServerTab tab, String from, String message) { + if (!wanted()) return; + String body = BBCode.toPlain(message); + Notice notice = new Notice(from + " poked you", body, () -> open(tab, null), null); + worker.execute(() -> { + DesktopNotifications out = backend(); + if (out == null) return; + int id = out.show(notice, 0); + if (id != 0) pokes.add(id); + }); + } + + void privateMessage(ServerTab tab, int clientId, String from, String message) { + if (!wanted()) return; + String text = BBCode.toPlain(message); + Conversation key = new Conversation(tab, clientId); + worker.execute(() -> { + DesktopNotifications out = backend(); + if (out == null) return; + Shown shown = conversations.computeIfAbsent(key, k -> new Shown()); + shown.lines.add(text); + if (shown.lines.size() > MAX_LINES) shown.lines.removeFirst(); + Notice notice = new Notice(from, String.join("\n", shown.lines), + () -> open(tab, clientId), reply -> reply(tab, clientId, reply)); + shown.id = out.show(notice, shown.id); + }); + } + + /** The window is in front again: what was notified of is in plain sight. */ + void windowActivated() { + worker.execute(() -> { + if (backend == null) return; + conversations.values().forEach(shown -> backend.close(shown.id)); + conversations.clear(); + pokes.forEach(backend::close); + pokes.clear(); + }); + } + + /** A private chat on a server that went away can neither be opened nor answered. */ + void forget(ServerTab tab) { + worker.execute(() -> conversations.entrySet().removeIf(e -> { + if (e.getKey().tab() != tab) return false; + if (backend != null) backend.close(e.getValue().id); + return true; + })); + } + + void shutdown() { + worker.execute(() -> { + if (backend != null) backend.shutdown(); + }); + worker.shutdown(); + } + + private boolean wanted() { + // Some window of ours has the focus exactly when there is an active window. + return settings.desktopNotifications + && KeyboardFocusManager.getCurrentKeyboardFocusManager().getActiveWindow() == null; + } + + private DesktopNotifications backend() { + if (!resolved) { + resolved = true; + backend = create(); + } + return backend; + } + + /** The desktop's notification service on Linux; elsewhere the tray icon's messages. */ + private DesktopNotifications create() { + if (System.getProperty("os.name", "").toLowerCase(Locale.ROOT).startsWith("linux")) { + DesktopNotifications service = FreedesktopNotifications.connect( + "TS3J", WindowClass.NAME, DesktopEntry.icon().toString()); + if (service != null) return service; + } + return frame.awtTray() instanceof AwtTray tray ? new TrayNotifications(tray) : null; + } + + private void open(ServerTab tab, Integer clientId) { + SwingUtilities.invokeLater(() -> { + frame.bringToFront(); + frame.selectTab(tab); + if (clientId != null) { + var client = tab.connection().getModel().getClient(clientId); + if (client != null) tab.chat().openPrivateChat(clientId, client.uniqueId, client.nickname); + } + }); + } + + private void reply(ServerTab tab, int clientId, String text) { + SwingUtilities.invokeLater(() -> { + if (tab.session().isConnected()) tab.session().sendChat(ChatScope.PRIVATE, clientId, text); + }); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java index e4e2ccc..ea6b9ec 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java @@ -18,6 +18,7 @@ import com.ts3client.session.ServerSession; import com.ts3client.session.Sessions; import com.ts3client.sound.SoundNotifier; import com.ts3client.sound.SoundPlayer; +import com.ts3client.ui.tray.TrayBackend; import javax.swing.AbstractAction; import javax.swing.JCheckBox; @@ -35,6 +36,7 @@ import javax.swing.SwingUtilities; import java.awt.BorderLayout; import java.awt.Dimension; import java.awt.GridLayout; +import java.awt.KeyboardFocusManager; import java.awt.event.ActionEvent; import java.awt.event.WindowAdapter; import java.awt.event.WindowEvent; @@ -81,6 +83,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { /** Mirrors the active server's own client state next to the clock; set once the window is up. */ private volatile TrayController tray; + private final DesktopNotifier notifier; private MainToolbar toolbar; private StatusBar statusBar; @@ -100,6 +103,11 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { this.sounds = new SoundNotifier(settings); this.soundPlayer = audio.createSoundPlayer(settings); this.sounds.setPlayer(soundPlayer); + this.notifier = new DesktopNotifier(this, settings); + // Any of the client's windows coming to the front counts, a dialog included. + KeyboardFocusManager.getCurrentKeyboardFocusManager().addPropertyChangeListener("activeWindow", e -> { + if (e.getNewValue() != null) notifier.windowActivated(); + }); this.hotkeys = new HotkeyService(new HotkeyActions(this), this::hotkeyArgumentChoices); hotkeys.addKeyPressListener(() -> SwingUtilities.invokeLater(sessions::keyPressed)); contacts.addListener(() -> SwingUtilities.invokeLater(this::contactsChanged)); @@ -437,9 +445,21 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { void tabDisconnected(ServerTab tab) { if (!tabs.contains(tab)) return; sessions.disconnected(tab.session()); + notifier.forget(tab); tabUpdated(tab); } + /** Tells the user of a poke or a private message while the client is in the background. */ + DesktopNotifier notifier() { + return notifier; + } + + /** The tray icon when AWT docked it, whose messages are the notifications on Windows and macOS. */ + TrayBackend awtTray() { + TrayController current = tray; + return current == null ? null : current.backend(); + } + // ---- hotkeys ---- /** @@ -537,7 +557,14 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { IconTheme.get().reload(settings); } + /** Brings the window back from the tray, the taskbar or behind other windows. */ void bringToFront() { + TrayController current = tray; + if (current != null) { + // The tray can ask the window manager in a way it honours for a window without focus. + current.show(); + return; + } setVisible(true); setExtendedState(getExtendedState() & ~JFrame.ICONIFIED); toFront(); @@ -743,6 +770,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { soundPlayer.shutdown(); hotkeys.dispose(); if (tray != null) tray.dispose(); + notifier.shutdown(); } /** Leaves every server and ends the process. */ diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/NotificationsPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/NotificationsPanel.java index 1e5c84d..2abfc4d 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/NotificationsPanel.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/NotificationsPanel.java @@ -9,6 +9,7 @@ import com.ts3client.sound.SoundNotifier; import com.ts3client.sound.SoundPack; import javax.swing.JButton; +import javax.swing.JCheckBox; import javax.swing.JComboBox; import javax.swing.JComponent; import javax.swing.JFileChooser; @@ -76,6 +77,8 @@ final class NotificationsPanel extends JPanel { private final JLabel packInfo = new JLabel(); private final JSlider volume; private final JTextField packDirField; + private final JCheckBox desktopNotifications = + new JCheckBox("Show pokes and private messages as desktop notifications while in the background"); private final JTable table; private final EventTableModel tableModel = new EventTableModel(); @@ -107,6 +110,7 @@ final class NotificationsPanel extends JPanel { String directory = packDirField.getText().trim(); boolean rescan = !directory.equals(settings.soundPackDir); settings.soundPackDir = directory; + settings.desktopNotifications = desktopNotifications.isSelected(); sounds.setPack((SoundPack) packCombo.getSelectedItem()); if (rescan) sounds.reload(); } @@ -153,7 +157,14 @@ final class NotificationsPanel extends JPanel { dirRow.add(packDirField, BorderLayout.CENTER); dirRow.add(browse, BorderLayout.EAST); packDirField.setToolTipText("Extra folder to look for sound packs in"); - addRow(p, c, row, new JLabel("Extra pack folder:"), dirRow); + addRow(p, c, row++, new JLabel("Extra pack folder:"), dirRow); + + desktopNotifications.setSelected(settings.desktopNotifications); + c.gridx = 0; + c.gridy = row; + c.gridwidth = 2; + p.add(desktopNotifications, c); + c.gridwidth = 1; return p; } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabConnectionEvents.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabConnectionEvents.java index 6b98eac..10cf9ec 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabConnectionEvents.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabConnectionEvents.java @@ -136,8 +136,11 @@ final class ServerTabConnectionEvents implements ServerSession.Listener { public void onChat(ChatScope scope, int fromClientId, String fromUniqueId, String fromName, String message) { SwingUtilities.invokeLater(() -> { switch (scope) { - case PRIVATE -> chatPanel.appendPrivateMessage(fromClientId, fromUniqueId, fromName, - fromClientId, fromUniqueId, fromName, message); + case PRIVATE -> { + chatPanel.appendPrivateMessage(fromClientId, fromUniqueId, fromName, + fromClientId, fromUniqueId, fromName, message); + host.notifier().privateMessage(tab, fromClientId, fromName, message); + } case SERVER -> chatPanel.appendServerMessage(fromClientId, fromUniqueId, fromName, message); default -> chatPanel.appendChannelMessage(fromClientId, fromUniqueId, fromName, message); } @@ -183,6 +186,7 @@ final class ServerTabConnectionEvents implements ServerSession.Listener { @Override public void onPoke(String fromName, String message) { SwingUtilities.invokeLater(() -> { + host.notifier().poke(tab, fromName, message); host.selectTab(tab); JOptionPane.showMessageDialog(host, fromName + " poked you:\n\n" + message, "Poke", JOptionPane.INFORMATION_MESSAGE); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/TrayController.java b/ts3-client/swing/src/main/java/com/ts3client/ui/TrayController.java index 66e5295..6d304b0 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/TrayController.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/TrayController.java @@ -110,6 +110,11 @@ final class TrayController { if (tray != null) tray.dispose(); } + /** The backend that took the icon, or {@code null} when none could. */ + TrayBackend backend() { + return tray; + } + /** The icon a state is drawn with, from the active icon pack. */ private static ImageIcon icon(SelfState state, int size) { return switch (state) { @@ -230,7 +235,7 @@ final class TrayController { * not focused, which is every time the tray icon is used, so the backend is asked * first — it can make the request the window manager does honour. */ - private void show() { + void show() { hideMenu(); frame.setVisible(true); frame.setExtendedState(frame.getExtendedState() & ~Frame.ICONIFIED); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/TrayNotifications.java b/ts3-client/swing/src/main/java/com/ts3client/ui/TrayNotifications.java new file mode 100644 index 0000000..6a96df3 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/TrayNotifications.java @@ -0,0 +1,36 @@ +package com.ts3client.ui; + +import com.ts3client.notify.desktop.DesktopNotifications; +import com.ts3client.ui.tray.AwtTray; + +/** + * Desktop notifications as messages from AWT's tray icon, for Windows and macOS: they + * land in the system's notification centre. A message cannot be replaced or taken back. + */ +final class TrayNotifications implements DesktopNotifications { + + private final AwtTray tray; + + TrayNotifications(AwtTray tray) { + this.tray = tray; + } + + @Override + public int show(Notice notice, int replaces) { + tray.showMessage(notice.title(), notice.body(), notice.onOpen()); + return 1; + } + + @Override + public void close(int id) { + } + + @Override + public boolean canReply() { + return false; + } + + @Override + public void shutdown() { + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/tray/AwtTray.java b/ts3-client/swing/src/main/java/com/ts3client/ui/tray/AwtTray.java index d0074e1..281d4c5 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/tray/AwtTray.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/tray/AwtTray.java @@ -21,6 +21,8 @@ public final class AwtTray implements TrayBackend { private final TrayIcon icon; private final int size; + /** What a click on the message last shown does, until it is used. */ + private volatile Runnable messageClick; /** * @param menu the icon's context menu, or {@code null} to have right clicks @@ -64,9 +66,24 @@ public final class AwtTray implements TrayBackend { } } }); + // Fired by a click on a message (and on some desktops a double click on the icon). + icon.addActionListener(e -> { + Runnable click = messageClick; + messageClick = null; + if (click != null) click.run(); + }); SystemTray.getSystemTray().add(icon); } + /** + * Shows a message by the icon, which Windows turns into a toast in its notification + * centre and macOS into a banner in its own. + */ + public void showMessage(String title, String body, Runnable onClick) { + messageClick = onClick; + icon.displayMessage(title, body, TrayIcon.MessageType.NONE); + } + @Override public void setIcon(IntFunction render) { Image image = render.apply(size);