JavaFX stop opening URL in WebView - open in brows

2020-02-04 06:04发布

问题:

The embedded WebView browser I am using needs special handling for particular URLs, to open them in the native default browser instead of WebView. The actual browsing part works fine but I need to stop the WebView from displaying that page as well. I can think of several ways to do it but none of them work. Here is my code:

this.wv.getEngine().locationProperty().addListener(new ChangeListener<String>() {
    @Override
    public void changed(ObservableValue<? extends String> observable, String oldValue, String newValue)
    {
        Desktop d = Desktop.getDesktop();
        try
        {
            URI address = new URI(observable.getValue());
            if ((address.getQuery() + "").indexOf("_openmodal=true") > -1)
            {
                // wv.getEngine().load(oldValue); // 1
                // wv.getEngine().getLoadWorker().cancel(); // 2
                // wv.getEngine().executeScript("history.back()"); // 3
                d.browse(address);
            }
        }
        catch (IOException | URISyntaxException e)
        {
            displayError(e);
        }
    }
});

A bit more info about what happens in each of three cases

1. Loading the previous address

wv.getEngine().load(oldValue);

This kills the JVM. Funnily enough, the page opens fine in the native browser.

# A fatal error has been detected by the Java Runtime Environment:
#
#  EXCEPTION_ACCESS_VIOLATION (0xc0000005) at pc=0x000000005b8fef38, pid=7440, tid=8000
#
# JRE version: 7.0_09-b05
# Java VM: Java HotSpot(TM) 64-Bit Server VM (23.5-b02 mixed mode windows-amd64 compressed oops)
# Problematic frame:
# C  [jfxwebkit.dll+0x2fef38]  Java_com_sun_webpane_platform_BackForwardList_bflItemGetIcon+0x184f58
#
# Failed to write core dump. Minidumps are not enabled by default on client versions of Windows
#
# An error report file with more information is saved as:
# C:\Users\Greg Balaga\eclipse\Companyapp\hs_err_pid7440.log
#
# If you would like to submit a bug report, please visit:
#   http://bugreport.sun.com/bugreport/crash.jsp
# The crash happened outside the Java Virtual Machine in native code.
# See problematic frame for where to report the bug.

2. Cancelling the worker

wv.getEngine().getLoadWorker().cancel();

Does nothing, the page loads in both the WebView and native browser.

3. Using history.back()

wv.getEngine().executeScript("history.back()");

Same as above, no effect.

4. Reacting to Stage changes instead

