Notify of pokes and private messages on the desktop
While none of the client's windows has the focus, a poke or a private message shows a desktop notification, which the desktop's notification centre keeps. A private conversation keeps one notification, updated as messages come; clicking it brings the window forward with that chat open, and they all go once a window of the client is in front again. On Linux they go to the freedesktop.org notification service, spoken to over the session bus by a small D-Bus client of our own on Java's Unix sockets, so no library or native code is involved; Plasma also takes a reply typed into the notification. Elsewhere they are AWT tray messages, which Windows and macOS put in their notification centres. The options can turn them off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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());
|
||||
|
||||
@@ -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<Object> args);
|
||||
}
|
||||
|
||||
private final SocketChannel channel;
|
||||
private final AtomicLong serials = new AtomicLong();
|
||||
private final Map<Long, CompletableFuture<List<Object>>> 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<Object> call(String destination, String path, String iface, String member, String signature,
|
||||
Object... args) throws IOException {
|
||||
long serial = serials.incrementAndGet();
|
||||
CompletableFuture<List<Object>> 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<List<Object>> reply = pending.get(m.replySerial());
|
||||
if (reply != null) reply.complete(m.body());
|
||||
}
|
||||
case DBusWire.ERROR -> {
|
||||
CompletableFuture<List<Object>> 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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<Integer, Object> fields, List<Object> 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<Integer, Object> 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<Object> 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<Object> items = new ArrayList<>();
|
||||
while (buf.position() < end) items.add(read(element));
|
||||
yield items;
|
||||
}
|
||||
case '(', '{' -> {
|
||||
List<Object> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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();
|
||||
}
|
||||
@@ -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<Long, Notice> 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<String> actions = new ArrayList<>(List.of(DEFAULT_ACTION, "Open"));
|
||||
if (reply && notice.onReply() != null) actions.addAll(List.of(REPLY_ACTION, "Reply"));
|
||||
Map<String, Object> 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<Object> 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(">", ">");
|
||||
}
|
||||
}
|
||||
@@ -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<String, Object> 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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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<Conversation, Shown> conversations = new HashMap<>();
|
||||
private final List<Integer> pokes = new ArrayList<>();
|
||||
|
||||
private record Conversation(ServerTab tab, int clientId) {
|
||||
}
|
||||
|
||||
private static final class Shown {
|
||||
final List<String> 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
}
|
||||
}
|
||||
@@ -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<Image> render) {
|
||||
Image image = render.apply(size);
|
||||
|
||||
Reference in New Issue
Block a user