✨ Add server that can detect windows and frames
authorDavid Roden <github-a8in@qsheltier.de>
Fri, 24 Jan 2025 18:57:41 +0000 (19:57 +0100)
committerDavid Roden <github-a8in@qsheltier.de>
Sat, 25 Jan 2025 09:27:48 +0000 (10:27 +0100)
server/src/main/java/de/qsheltier/msta/Server.java [new file with mode: 0644]
server/src/test/java/de/qsheltier/msta/ServerTest.java [new file with mode: 0644]

diff --git a/server/src/main/java/de/qsheltier/msta/Server.java b/server/src/main/java/de/qsheltier/msta/Server.java
new file mode 100644 (file)
index 0000000..8176618
--- /dev/null
@@ -0,0 +1,170 @@
+package de.qsheltier.msta;
+
+import java.awt.Component;
+import java.awt.Frame;
+import java.awt.Window;
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.net.InetSocketAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.net.SocketAddress;
+import java.util.ArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Consumer;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Arrays.stream;
+
+/**
+ * MSTA Server Component. Opens a TCP {@link Socket}, waits for a connection,
+ * handles one incoming connection, and calls the shutdown hook after the
+ * connection has been closed.
+ */
+public class Server implements Closeable {
+
+       /**
+        * Creates a new MSTA Server that will listen on the given port and
+        * run the given shutdown hook when the connection has been closed.
+        * If the given port is {@code 0}, a random port number will be chosen
+        * and can be retrieved using {@link #getPort()}.
+        *
+        * @param port The port to listen on ({@code 0}, or between
+        *        {@code 1024} and {@code 65535})
+        * @param shutdownHook The shutdown hook to run when the
+        *              connection has been closed
+        * @throws IOException if the port cannot be {@link ServerSocket#bind(SocketAddress) bound}
+        */
+       public Server(int port, Runnable shutdownHook) throws IOException {
+               this.shutdownHook = shutdownHook;
+               serverSocket.bind(new InetSocketAddress(port));
+       }
+
+       /**
+        * Creates a new MSTA Server that will listen on a random port and
+        * run the given shutdown hook when the connection has been closed.
+        *
+        * @param shutdownHook The shutdown hook to run when the connection has been closed
+        * @throws IOException if the port cannot be {@link ServerSocket#bind(SocketAddress) bound}
+        */
+       public Server(Runnable shutdownHook) throws IOException {
+               this(0, shutdownHook);
+       }
+
+       /**
+        * Creates a new MSTA Server that will listen on a random port and
+        * do nothing when the connection has been closed.
+        *
+        * @throws IOException if the port cannot be {@link ServerSocket#bind(SocketAddress) bound}
+        */
+       public Server() throws IOException {
+               this(() -> {});
+       }
+
+       /**
+        * Starts the server.
+        *
+        * @throws InterruptedException
+        */
+       public void start() throws InterruptedException {
+               var startLatch = new CountDownLatch(1);
+               new Thread(() -> {
+                       try {
+                               startLatch.countDown();
+                               var socket = serverSocket.accept();
+                               handleSocket(socket.getInputStream(), socket.getOutputStream());
+                       } catch (IOException e) {
+                               /* swallow exceptions. */
+                       } finally {
+                               shutdownHook.run();
+                       }
+               }).start();
+               startLatch.await();
+       }
+
+       /**
+        * Shuts the server down and closes the server socket.
+        *
+        * @throws IOException if an I/O error occurs
+        */
+       @Override
+       public void close() throws IOException {
+               serverSocket.close();
+       }
+
+       /**
+        * Returns the local port of the server. Use this to get the port to
+        * connect to if you did not specify the port to bind to in the
+        * constructor.
+        *
+        * @return The port the server is listening on
+        */
+       public int getPort() {
+               return serverSocket.getLocalPort();
+       }
+
+       private void handleSocket(InputStream inputStream, OutputStream outputStream) throws IOException {
+               var finished = new AtomicBoolean(false);
+               var lastOpenWindows = new ArrayList<Window>();
+               var lastOpenFrames = new ArrayList<Frame>();
+               try (var inputReader = new BufferedReader(new InputStreamReader(inputStream));
+                    var outputWriter = new BufferedWriter(new OutputStreamWriter(outputStream, UTF_8))) {
+                       Consumer<String> writeLine = (String line) -> {
+                               try {
+                                       outputWriter.write(line + "\r\n");
+                                       outputWriter.flush();
+                               } catch (IOException e) {
+                                       /* ignore. */
+                               }
+                       };
+                       writeLine.accept("{\"event\":\"connected\"}");
+                       new Thread(() -> {
+                               while (!finished.get()) {
+                                       var allWindows = stream(Window.getWindows()).filter(Component::isVisible).toList();
+                                       var openWindows = allWindows.stream()
+                                                       .filter(window -> !(window instanceof Frame))
+                                                       .filter(window -> !lastOpenWindows.contains(window))
+                                                       .toList();
+                                       openWindows.stream()
+                                                       .map(window -> "{\"event\":\"new-window\"}")
+                                                       .forEach(writeLine);
+
+                                       var openFrames = allWindows.stream()
+                                                       .filter(Frame.class::isInstance)
+                                                       .map(Frame.class::cast)
+                                                       .filter(frame -> !lastOpenFrames.contains(frame))
+                                                       .toList();
+                                       openFrames.stream()
+                                                       .map(window -> "{\"event\":\"new-frame\"}")
+                                                       .forEach(writeLine);
+
+                                       lastOpenWindows.clear();
+                                       lastOpenWindows.addAll(openWindows);
+                                       lastOpenFrames.clear();
+                                       lastOpenFrames.addAll(openFrames);
+                                       try {
+                                               Thread.sleep(100);
+                                       } catch (InterruptedException e) {
+                                               /* ignore. */
+                                       }
+                               }
+                       }).start();
+                       String line;
+                       while ((line = inputReader.readLine()) != null) {
+                       }
+               } finally {
+                       finished.set(true);
+               }
+       }
+
+       private final ServerSocket serverSocket = new ServerSocket();
+       private final Runnable shutdownHook;
+
+}
diff --git a/server/src/test/java/de/qsheltier/msta/ServerTest.java b/server/src/test/java/de/qsheltier/msta/ServerTest.java
new file mode 100644 (file)
index 0000000..3077005
--- /dev/null
@@ -0,0 +1,110 @@
+package de.qsheltier.msta;
+
+import java.awt.Frame;
+import java.awt.Window;
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.api.function.ThrowingConsumer;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.not;
+import static org.junit.jupiter.api.Timeout.ThreadMode.SEPARATE_THREAD;
+
+public class ServerTest {
+
+       @Test
+       public void newServerExposesLocalPort() throws Exception {
+               try (var server = new Server()) {
+                       assertThat(server.getPort(), not(equalTo(-1)));
+               }
+       }
+
+       @Test
+       @Timeout(value = 5, unit = TimeUnit.SECONDS, threadMode = SEPARATE_THREAD)
+       public void serverSendsConnectEventUponConnection() throws Exception {
+               try (var server = new Server()) {
+                       server.start();
+                       try (var connection = new Socket("localhost", server.getPort());
+                            var reader = new BufferedReader(new InputStreamReader(connection.getInputStream()))) {
+                               var line = reader.readLine();
+                               assertThat(line, equalTo("{\"event\":\"connected\"}"));
+                       }
+               }
+       }
+
+       @Test
+       @Timeout(value = 5, unit = TimeUnit.SECONDS, threadMode = SEPARATE_THREAD)
+       public void serverRunsShutdownHookWhenConnectionIsClosed() throws Exception {
+               var shutdown = new AtomicBoolean();
+               try (var server = new Server(() -> shutdown.set(true))) {
+                       server.start();
+                       try (var ignored = new Socket("localhost", server.getPort())) {
+                       }
+                       while (!shutdown.get()) {
+                               Thread.yield();
+                       }
+               }
+       }
+
+       @Test
+       public void serverUsesTheGivenPortNumber() throws Exception {
+               int serverPort;
+               try (var serverSocket = new ServerSocket(0)) {
+                       serverPort = serverSocket.getLocalPort();
+               }
+               try (var server = new Server(serverPort, () -> {})) {
+                       server.start();
+                       try (var ignored = new Socket("localhost", serverPort)) {
+                       }
+               }
+       }
+
+       @Test
+       @Timeout(value = 5, unit = TimeUnit.SECONDS, threadMode = SEPARATE_THREAD)
+       public void serverSendsEventWhenAWindowIsOpened() throws Throwable {
+               var window = new Window(null);
+               window.setVisible(true);
+               try {
+                       connectToServerAndWaitForEvent(reader -> {
+                               var line = reader.readLine();
+                               assertThat(line, equalTo("{\"event\":\"new-window\"}"));
+                       });
+               } finally {
+                       window.setVisible(false);
+               }
+       }
+
+       @Test
+       @Timeout(value = 5, unit = TimeUnit.SECONDS, threadMode = SEPARATE_THREAD)
+       public void serverSendsEventWhenAFrameIsOpened() throws Throwable {
+               var frame = new Frame("Frame Title");
+               frame.setVisible(true);
+               try {
+                       connectToServerAndWaitForEvent(reader -> {
+                               var line = reader.readLine();
+                               assertThat(line, equalTo("{\"event\":\"new-frame\"}"));
+                       });
+               } finally {
+                       frame.setVisible(false);
+               }
+       }
+
+       private static void connectToServerAndWaitForEvent(ThrowingConsumer<BufferedReader> readerConsumer) throws Throwable {
+               try (var server = new Server()) {
+                       server.start();
+                       try (var connection = new Socket("localhost", server.getPort());
+                            var reader = new BufferedReader(new InputStreamReader(connection.getInputStream()))) {
+                               reader.readLine();
+                               readerConsumer.accept(reader);
+                       }
+               }
+       }
+
+}