blob: d4336444458b43ca51f84971c150c2f9ef2dc9b1 [file] [log] [blame]
/**
* 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>
* &lt;dependency&gt;
* &lt;groupId&gt;org.netbeans.html.browser&lt;/groupId&gt;
* &lt;artifactId&gt;browser&lt;/artifactId&gt;
* &lt;version&gt;<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>&lt;/version&gt;
* &lt;/dependency&gt;
* </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
}