From fa9cf93245627c45c4a100bffe394aacb3d600cb Mon Sep 17 00:00:00 2001 From: Peter Osterlund Date: Sat, 18 May 2019 21:20:27 +0200 Subject: [PATCH] Create EngineServer Java program This program can be run on a PC and works as an engine server that DroidFish can connect to using its "network engine" feature. --- EngineServer/.gitignore | 1 + EngineServer/build.gradle | 15 + .../org/petero/engineserver/EngineConfig.java | 35 +++ .../org/petero/engineserver/EngineServer.java | 134 +++++++++ .../org/petero/engineserver/ErrorHandler.java | 24 ++ .../org/petero/engineserver/MainWindow.java | 283 ++++++++++++++++++ .../org/petero/engineserver/PortListener.java | 201 +++++++++++++ settings.gradle | 2 +- 8 files changed, 694 insertions(+), 1 deletion(-) create mode 100644 EngineServer/.gitignore create mode 100644 EngineServer/build.gradle create mode 100644 EngineServer/src/main/java/org/petero/engineserver/EngineConfig.java create mode 100644 EngineServer/src/main/java/org/petero/engineserver/EngineServer.java create mode 100644 EngineServer/src/main/java/org/petero/engineserver/ErrorHandler.java create mode 100644 EngineServer/src/main/java/org/petero/engineserver/MainWindow.java create mode 100644 EngineServer/src/main/java/org/petero/engineserver/PortListener.java diff --git a/EngineServer/.gitignore b/EngineServer/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/EngineServer/.gitignore @@ -0,0 +1 @@ +/build diff --git a/EngineServer/build.gradle b/EngineServer/build.gradle new file mode 100644 index 0000000..50394ab --- /dev/null +++ b/EngineServer/build.gradle @@ -0,0 +1,15 @@ +apply plugin: 'java-library' + +jar { + manifest { + attributes "Main-Class": "org.petero.engineserver.EngineServer" + } +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + testImplementation 'junit:junit:4.12' +} + +sourceCompatibility = "8" +targetCompatibility = "8" diff --git a/EngineServer/src/main/java/org/petero/engineserver/EngineConfig.java b/EngineServer/src/main/java/org/petero/engineserver/EngineConfig.java new file mode 100644 index 0000000..ea588b4 --- /dev/null +++ b/EngineServer/src/main/java/org/petero/engineserver/EngineConfig.java @@ -0,0 +1,35 @@ +/* + EngineServer - Network engine server for DroidFish + Copyright (C) 2019 Peter Österlund, peterosterlund2@gmail.com + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +package org.petero.engineserver; + + +/** Defines settings for one network engine. */ +public class EngineConfig { + boolean enabled; + int port; + String filename; + String arguments; + + public EngineConfig(boolean e, int p, String fn, String args) { + enabled = e; + port = p; + filename = fn; + arguments = args; + } +} diff --git a/EngineServer/src/main/java/org/petero/engineserver/EngineServer.java b/EngineServer/src/main/java/org/petero/engineserver/EngineServer.java new file mode 100644 index 0000000..e7abda1 --- /dev/null +++ b/EngineServer/src/main/java/org/petero/engineserver/EngineServer.java @@ -0,0 +1,134 @@ +/* + EngineServer - Network engine server for DroidFish + Copyright (C) 2019 Peter Österlund, peterosterlund2@gmail.com + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +package org.petero.engineserver; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Properties; + + +/** Manages starting and stopping PortListeners. */ +public class EngineServer implements ErrorHandler { + private EngineConfig[] configs; + private PortListener[] portListeners; + private MainWindow window; + + public EngineServer(int numEngines) { + configs = new EngineConfig[numEngines]; + portListeners = new PortListener[numEngines]; + for (int i = 0; i < numEngines; i++) { + configs[i] = new EngineConfig(false, 4567 + i, "", ""); + } + readConfig(); + for (int i = 0; i < numEngines; i++) + configChanged(i); + } + + private File getConfigFile() { + String home = System.getProperty("user.home"); + return new File(home, ".engineServer.ini"); + } + + private void readConfig() { + try { + Properties prop = new Properties(); + InputStream is = new FileInputStream(getConfigFile()); + prop.load(is); + for (int i = 0; i < configs.length; i++) { + boolean enabled = Boolean.valueOf(prop.getProperty("enabled" + i, "false")); + String defPort = Integer.toString(4567 + i); + int port = Integer.valueOf(prop.getProperty("port" + i, defPort)); + String filename = prop.getProperty("filename" + i, ""); + String arguments = prop.getProperty("arguments" + i, ""); + configs[i] = new EngineConfig(enabled, port, filename, arguments); + } + } catch (IOException ignore) { + } catch (NumberFormatException ignore) { + } + } + + private void writeConfig() { + Properties prop = new Properties(); + for (int i = 0; i < configs.length; i++) { + EngineConfig config = configs[i]; + String enabled = config.enabled ? "true" : "false"; + String port = Integer.toString(config.port); + String filename = config.filename; + String arguments = config.arguments; + prop.setProperty("enabled" + i, enabled); + prop.setProperty("port" + i, port); + prop.setProperty("filename" + i, filename); + prop.setProperty("arguments" + i, arguments); + } + try { + OutputStream os = new FileOutputStream(getConfigFile()); + prop.store(os, "Created by EngineServer for DroidFish"); + } catch (IOException ignore) { + } + } + + private void run() { + window = new MainWindow(this, configs); + } + + public void configChanged(int engineNo) { + EngineConfig config = configs[engineNo]; + if (portListeners[engineNo] != null) { + portListeners[engineNo].shutdown(); + portListeners[engineNo] = null; + } + if (config.enabled) + portListeners[engineNo] = new PortListener(config, this); + } + + public void shutdown() { + writeConfig(); + for (PortListener pl : portListeners) + if (pl != null) + pl.shutdown(); + System.exit(0); + } + + public static void main(String[] args) { + int numEngines = 8; + if (args.length > 0) { + try { + numEngines = Integer.valueOf(args[0]); + numEngines = Math.max(1, numEngines); + numEngines = Math.min(20, numEngines); + } catch (NumberFormatException ignore) { + } + } + EngineServer server = new EngineServer(numEngines); + server.run(); + } + + @Override + public void reportError(String title, String message) { + if (window != null) { + window.reportError(title, message); + } else { + System.err.printf("%s\n%s\n", title, message); + } + } +} diff --git a/EngineServer/src/main/java/org/petero/engineserver/ErrorHandler.java b/EngineServer/src/main/java/org/petero/engineserver/ErrorHandler.java new file mode 100644 index 0000000..a626ea0 --- /dev/null +++ b/EngineServer/src/main/java/org/petero/engineserver/ErrorHandler.java @@ -0,0 +1,24 @@ +/* + EngineServer - Network engine server for DroidFish + Copyright (C) 2019 Peter Österlund, peterosterlund2@gmail.com + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +package org.petero.engineserver; + + +public interface ErrorHandler { + void reportError(String title, String message); +} diff --git a/EngineServer/src/main/java/org/petero/engineserver/MainWindow.java b/EngineServer/src/main/java/org/petero/engineserver/MainWindow.java new file mode 100644 index 0000000..5095e3b --- /dev/null +++ b/EngineServer/src/main/java/org/petero/engineserver/MainWindow.java @@ -0,0 +1,283 @@ +/* + EngineServer - Network engine server for DroidFish + Copyright (C) 2019 Peter Österlund, peterosterlund2@gmail.com + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +package org.petero.engineserver; + +import java.awt.Container; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.awt.event.FocusAdapter; +import java.awt.event.FocusEvent; +import java.awt.event.WindowAdapter; +import java.awt.event.WindowEvent; +import java.io.File; + +import javax.swing.JButton; +import javax.swing.JCheckBox; +import javax.swing.JDialog; +import javax.swing.JFileChooser; +import javax.swing.JFrame; +import javax.swing.JLabel; +import javax.swing.JOptionPane; +import javax.swing.JTextField; +import javax.swing.SwingUtilities; + + +/** Displays the GUI to configure engine settings. */ +public class MainWindow { + private JFrame frame; + private JCheckBox[] enabled; + private JTextField[] port; + private JTextField[] filename; + private JTextField[] arguments; + private JButton[] browse; + + private EngineServer server; + private EngineConfig[] configs; + + public MainWindow(EngineServer server, EngineConfig[] configs) { + this.server = server; + this.configs = configs; + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + initUI(); + } + }); + } + + private void initUI() { + final int numEngines = configs.length; + + frame = new JFrame(); + enabled = new JCheckBox[numEngines]; + port = new JTextField[numEngines]; + filename = new JTextField[numEngines]; + arguments = new JTextField[numEngines]; + browse = new JButton[numEngines]; + + Container pane = frame.getContentPane(); + frame.setTitle("Chess Engine Server"); + frame.addWindowListener(new WindowAdapter() { + @Override + public void windowClosing(WindowEvent event) { + server.shutdown(); + } + }); + + GridBagLayout layout = new GridBagLayout(); + pane.setLayout(layout); + int row = 0; + + GridBagConstraints constr = new GridBagConstraints(); + Insets inset = new Insets(0, 0, 5, 5); + constr.insets = inset; + constr.gridx = 0; + constr.gridy = row; + pane.add(new JLabel("Enabled"), constr); + + constr = new GridBagConstraints(); + constr.insets = inset; + constr.gridx = 1; + constr.gridy = row; + pane.add(new JLabel("Port"), constr); + + constr = new GridBagConstraints(); + constr.insets = inset; + constr.gridx = 2; + constr.gridy = row; + pane.add(new JLabel("Program and arguments"), constr); + + row++; + + for (int r = 0; r < numEngines; r++) { + final int engineNo = r; + final EngineConfig config = configs[r]; + enabled[r] = new JCheckBox(""); + constr = new GridBagConstraints(); + constr.insets = inset; + constr.gridx = 0; + constr.gridy = row; + pane.add(enabled[r], constr); + enabled[r].setSelected(config.enabled); + enabled[r].addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent event) { + enabledChanged(engineNo); + } + }); + + port[r] = new JTextField(); + constr = new GridBagConstraints(); + constr.anchor = GridBagConstraints.WEST; + constr.insets = inset; + constr.gridx = 1; + constr.gridy = row; + pane.add(port[r], constr); + port[r].setColumns(5); + port[r].setText(Integer.toString(config.port)); + port[r].addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent event) { + portChanged(engineNo); + } + }); + port[r].addFocusListener(new FocusAdapter() { + @Override + public void focusLost(FocusEvent focusEvent) { + portChanged(engineNo); + } + }); + + filename[r] = new JTextField(); + constr = new GridBagConstraints(); + constr.insets = inset; + constr.fill = GridBagConstraints.HORIZONTAL; + constr.gridx = 2; + constr.gridy = row; + pane.add(filename[r], constr); + filename[r].setColumns(40); + filename[r].setText(config.filename); + filename[r].addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent event) { + filenameChanged(engineNo); + } + }); + filename[r].addFocusListener(new FocusAdapter() { + @Override + public void focusLost(FocusEvent focusEvent) { + filenameChanged(engineNo); + } + }); + + arguments[r] = new JTextField(); + constr = new GridBagConstraints(); + constr.insets = inset; + constr.fill = GridBagConstraints.HORIZONTAL; + constr.gridx = 2; + constr.gridy = row + 1; + pane.add(arguments[r], constr); + arguments[r].setColumns(40); + arguments[r].setText(config.arguments); + arguments[r].addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent event) { + argumentsChanged(engineNo); + } + }); + arguments[r].addFocusListener(new FocusAdapter() { + @Override + public void focusLost(FocusEvent focusEvent) { + argumentsChanged(engineNo); + } + }); + + browse[r] = new JButton("Browse"); + constr = new GridBagConstraints(); + constr.anchor = GridBagConstraints.NORTH; + constr.insets = inset; + constr.gridx = 3; + constr.gridy = row; + constr.gridheight = 2; + pane.add(browse[r], constr); + browse[r].addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent event) { + browseFile(engineNo); + } + }); + + row += 2; + } + + frame.pack(); + frame.setLocationRelativeTo(null); + frame.setVisible(true); + } + + private void enabledChanged(int engineNo) { + EngineConfig config = configs[engineNo]; + boolean e = enabled[engineNo].isSelected(); + if (e != config.enabled) { + config.enabled = e; + server.configChanged(engineNo); + } + } + + private void portChanged(int engineNo) { + EngineConfig config = configs[engineNo]; + try { + int p = Integer.valueOf(port[engineNo].getText()); + if (p >= 1024 && p < 65536 && p != config.port) { + config.port = p; + server.configChanged(engineNo); + } + } catch (NumberFormatException ignore) { + } + } + + private void filenameChanged(int engineNo) { + EngineConfig config = configs[engineNo]; + String fn = filename[engineNo].getText().trim(); + if (!fn.equals(config.filename)) { + config.filename = fn; + server.configChanged(engineNo); + } + } + + private void browseFile(int engineNo) { + String fn = filename[engineNo].getText(); + JFileChooser chooser = new JFileChooser(); + chooser.setDialogTitle("Select chess engine"); + chooser.setSelectedFile(new File(fn)); + if (chooser.showOpenDialog(frame) == JFileChooser.APPROVE_OPTION) + fn = chooser.getSelectedFile().getAbsolutePath(); + filename[engineNo].setText(fn); + filenameChanged(engineNo); + } + + private void argumentsChanged(int engineNo) { + EngineConfig config = configs[engineNo]; + String args = arguments[engineNo].getText().trim(); + if (!args.equals(config.arguments)) { + config.arguments = args; + server.configChanged(engineNo); + } + } + + public void reportError(String title, String message) { + StringBuilder sb = new StringBuilder(); + int lineLen = 100; + while (message.length() > lineLen) { + sb.append(message.substring(0, lineLen)); + sb.append('\n'); + message = message.substring(lineLen); + } + sb.append(message); + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + JOptionPane.showMessageDialog(frame, sb.toString(), title, JOptionPane.ERROR_MESSAGE); + } + }); + } +} diff --git a/EngineServer/src/main/java/org/petero/engineserver/PortListener.java b/EngineServer/src/main/java/org/petero/engineserver/PortListener.java new file mode 100644 index 0000000..8e47003 --- /dev/null +++ b/EngineServer/src/main/java/org/petero/engineserver/PortListener.java @@ -0,0 +1,201 @@ +/* + EngineServer - Network engine server for DroidFish + Copyright (C) 2019 Peter Österlund, peterosterlund2@gmail.com + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +package org.petero.engineserver; + +import java.io.Closeable; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.util.ArrayList; + + +/** Listens to a TCP port and connects an engine process to a TCP socket. */ +public class PortListener { + private EngineConfig config; + private ErrorHandler errorHandler; + + private Thread thread; + private ServerSocket serverSocket; + private Socket clientSocket; + private Process proc; + private volatile boolean shutDownFlag = false; + + public PortListener(EngineConfig config, ErrorHandler errorHandler) { + this.config = config; + this.errorHandler = errorHandler; + + thread = new Thread() { + @Override + public void run() { + try { + mainLoop(); + } catch (InterruptedException ex) { + if (!shutDownFlag) + reportError("Background thread interrupted", ex); + } catch (IOException ex) { + if (!shutDownFlag) + reportError("IO error in background thread", ex); + } + } + }; + thread.start(); + } + + private void mainLoop() throws IOException, InterruptedException { + try (ServerSocket serverSocket = new ServerSocket()) { + serverSocket.setReuseAddress(true); + serverSocket.bind(new InetSocketAddress(config.port)); + + this.serverSocket = serverSocket; + while (!shutDownFlag) { + try (Socket clientSocket = serverSocket.accept()) { + this.clientSocket = clientSocket; + + ProcessBuilder builder = new ProcessBuilder(); + ArrayList args = new ArrayList<>(); + args.add(config.filename); + addArguments(args, config.arguments); + builder.command(args); + File dir = new File(config.filename).getParentFile(); + if (dir != null) + builder.directory(dir); + builder.redirectError(ProcessBuilder.Redirect.INHERIT); + + Process proc = builder.start(); + this.proc = proc; + Thread t1 = forwardIO(proc.getInputStream(), clientSocket.getOutputStream()); + Thread t2 = forwardIO(clientSocket.getInputStream(), proc.getOutputStream()); + try { + int exitCode = proc.waitFor(); + if (exitCode != 0) { +// errorHandler.reportError("Engine error", +// "Engine terminated with status " + exitCode); + } + } catch (InterruptedException ex) { + proc.getOutputStream().close(); + proc.destroyForcibly(); + } finally { + close(clientSocket); + t1.join(); + t2.join(); + proc.waitFor(); + this.proc = null; + } + } + } + } + } + + private void addArguments(ArrayList cmdList, String argString) { + boolean inQuote = false; + StringBuilder sb = new StringBuilder(); + int len = argString.length(); + for (int i = 0; i < len; i++) { + char c = argString.charAt(i); + switch (c) { + case '"': + inQuote = !inQuote; + if (!inQuote) { + cmdList.add(sb.toString()); + sb = new StringBuilder(); + } + break; + case '\\': + if (i < len - 1) { + sb.append(argString.charAt(i + 1)); + i++; + } + break; + case ' ': + case '\t': + if (!inQuote) { + if (!sb.toString().isEmpty()) { + cmdList.add(sb.toString()); + sb = new StringBuilder(); + } + break; + } + default: + sb.append(c); + break; + } + } + if (!sb.toString().isEmpty()) + cmdList.add(sb.toString()); + } + + private Thread forwardIO(InputStream is, OutputStream os) { + Thread t = new Thread() { + @Override + public void run() { + try { + while (true) { + byte[] buffer = new byte[4096]; + int len = is.read(buffer); + if (len < 0) + break; + os.write(buffer, 0, len); + os.flush(); + } + } catch (IOException ignore) { + } + close(is); + close(os); + Process p = proc; + if (p != null) + p.destroyForcibly(); + } + }; + t.start(); + return t; + } + + public void shutdown() { + shutDownFlag = true; + thread.interrupt(); + ServerSocket ss = serverSocket; + if (ss != null) { + close(ss); + } + Socket s = clientSocket; + if (s != null) { + close(s); + } + try { + thread.join(); + } catch (InterruptedException ex) { + reportError("Failed to shutdown background thread", ex); + } + } + + private void close(Closeable closeable) { + try { + closeable.close(); + } catch (IOException ignore) { + } + } + + private void reportError(String errMsg, Exception ex) { + errorHandler.reportError(errMsg, ex.getMessage()); + } +} diff --git a/settings.gradle b/settings.gradle index 3dca471..289ccf3 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1 @@ -include ':DroidFishApp', ':CuckooChessEngine', ':CuckooChessApp', ':CuckooChess' +include ':DroidFishApp', ':CuckooChessEngine', ':CuckooChessApp', ':CuckooChess', ':EngineServer'