I have also tried to instead of looking the locationProperty of WebEngine, listen on chenges for stateProperty of the Worker and fire the same opening code if newState == State.SCHEDULED. There was no difference in result from previous method (apart from not actually being able to use #1).


Update

The code I'm using now still crashes the JVM:

this.wv.getEngine().locationProperty().addListener(new ChangeListener<String>() {
    @Override
    public void changed(ObservableValue<? extends String> observable, final String oldValue, String newValue)
    {
        Desktop d = Desktop.getDesktop();
        try
        {
            URI address = new URI(newValue);
            if ((address.getQuery() + "").indexOf("_openmodal=true") > -1)
            {
                Platform.runLater(new Runnable() {
                    @Override
                    public void run()
                    {
                        wv.getEngine().load(oldValue);
                    }
                });
                d.browse(address);
            }
        }
        catch (IOException | URISyntaxException e)
        {
            displayError(e);
        }
    }
});

Workaround

Ok I managed to make it work by tearing down the webview and rebuilding it.

this.wv.getEngine().locationProperty().addListener(new ChangeListener<String>() {
    @Override
    public void changed(ObservableValue<? extends String> observable, final String oldValue, String newValue)
    {
        Desktop d = Desktop.getDesktop();
        try
        {
            URI address = new URI(newValue);
            if ((address.getQuery() + "").indexOf("_openmodal=true") > -1)
            {
                Platform.runLater(new Runnable() {
                    @Override
                    public void run()
                    {
                        grid_layout.getChildren().remove(wv);
                        wv = new WebView();
                        grid_layout.add(wv, 0, 1);
                        wv.getEngine().load(oldValue);
                    }
                });
                d.browse(address);
            }
        }
        catch (IOException | URISyntaxException e)
        {
            displayError(e);
        }
    }
});

回答1:

There is another method for handling this.

You can add an event listener to the DOM elements and intercept it that way.

Example:

NodeList nodeList = document.getElementsByTagName("a");
            for (int i = 0; i < nodeList.getLength(); i++)
            {
                Node node= nodeList.item(i);
                EventTarget eventTarget = (EventTarget) node;
                eventTarget.addEventListener("click", new EventListener()
                {
                    @Override
                    public void handleEvent(Event evt)
                    {
                        EventTarget target = evt.getCurrentTarget();
                        HTMLAnchorElement anchorElement = (HTMLAnchorElement) target;
                        String href = anchorElement.getHref();
                        //handle opening URL outside JavaFX WebView
                        System.out.println(href);
                        evt.preventDefault();
                    }
                }, false);
            }

Where document is the DOM document object. Make sure this is done after the document has finished loading.



回答2:

I finally found a working solution that worked for me:

import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.concurrent.Worker;
import javafx.scene.web.WebView;
import org.w3c.dom.Document;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.w3c.dom.events.Event;
import org.w3c.dom.events.EventListener;
import org.w3c.dom.events.EventTarget;
import org.w3c.dom.html.HTMLAnchorElement;

import java.awt.*;
import java.net.URI;

public class HyperLinkRedirectListener implements ChangeListener<Worker.State>, EventListener
{
    private static final String CLICK_EVENT = "click";
    private static final String ANCHOR_TAG = "a";

    private final WebView webView;

    public HyperLinkRedirectListener(WebView webView)
    {
        this.webView = webView;
    }

    @Override
    public void changed(ObservableValue<? extends Worker.State> observable, Worker.State oldValue, Worker.State newValue)
    {
        if (Worker.State.SUCCEEDED.equals(newValue))
        {
            Document document = webView.getEngine().getDocument();
            NodeList anchors = document.getElementsByTagName(ANCHOR_TAG);
            for (int i = 0; i < anchors.getLength(); i++)
            {
                Node node = anchors.item(i);
                EventTarget eventTarget = (EventTarget) node;
                eventTarget.addEventListener(CLICK_EVENT, this, false);
            }
        }
    }

    @Override
    public void handleEvent(Event event)
    {
        HTMLAnchorElement anchorElement = (HTMLAnchorElement) event.getCurrentTarget();
        String href = anchorElement.getHref();

        if (Desktop.isDesktopSupported())
        {
            openLinkInSystemBrowser(href);
        } else
        {
            // LOGGER.warn("OS does not support desktop operations like browsing. Cannot open link '{}'.", href);
        }

        event.preventDefault();
    }

    private void openLinkInSystemBrowser(String url)
    {
        // LOGGER.debug("Opening link '{}' in default system browser.", url);

        try
        {
            URI uri = new URI(url);
            Desktop.getDesktop().browse(uri);
        } catch (Throwable e)
        {
            // LOGGER.error("Error on opening link '{}' in system browser.", url);
        }
    }
}

Usage:

webView.getEngine().getLoadWorker().stateProperty().addListener(new HyperLinkRedirectListener(webView));


回答3:

This worked for me as I had to generically trap any anchor with target="_blank". I had to work around the fact that the PopupFeatures callback has absolutely no useful context by asking the DOM for all elements under the pointer (e.g. :hover).

// intercept target=_blank hyperlinks
webView.getEngine().setCreatePopupHandler(
    new Callback<PopupFeatures, WebEngine>() {
        @Override
        public WebEngine call(PopupFeatures config) {
            // grab the last hyperlink that has :hover pseudoclass
            Object o = webView
                    .getEngine()
                    .executeScript(
                            "var list = document.querySelectorAll( ':hover' );"
                                    + "for (i=list.length-1; i>-1; i--) "
                                    + "{ if ( list.item(i).getAttribute('href') ) "
                                    + "{ list.item(i).getAttribute('href'); break; } }");

            // open in native browser
            try {
                if (o != null) {
                    Desktop.getDesktop().browse(
                            new URI(o.toString()));
                } else {
                    log.error("No result from uri detector: " + o);
                }
            } catch (IOException e) {
                log.error("Unexpected error obtaining uri: " + o, e);
            } catch (URISyntaxException e) {
                log.error("Could not interpret uri: " + o, e);
            }

            // prevent from opening in webView
            return null;
        }
    });


回答4:

@Avrom's answer of using DOM interceptors offers a better solution than this answer with regards to the question: "JavaFX stop opening URL in WebView - open in browser instead".

This answer just left for posterity.


Use option 1 engine.load(oldValue) and wrap the load call in Platform.runLater as a workaround to prevent the jvm crash.

import javafx.application.*;
import javafx.beans.value.*;
import javafx.scene.Scene;
import javafx.scene.web.*;
import javafx.stage.Stage;

public class GoogleBlock extends Application {
  public static void main(String[] args) throws Exception { launch(args); }

  @Override public void start(final Stage stage) throws Exception {
    final WebView webView = new WebView();
    final WebEngine engine = webView.getEngine();
    engine.load("http://www.google.com");
    engine.locationProperty().addListener(new ChangeListener<String>() {
      @Override public void changed(ObservableValue<? extends String> ov, final String oldLoc, final String loc) {
        if (!loc.contains("google.com")) {
          Platform.runLater(new Runnable() {
            @Override public void run() {
              engine.load(oldLoc);
            }
          });
        }
      }
    });

    stage.setScene(new Scene(webView));
    stage.show();
  }
}

Update

Although the above solution works OK for me in the supplied GoogleBlock sample application under jdk7u15, win7, Dreen reports that just wrapping the load value in Platform.runLater does not fix crash issues in all cases, so the complete replacement of the WebView object with a new WebView (as Dreen outlines in the updated question), might be the preferred solution here (at least until the underlying bug is fixed).


The jvm crash you note in your question is a known issue in JavaFX 2.2:

JDK-8087652 WebView crashes on calling webEngine.load(url) in a webEngine.locationProperty() ChangeListener.



回答5:

Sorry for digging out this old thread but I found another solution that I wanted to share with others who struggle with the same problem. I found a library that has a nice wrapper around the entire issue, see its docs at github.

Edit: Oh, sry for not telling what the project does: The linked library contains a class that actually implemented all of the code discussed in this thread. The user can simply create a new instance of the WebViewHyperlinkListener-interface that gets automatically called when something (mouse enter, mouse quit, mouse click) happens with the link. Once the handler terminates, it returns a boolean: If the handler returns true, the WebView will not navigate to the linked web page. If the handler returns false, it will.



回答6:

When wrapping the d.browse call into a Runnable Object the runtime error never occured again. The strange thing was without that wrapping the ChangeListener was called a second time after some seconds with the same new location and this second call crashed the JVM.



回答7:

I found another solution:

  1. Register a CreatePopupHandler that returns a different WebEngine than the main one
  2. The main WebEngine sends the load-call to the secondary WebEngine
  3. Register a LocationChangeListener on the secondary WebEngine and catch the location change (including the address) and open it in our external browser
  4. Finally clean up the secondary WebEngine: stop loading & unload the URL

I implemented it so the secondary WebEngine is initialized lazy. It may also be initialized in the constructor. Both has pros and contras.

Note: This only triggers for Links which open as a popup. This usually is the case when an a-element has a target-attribute that is not "_self" or with JS: window.open(...).

Here is the Magic ...

Register it like this:

engine.setCreatePopupHandler(new BrowserPopupHandler());

The core class:

public static class BrowserPopupHandler implements Callback<PopupFeatures, WebEngine>
{

    private WebEngine popupHandlerEngine;

    public WebEngine call(PopupFeatures popupFeatures)
    {
        // by returning null here the action would be canceled
        // by returning a different WebEngine (than the main one where we register our listener) the load-call will go to that one
        // we return a different WebEngine here and register a location change listener on it (see blow)
        return getPopupHandler();
    }

    private WebEngine getPopupHandler()
    {
        if (popupHandlerEngine == null) // lazy init - so we only initialize it when needed ...
        {
            synchronized (this) // double checked synchronization
            {
                if (popupHandlerEngine == null)
                {
                    popupHandlerEngine = initEngine();
                }
            }
        }
        return popupHandlerEngine;
    }

    private WebEngine initEngine()
    {
        final WebEngine popupHandlerEngine = new WebEngine();

        // this change listener will trigger when our secondary popupHandlerEngine starts to load the url ...
        popupHandlerEngine.locationProperty().addListener(new ChangeListener<String>()
        {

            public void changed(ObservableValue<? extends String> observable, String oldValue, String location)
            {
                if (!location.isEmpty())
                {
                    Platform.runLater(new Runnable()
                    {

                        public void run()
                        {
                            popupHandlerEngine.loadContent(""); // stop loading and unload the url
                            // -> does this internally: popupHandlerEngine.getLoadWorker().cancelAndReset();
                        }

                    });

                    try
                    {
                        // Open URL in Browser:
                        Desktop desktop = Desktop.getDesktop();
                        if (desktop.isSupported(Desktop.Action.BROWSE))
                        {
                            URI uri = new URI(location);
                            desktop.browse(uri);
                        }
                        else
                        {
                            System.out.println("Could not load URL: " + location);
                        }
                    }
                    catch (Exception e)
                    {
                        e.printStackTrace();
                    }
                }
            }

        });
        return popupHandlerEngine;
    }

}


回答8:

2017 version - still very hacky but much more concise:

class AboutDialog extends Dialog {
    private final Controller controller;
    private final String url;

    AboutDialog() {
        super();

        this.controller = Controller.getInstance();

        this.setTitle(controller.getProperty("about_title"));
        this.setHeaderText(null);

        this.url = getClass().getResource("/about_dialog.html").toExternalForm();

        this.setWebView();

        this.getDialogPane().getButtonTypes().add(new ButtonType(controller.getProperty("close"), ButtonBar.ButtonData.CANCEL_CLOSE));
        this.getDialogPane().setPrefWidth(600);
    }

    private void setWebView() {
        final WebView webView = new WebView();
        webView.getEngine().load(url);

        webView.getEngine().locationProperty().addListener((observable, oldValue, newValue) -> {
            controller.getMainFxApp().getHostServices().showDocument(newValue);
            Platform.runLater(this::setWebView);
        });

        this.getDialogPane().setContent(webView);
    }
}


回答9:

Your "update" section is actually really close to what I got to work, no workaround needed:

wv.getEngine().locationProperty().addListener(new ChangeListener<String>() {    
            public void changed(ObservableValue<? extends String> observable, String oldValue, String newValue)
            {
                if((address.getQuery() + "").indexOf("_openmodal=true") > -1) {
                    Platform.runLater(new Runnable() {
                        public void run() {
                            try {
                                Desktop.getDesktop().browse(new URI(newValue));
                            } catch (IOException e) { //Decide how to handle:
                                //Can't find default program handler for link.
                            } catch (URISyntaxException e) {
                                //Bad syntax on link.
                            }
                            wv.getEngine().reload();
                        }
                    });
                }
            }

        });

What I like about this method is that it accounts for a URL coming from more than just a hyperlink element as well as links to things like emails or local files.