--- /dev/null
+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;
+
+}
--- /dev/null
+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);
+ }
+ }
+ }
+
+}