package ctbrec.ui; import static java.nio.charset.StandardCharsets.*; import java.io.BufferedReader; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; import java.net.Socket; import java.util.Arrays; import java.util.HashMap; import java.util.Map; import java.util.Optional; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Consumer; import org.json.JSONObject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import ctbrec.Config; import ctbrec.OS; import ctbrec.io.ProcessOutputLogger; public class ExternalBrowser implements AutoCloseable { private static final Logger LOG = LoggerFactory.getLogger(ExternalBrowser.class); private static final ExternalBrowser INSTANCE = new ExternalBrowser(); private Lock lock = new ReentrantLock(); private Consumer messageListener; private InputStream in; private OutputStream out; private Socket socket; // NOSONAR private Thread reader; private volatile boolean stopped = true; private volatile boolean browserReady = false; private Object browserReadyLock = new Object(); private Map> responseFutures = new HashMap<>(); private Runnable onReadyCallback; public static ExternalBrowser getInstance() { return INSTANCE; } public void run(JSONObject jsonConfig, Consumer messageListener) throws InterruptedException, IOException { LOG.debug("Running browser with config {}", jsonConfig); lock.lock(); try { stopped = false; this.messageListener = messageListener; addProxyConfig(jsonConfig.getJSONObject("config")); var configDir = new File(Config.getInstance().getConfigDir(), "ctbrec-minimal-browser"); String[] cmdline = OS.getBrowserCommand(configDir.getCanonicalPath()); Process p = new ProcessBuilder(cmdline).start(); new Thread(new ProcessOutputLogger(p.getInputStream(), "ExternalBrowser stdout")).start(); new Thread(new ProcessOutputLogger(p.getErrorStream(), "ExternalBrowser stderr")).start(); LOG.debug("Browser started: {}", Arrays.toString(cmdline)); connectToRemoteControlSocket(); while (!browserReady) { synchronized (browserReadyLock) { browserReadyLock.wait(100); } } if(LOG.isTraceEnabled()) { LOG.debug("Connected to remote control server. Sending config {}", jsonConfig); } else { LOG.debug("Connected to remote control server. Sending config"); } out.write(jsonConfig.toString().getBytes(UTF_8)); out.write('\n'); out.flush(); Optional.ofNullable(onReadyCallback).ifPresent(Runnable::run); LOG.debug("Waiting for browser to terminate"); p.waitFor(); int exitValue = p.exitValue(); reader = null; in = null; out = null; this.messageListener = null; LOG.debug("Browser Process terminated with {}", exitValue); } finally { lock.unlock(); } } private void connectToRemoteControlSocket() throws IOException { for (var i = 0; i < 20; i++) { try { socket = new Socket("localhost", 3202); in = socket.getInputStream(); out = socket.getOutputStream(); reader = new Thread(this::readBrowserOutput); reader.start(); LOG.debug("Connected to control socket"); return; } catch (IOException e) { if(i == 19) { LOG.error("Connection to remote control socket failed", e); throw e; } } // wait a bit, so that the remote server socket can be opened by electron try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } } public CompletableFuture executeJavaScript(String javaScript) throws IOException { String id = UUID.randomUUID().toString(); var future = new CompletableFuture(); var script = new JSONObject(); script.put("msgid", id); script.put("execute", javaScript); if (out != null) { out.write(script.toString().getBytes(UTF_8)); out.write('\n'); out.flush(); responseFutures.put(id, future); } if (javaScript.equals("quit")) { stopped = true; } return future; } @Override public void close() throws IOException { if(stopped) { return; } stopped = true; executeJavaScript("quit"); } private void readBrowserOutput() { LOG.debug("Browser output reader started"); try (var br = new BufferedReader(new InputStreamReader(in))) { String line; synchronized (browserReadyLock) { browserReady = true; browserReadyLock.notifyAll(); } while( !Thread.interrupted() && (line = br.readLine()) != null ) { LOG.debug("Browser output: {}", line); if (line.startsWith("{")) { JSONObject json = new JSONObject(line); if (json.has("msgid")) { handleExecuteScriptResponse(json); } else { messageListener.accept(line); } } } } catch (IOException e) { if(!stopped) { LOG.error("Couldn't read browser output", e); } } finally { stopped = true; synchronized (browserReadyLock) { browserReady = true; browserReadyLock.notifyAll(); } } } private void handleExecuteScriptResponse(JSONObject json) { var msgid = json.getString("msgid"); LOG.debug("Future {}", msgid); CompletableFuture future = responseFutures.get(msgid); if (future != null) { responseFutures.remove(msgid); if (json.has("result")) { LOG.debug("Future {} done. Result: {}", msgid, json.get("result")); future.complete(json.get("result")); } else if (json.has("error")) { LOG.debug("Future {} failed", msgid); future.completeExceptionally(new Exception(json.getJSONObject("error").toString())); } } else { LOG.warn("No future for previous request {}", msgid); } } private void addProxyConfig(JSONObject jsonConfig) { var proxyType = Config.getInstance().getSettings().proxyType; switch (proxyType) { case HTTP: var proxy = new JSONObject(); proxy.put("address", "http=" + Config.getInstance().getSettings().proxyHost + ':' + Config.getInstance().getSettings().proxyPort + ";https=" + Config.getInstance().getSettings().proxyHost + ':' + Config.getInstance().getSettings().proxyPort); if(Config.getInstance().getSettings().proxyUser != null && !Config.getInstance().getSettings().proxyUser.isEmpty()) { String username = Config.getInstance().getSettings().proxyUser; String password = Config.getInstance().getSettings().proxyPassword; proxy.put("user", username); proxy.put("password", password); } jsonConfig.put("proxy", proxy); break; case SOCKS4: proxy = new JSONObject(); proxy.put("address", "socks4://" + Config.getInstance().getSettings().proxyHost + ':' + Config.getInstance().getSettings().proxyPort); jsonConfig.put("proxy", proxy); break; case SOCKS5: proxy = new JSONObject(); proxy.put("address", "socks5://" + Config.getInstance().getSettings().proxyHost + ':' + Config.getInstance().getSettings().proxyPort); if(Config.getInstance().getSettings().proxyUser != null && !Config.getInstance().getSettings().proxyUser.isEmpty()) { String username = Config.getInstance().getSettings().proxyUser; String password = Config.getInstance().getSettings().proxyPassword; proxy.put("user", username); proxy.put("password", password); } jsonConfig.put("proxy", proxy); break; case DIRECT: default: // nothing to do here break; } } public ExternalBrowser onReady(Runnable onReadyCallback) { this.onReadyCallback = onReadyCallback; return this; } }