From: David Roden Date: Fri, 24 Jan 2025 18:57:41 +0000 (+0100) Subject: ✨ Add server that can detect windows and frames X-Git-Url: https://git.pterodactylus.net/?a=commitdiff_plain;h=e24a1166271ce3701b301d18f9f0766ce554bb09;p=msta.git ✨ Add server that can detect windows and frames --- 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 index 0000000..8176618 --- /dev/null +++ b/server/src/main/java/de/qsheltier/msta/Server.java @@ -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(); + var lastOpenFrames = new ArrayList(); + try (var inputReader = new BufferedReader(new InputStreamReader(inputStream)); + var outputWriter = new BufferedWriter(new OutputStreamWriter(outputStream, UTF_8))) { + Consumer 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 index 0000000..3077005 --- /dev/null +++ b/server/src/test/java/de/qsheltier/msta/ServerTest.java @@ -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 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); + } + } + } + +}