| /** |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF licenses this file |
| * to you under the Apache License, Version 2.0 (the |
| * "License"); you may not use this file except in compliance |
| * with the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, |
| * software distributed under the License is distributed on an |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| * KIND, either express or implied. See the License for the |
| * specific language governing permissions and limitations |
| * under the License. |
| */ |
| package org.netbeans.html.presenters.browser; |
| |
| import org.netbeans.html.presenters.render.Show; |
| import java.io.Closeable; |
| import java.io.File; |
| import java.io.FileNotFoundException; |
| import java.io.Flushable; |
| import java.io.IOException; |
| import java.io.InputStream; |
| import java.io.InputStreamReader; |
| import java.io.OutputStream; |
| import java.io.Reader; |
| import java.io.Writer; |
| import java.net.URI; |
| import java.net.URISyntaxException; |
| import java.net.URL; |
| import java.net.URLConnection; |
| import java.net.URLDecoder; |
| import java.nio.file.Files; |
| import java.util.ArrayList; |
| import java.util.HashMap; |
| import java.util.LinkedList; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Queue; |
| import java.util.UUID; |
| import java.util.concurrent.Executor; |
| import java.util.concurrent.Executors; |
| import java.util.concurrent.ThreadFactory; |
| import java.util.function.Supplier; |
| import java.util.logging.Level; |
| import java.util.logging.Logger; |
| import org.netbeans.html.boot.spi.Fn; |
| import org.netbeans.html.boot.spi.Fn.Presenter; |
| import org.netbeans.html.presenters.spi.ProtoPresenter; |
| import org.netbeans.html.presenters.spi.ProtoPresenterBuilder; |
| import org.openide.util.lookup.ServiceProvider; |
| |
| /** Browser based {@link Presenter}. It starts local server and |
| * launches browser that connects to it. Use {@link Browser.Config} to |
| * configure the actual browser to be started. |
| * <p> |
| * To use this presenter specify following dependency: |
| * <pre> |
| * <dependency> |
| * <groupId>org.netbeans.html.browser</groupId> |
| * <artifactId>browser</artifactId> |
| * <version><a target="blank" href="http://search.maven.org/#search%7Cgav%7C1%7Cg%3A%22com.dukescript.presenters%22%20AND%20a%3A%22browser%22">1.x</a></version> |
| * </dependency> |
| * </pre> |
| */ |
| @ServiceProvider(service = Presenter.class) |
| public final class Browser implements Fn.Presenter, Fn.KeepAlive, Flushable, |
| Executor, Closeable { |
| static final Logger LOG = Logger.getLogger(Browser.class.getName()); |
| private final Map<String,Command> SESSIONS = new HashMap<>(); |
| private final String app; |
| private HttpServer server; |
| private Runnable onPageLoad; |
| private Command current; |
| private final Config config; |
| private final Supplier<HttpServer<?, ?, ?, ?>> serverProvider; |
| |
| /** Default constructor. Reads configuration from properties. The actual browser to |
| * be launched can be influenced by value of |
| * <code>com.dukescript.presenters.browser</code> property. |
| * It can have following values: |
| * <ul> |
| * <li><b>GTK</b> - use Gtk WebKit implementation. Requires presence of |
| * appropriate native libraries</li> |
| * <li><b>AWT</b> - use {@link java.awt.Desktop#browse(java.net.URI)} to |
| * launch a browser</li> |
| * <li><b>NONE</b> - just launches the server, useful together with |
| * <code>com.dukescript.presenters.browserPort</code> property that |
| * can specify a fixed port to open the server at |
| * </li> |
| * <li>any other value is interpreted as a command which is then |
| * launched on a command line with one parameter - the URL to connect to</li> |
| * </ul> |
| * If the property is not specified the system tries <b>GTK</b> mode first, |
| * followed by <b>AWT</b> and then tries to execute <code>xdg-open</code> |
| * (default LINUX command to launch a browser from a shell script). |
| */ |
| public Browser() { |
| this(new Config()); |
| } |
| |
| /** |
| * Browser configured by provided config. |
| * |
| * @param config the configuration |
| */ |
| public Browser(Config config) { |
| this(findCalleeClassName(), config, null); |
| } |
| |
| Browser(String app, Config config, Supplier<HttpServer<?,?,?, ?>> serverProvider) { |
| this.serverProvider = serverProvider != null ? serverProvider : GrizzlyServer::new; |
| this.app = app; |
| this.config = new Config(config); |
| } |
| |
| @Override |
| public final void execute(final Runnable r) { |
| current.execute(r); |
| } |
| |
| @Override |
| public void close() throws IOException { |
| if (server != null) { |
| server.shutdownNow(); |
| } |
| } |
| |
| HttpServer server() { |
| return server; |
| } |
| |
| static HttpServer findServer(Object obj) { |
| Command c; |
| if (obj instanceof Command) { |
| c = (Command) obj; |
| } else if (obj instanceof ProtoPresenter) { |
| c = ((ProtoPresenter) obj).lookup(Command.class); |
| } else { |
| throw new IllegalArgumentException("Cannot find server for " + obj); |
| } |
| return c.browser.server(); |
| } |
| |
| /** Shows URL in a browser. |
| * @param page the page to display in the browser |
| * @throws IOException if something goes wrong |
| */ |
| void show(URI page) throws IOException { |
| String impl = config.getBrowser(); |
| if ("none".equalsIgnoreCase(impl)) { // NOI18N |
| return; |
| } |
| if (impl != null) { |
| Show.show(impl, page); |
| } else { |
| IOException one, two; |
| try { |
| String ui = System.getProperty("os.name").contains("Mac") ? |
| "Cocoa" : "GTK"; |
| Show.show(ui, page); |
| return; |
| } catch (IOException ex) { |
| one = ex; |
| } |
| try { |
| Show.show("AWT", page); |
| return; |
| } catch (IOException ex) { |
| two = ex; |
| } |
| try { |
| Show.show(impl, page); |
| } catch (IOException ex) { |
| two.initCause(one); |
| ex.initCause(two); |
| throw ex; |
| } |
| } |
| } |
| |
| @Override |
| public Fn defineFn(String string, String... strings) { |
| throw new UnsupportedOperationException(); |
| } |
| |
| @Override |
| public void loadScript(Reader reader) throws Exception { |
| throw new UnsupportedOperationException(); |
| } |
| |
| @Override |
| public Fn defineFn(String string, String[] strings, boolean[] blns) { |
| throw new UnsupportedOperationException(); |
| } |
| |
| @Override |
| public void flush() throws IOException { |
| throw new UnsupportedOperationException(); |
| } |
| |
| private static URI pageURL(String protocol, HttpServer server, final String page) { |
| int port = server.getPort(); |
| try { |
| return new URI(protocol + "://localhost:" + port + page); |
| } catch (URISyntaxException ex) { |
| throw new IllegalStateException(ex); |
| } |
| } |
| |
| @Override |
| public final void displayPage(URL page, Runnable onPageLoad) { |
| try { |
| this.onPageLoad = onPageLoad; |
| this.server = serverProvider.get(); |
| int from = 8080; |
| int to = 65535; |
| int port = config.getPort(); |
| if (port != -1) { |
| from = to = port; |
| } |
| server.init(from, to); |
| |
| this.server.addHttpHandler(new RootPage(page), "/"); |
| server.start(); |
| |
| show(pageURL("http", server, "/")); |
| } catch (IOException ex) { |
| Logger.getLogger(Browser.class.getName()).log(Level.SEVERE, null, ex); |
| } |
| } |
| |
| /** Parameters to configure {@link Browser}. |
| * Create an instance and pass it |
| * to {@link Browser#Browser(org.netbeans.html.presenters.browser.Browser.Config) } |
| * constructor. |
| */ |
| public final static class Config { |
| String browser; |
| Integer port; |
| boolean debug; |
| |
| /** |
| * Default constructor. |
| */ |
| public Config() { |
| } |
| |
| private Config(Config copy) { |
| this.browser = copy.browser; |
| this.port = copy.port; |
| this.debug = copy.debug; |
| } |
| |
| /** The command to use when invoking a browser. Possible values: |
| * <ul> |
| * <li> |
| * <b>GTK</b> - use Gtk WebKit implementation. Requires presence of appropriate native libraries |
| * </li> |
| * <li> |
| * <b>AWT</b> - use Desktop.browse(java.net.URI) to launch a browser |
| * </li> |
| * <li> |
| * <b>NONE</b> - just launches the server, useful together with {@link #port(int)} to specify a fixed port to open the server at |
| * </li> |
| * <li> |
| * any other value is interpreted as a command which is then launched on a command line with one parameter - the URL to connect to |
| * </li> |
| * </ul> |
| * |
| * @param executable browser to execute |
| * @return this instance |
| */ |
| public Config command(String executable) { |
| this.browser = executable; |
| return this; |
| } |
| |
| /** The port to start the server at. |
| * By default a random port is selected. |
| * @param port the port |
| * @return this instance |
| */ |
| public Config port(int port) { |
| this.port = port; |
| return this; |
| } |
| |
| /** Enable or disable debugging. The default value is taken from a property |
| * {@code com.dukescript.presenters.browserDebug}. If the property is |
| * not specified, then the default value is {@code false}. |
| * |
| * @param debug true or false |
| * @return this instance |
| * @since 1.8 |
| */ |
| Config debug(boolean debug) { |
| this.debug = debug; |
| return this; |
| } |
| |
| final String getBrowser() { |
| if (browser != null) { |
| return browser; |
| } |
| return System.getProperty("com.dukescript.presenters.browser"); // NOI18N |
| } |
| |
| final int getPort() { |
| if (port != null) { |
| return port; |
| } |
| String browserPort = System.getProperty("com.dukescript.presenters.browserPort"); // NOI18N |
| try { |
| return Integer.parseInt(browserPort); |
| } catch (NumberFormatException ex) { |
| return -1; |
| } |
| } |
| } |
| |
| static <Response> void cors(HttpServer<?, Response, ?, ?> s, Response r) { |
| s.setCharacterEncoding(r, "UTF-8"); |
| s.addHeader(r, "Access-Control-Allow-Origin", "*"); |
| s.addHeader(r, "Access-Control-Allow-Credentials", "true"); |
| s.addHeader(r, "Access-Control-Allow-Headers", "Content-Type"); |
| s.addHeader(r, "Access-Control-Allow-Methods", "GET, POST, DELETE, PUT"); |
| } |
| |
| private final class RootPage extends HttpServer.Handler { |
| private final URL page; |
| |
| public RootPage(URL page) { |
| this.page = page; |
| } |
| |
| @Override |
| public <Request, Response> void service(HttpServer<Request, Response, ?, ?> server, Request rqst, Response rspns) throws IOException { |
| String path = server.getRequestURI(rqst); |
| cors(server, rspns); |
| if ("/".equals(path) || "index.html".equals(path)) { |
| Reader is; |
| String prefix = "http://" + server.getServerName(rqst) + ":" + server.getServerPort(rqst) + "/"; |
| Writer w = server.getWriter(rspns); |
| server.setContentType(rspns, "text/html"); |
| final Command cmd = new Command(server, Browser.this, prefix); |
| try { |
| is = new InputStreamReader(page.openStream()); |
| } catch (IOException ex) { |
| w.write("<html><body>"); |
| w.write("<h1>Browser</h1>"); |
| w.write("<pre id='cmd'></pre>"); |
| emitScript(w, prefix, cmd.id); |
| w.write("</body></html>"); |
| w.close(); |
| return; |
| } |
| SESSIONS.put(cmd.id, cmd); |
| int state = 0; |
| for (;;) { |
| int ch = is.read(); |
| if (ch == -1) { |
| break; |
| } |
| char lower = Character.toLowerCase((char)ch); |
| switch (state) { |
| case 1000: break; |
| case 0: if (lower == '<') state = 1; break; |
| case 1: if (lower == 'b') state = 2; |
| else if (lower != ' ' && lower != '\n') state = 0; |
| break; |
| case 2: if (lower == 'o') state = 3; else state = 0; break; |
| case 3: if (lower == 'd') state = 4; else state = 0; break; |
| case 4: if (lower == 'y') state = 5; else state = 0; break; |
| case 5: if (lower == '>') state = 500; |
| else if (lower != ' ' && lower != '\n') state = 0; |
| break; |
| } |
| w.write((char)ch); |
| if (state == 500) { |
| emitScript(w, prefix, cmd.id); |
| state = 1000; |
| } |
| } |
| if (state != 1000) { |
| emitScript(w, prefix, cmd.id); |
| } |
| is.close(); |
| w.close(); |
| } else if (path.equals("/command.js")) { |
| String id = server.getParameter(rqst, "id"); |
| Command c = SESSIONS.get(id); |
| if (c == null) { |
| server.getWriter(rspns).write("No command for " + id); |
| server.setStatus(rspns, 404); |
| return; |
| } |
| c.service(rqst, rspns); |
| } else { |
| if (path.startsWith("/")) { |
| path = path.substring(1); |
| } |
| URL relative = new URL(page, path); |
| InputStream is; |
| URLConnection conn; |
| try { |
| conn = relative.openConnection(); |
| is = conn.getInputStream(); |
| } catch (FileNotFoundException ex) { |
| server.setStatus(rspns, 404); |
| return; |
| } |
| String found = null; |
| if (relative.getProtocol().equals("file")) { |
| try { |
| File file = new File(relative.toURI()); |
| found = Files.probeContentType(file.toPath()); |
| } catch (URISyntaxException | IOException ignore) { |
| } |
| } else { |
| found = conn.getContentType(); |
| } |
| if (found == null || "content/unknown".equals(found)) { |
| if (path.endsWith(".html")) { |
| found = "text/html"; |
| } |
| if (path.endsWith(".js")) { |
| found = "text/javascript"; |
| } |
| if (path.endsWith(".css")) { |
| found = "text/css"; |
| } |
| } |
| if (found != null) { |
| server.setContentType(rspns, found); |
| } |
| OutputStream out = server.getOutputStream(rspns); |
| for (;;) { |
| int b = is.read(); |
| if (b == -1) { |
| break; |
| } |
| out.write(b); |
| } |
| out.close(); |
| is.close(); |
| } |
| } |
| |
| private void emitScript(Writer w, String prefix, String id) throws IOException { |
| w.write(" <script id='exec' type='text/javascript'>"); |
| w.write("\n" |
| + "function waitForCommand(counter) {\n" |
| + " try {\n" |
| + " if (waitForCommand.seenError) {\n" |
| + " console.warn('Disconnected from " + prefix + "');\n" |
| + " return;\n" |
| + " };\n" |
| + " var request = new XMLHttpRequest();\n"); |
| if (Browser.this.config.debug) { |
| w.write("" |
| + " console.log('GET[' + counter + ']....');\n" |
| ); |
| } |
| w.write("" |
| + " request.open('GET', '" + prefix + "command.js?id=" + id + "', true);\n" |
| + " request.setRequestHeader('Content-Type', 'text/plain; charset=utf-8');\n" |
| + " request.onerror = function(ev) {\n" |
| + " console.warn(ev);\n" |
| + " waitForCommand.seenError = true;\n" |
| + " };\n" |
| + " request.onreadystatechange = function() {\n" |
| + " if (this.readyState!==4) return;\n" |
| + " try {\n" |
| ); |
| if (Browser.this.config.debug) { |
| w.write("" |
| + " console.log('...GET[' + counter + '] got something ' + this.responseText.substring(0,80));\n" |
| + " var cmd = document.getElementById('cmd');\n" |
| + " if (cmd) cmd.innerHTML = this.responseText.substring(0,80);\n" |
| ); |
| } |
| w.write("" |
| + " (0 || eval)(this.responseText);\n" |
| + " } catch (e) {\n" |
| + " console.warn(e); \n" |
| + " } finally {\n" |
| + " waitForCommand(counter + 1);\n" |
| + " }\n" |
| + " };\n" |
| + " request.send();\n" |
| + " } catch (e) {\n" |
| + " console.warn(e);\n" |
| + " waitForCommand(counter + 1);\n" |
| + " }\n" |
| + "}\n" |
| + "waitForCommand(1);\n" |
| ); |
| w.write(" </script>\n"); |
| } |
| } |
| |
| String createCallbackFn(String prefix, String id) { |
| StringBuilder sb = new StringBuilder(); |
| sb.append("this.toBrwsrSrvr = function(name, a1, a2, a3, a4) {\n" |
| + "var url = '").append(prefix).append("command.js?id=").append(id).append("&name=' + name;\n" |
| + "var body = 'p0=' + encodeURIComponent(a1);\n" |
| + "body += '&p1=' + encodeURIComponent(a2);\n" |
| + "body += '&p2=' + encodeURIComponent(a3);\n" |
| + "body += '&p3=' + encodeURIComponent(a4);\n" |
| + "var request = new XMLHttpRequest();\n" |
| ); |
| if (Browser.this.config.debug) { |
| sb.append("" |
| + "console.log('PUT ... ' + body.substring(0, 80));\n" |
| + "var now = new Date().getTime();\n" |
| ); |
| } |
| sb.append("" |
| + "request.open('PUT', url, false);\n" |
| + "request.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=utf-8');\n" |
| + "request.send(body);\n" |
| + "var txt = request.responseText;\n" |
| ); |
| if (Browser.this.config.debug) { |
| sb.append("" |
| + "var then = new Date().getTime();\n" |
| + "if (txt && txt !== 'null') {\n" |
| + " var cmd = document.getElementById('cmd');\n" |
| + " if (cmd) cmd.innerHTML = txt.substring(0,80);\n" |
| + "}\n" |
| + "console.log('... PUT [' + (then - now) + 'ms]: ' + txt.substring(0, 80));\n" |
| ); |
| } |
| sb.append("" |
| + "return txt;\n" |
| + "};\n" |
| ); |
| return sb.toString(); |
| } |
| |
| private static String findCalleeClassName() { |
| StackTraceElement[] frames = new Exception().getStackTrace(); |
| for (StackTraceElement e : frames) { |
| String cn = e.getClassName(); |
| if (cn.startsWith("com.dukescript.presenters.")) { // NOI18N |
| continue; |
| } |
| if (cn.startsWith("org.netbeans.html.")) { // NOI18N |
| continue; |
| } |
| if (cn.startsWith("net.java.html.")) { // NOI18N |
| continue; |
| } |
| if (cn.startsWith("java.")) { // NOI18N |
| continue; |
| } |
| if (cn.startsWith("javafx.")) { // NOI18N |
| continue; |
| } |
| if (cn.startsWith("com.sun.")) { // NOI18N |
| continue; |
| } |
| return cn; |
| } |
| return "org.netbeans.html"; // NOI18N |
| } |
| |
| private static final class Command<Request, Response, Runner> extends Object |
| implements Executor { |
| private final HttpServer<Request, Response, ?, Runner> server; |
| private final Queue<Object> exec; |
| private final Browser browser; |
| private final String id; |
| private final String prefix; |
| private Runner RUNNER; |
| private Response suspended; |
| private boolean initialized; |
| private final ProtoPresenter presenter; |
| |
| Command(HttpServer<Request, Response, ?, Runner> s, Browser browser, String prefix) { |
| this.server = s; |
| this.id = UUID.randomUUID().toString(); |
| this.RUNNER = s.initializeRunner(this.id); |
| this.exec = new LinkedList<>(); |
| this.prefix = prefix; |
| this.browser = browser; |
| this.presenter = ProtoPresenterBuilder.newBuilder(). |
| preparator(this::callbackFn, true). |
| loadJavaScript(this::loadJS, false). |
| app(browser.app). |
| dispatcher(this, true). |
| displayer(this::displayPage). |
| logger(this::log). |
| type("Browser"). |
| register(this). |
| build(); |
| } |
| |
| @Override |
| public final void execute(final Runnable r) { |
| server.runSafe(this.RUNNER, r, this.presenter); |
| } |
| |
| final synchronized void add(Object obj) { |
| if (suspended != null) { |
| Response rqst = suspended; |
| server.resume(rqst, () -> { |
| try (Writer w = server.getWriter(rqst)) { |
| w.write(obj.toString()); |
| } catch (IOException ex) { |
| LOG.log(Level.SEVERE, null, ex); |
| } |
| }); |
| suspended = null; |
| return; |
| } |
| exec.add(obj); |
| } |
| |
| private synchronized Object take(Response rspns) { |
| Object o = exec.poll(); |
| if (o != null) { |
| return o; |
| } |
| suspended = rspns; |
| server.suspend(rspns); |
| return null; |
| } |
| |
| private synchronized boolean initialize(Response rspns) { |
| if (!initialized) { |
| initialized = true; |
| suspended = rspns; |
| server.suspend(rspns); |
| execute(browser.onPageLoad); |
| return true; |
| } |
| return false; |
| } |
| |
| void service(Request rqst, Response rspns) throws IOException { |
| final String methodName = server.getParameter(rqst, "name"); |
| server.setContentType(rspns, "text/javascript"); |
| Writer w = server.getWriter(rspns); |
| if (methodName == null) { |
| if (initialize(rspns)) { |
| return; |
| } |
| // send new request |
| Object obj = take(rspns); |
| if (obj == null) { |
| LOG.log(Level.FINE, "Suspending response {0}", rspns); |
| return; |
| } |
| final String s = obj.toString(); |
| w.write(s); |
| LOG.log(Level.FINE, "Exec global: {0}", s); |
| } else { |
| List<String> args = new ArrayList<>(); |
| String body = server.getBody(rqst); |
| for (String p : body.split("&")) { |
| args.add(URLDecoder.decode(p.substring(3), "UTF-8")); |
| } |
| String res; |
| try { |
| LOG.log(Level.FINE, "Call {0}", methodName + " with " + args); |
| res = presenter.js2java(methodName, |
| args.get(0), args.get(1), args.get(2), args.get(3) |
| ); |
| LOG.log(Level.FINE, "Result: {0}", res); |
| } catch (Exception ex) { |
| res = "error:" + ex.getMessage(); |
| } |
| if (res != null) { |
| w.write(res); |
| } else { |
| w.write("null"); |
| } |
| } |
| w.close(); |
| } |
| |
| void callbackFn(ProtoPresenterBuilder.OnPrepared onReady) { |
| String sb = this.browser.createCallbackFn(prefix, id); |
| add(sb); |
| onReady.callbackIsPrepared("toBrwsrSrvr"); |
| } |
| |
| private static Level findLevel(int priority) { |
| if (priority >= Level.SEVERE.intValue()) { |
| return Level.SEVERE; |
| } |
| if (priority >= Level.WARNING.intValue()) { |
| return Level.WARNING; |
| } |
| if (priority >= Level.INFO.intValue()) { |
| return Level.INFO; |
| } |
| return Level.FINE; |
| } |
| |
| void log(int priority, String msg, Object... args) { |
| Level level = findLevel(priority); |
| |
| if (args.length == 1 && args[0] instanceof Throwable) { |
| LOG.log(level, msg, (Throwable) args[0]); |
| } else { |
| LOG.log(level, msg, args); |
| } |
| } |
| |
| final void loadJS(String js) { |
| add(js); |
| } |
| |
| void dispatch(Runnable r) { |
| server.runSafe(RUNNER, r, null); |
| } |
| |
| |
| public void displayPage(URL url, Runnable r) { |
| throw new UnsupportedOperationException(url.toString()); |
| } |
| } // end of Command |
| } |