GUI: added old what's new page (build-in window on startup);

This commit is contained in:
Oleg Agafonov 2024-08-05 00:01:38 +04:00
parent e04306c51f
commit 8f7abe2dc5
12 changed files with 540 additions and 24 deletions

View file

@ -144,6 +144,24 @@
<version>1.17</version>
</dependency>
<!-- svg support END -->
<!-- JavaFX support (build-in browser) START -->
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-controls</artifactId>
<version>11.0.2</version>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-swing</artifactId>
<version>11.0.2</version>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-web</artifactId>
<version>11.0.2</version>
</dependency>
<!-- JavaFX support (build-in browser) END -->
</dependencies>
<!-- to get the reference to local repository with com\googlecode\jspf\jspf-core\0.9.1\ -->

View file

@ -72,6 +72,8 @@ import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.SocketException;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.Charset;
import java.util.*;
import java.util.concurrent.Executors;
@ -109,6 +111,7 @@ public class MageFrame extends javax.swing.JFrame implements MageClient {
private static CallbackClient callbackClient;
private static final Preferences PREFS = Preferences.userNodeForPackage(MageFrame.class);
private final JPanel fakeTopPanel;
private WhatsNewDialog whatsNewDialog; // can be null
private JLabel title;
private Rectangle titleRectangle;
private static final MageVersion VERSION = new MageVersion(MageFrame.class);
@ -306,6 +309,15 @@ public class MageFrame extends javax.swing.JFrame implements MageClient {
errorDialog.setLocation(100, 100);
desktopPane.add(errorDialog, JLayeredPane.MODAL_LAYER);
try {
this.whatsNewDialog = new WhatsNewDialog();
} catch (Throwable e) {
// example: JavaFX is not supported on old MacOS with OpenJDK
// https://bugs.openjdk.java.net/browse/JDK-8202132
LOGGER.error("JavaFX is not supported by your system. What's new page will be disabled.", e);
this.whatsNewDialog = null;
}
PING_SENDER_EXECUTOR.scheduleAtFixedRate(SessionHandler::ping, TablesPanel.PING_SERVER_SECS, TablesPanel.PING_SERVER_SECS, TimeUnit.SECONDS);
updateMemUsageTask = new UpdateMemUsageTask(jMemUsageLabel);
@ -384,6 +396,11 @@ public class MageFrame extends javax.swing.JFrame implements MageClient {
}
setWindowTitle();
});
// run what's new checks (loading in background)
SwingUtilities.invokeLater(() -> {
showWhatsNewDialog(false);
});
}
private void bootstrapSetsAndFormats() {
@ -1861,8 +1878,14 @@ public class MageFrame extends javax.swing.JFrame implements MageClient {
updateTooltipContainerSizes();
}
public static void showWhatsNewDialog() {
AppUtil.openUrlInBrowser("https://jaydi85.github.io/xmage-web-news/news.html");
public void showWhatsNewDialog(boolean forceToShowPage) {
if (whatsNewDialog != null) {
// build-in browser
whatsNewDialog.checkUpdatesAndShow(forceToShowPage);
} else {
// system browser
AppUtil.openUrlInSystemBrowser(WhatsNewDialog.WHATS_NEW_PAGE);
}
}
public boolean isGameFrameActive(UUID gameId) {

View file

@ -147,7 +147,7 @@ public class AboutDialog extends MageDialog {
}//GEN-LAST:event_btnOkActionPerformed
private void btnWhatsNewActionPerformed(java.awt.event.ActionEvent evt) {//GEN-FIRST:event_btnWhatsNewActionPerformed
MageFrame.showWhatsNewDialog();
MageFrame.getInstance().showWhatsNewDialog(true);
}//GEN-LAST:event_btnWhatsNewActionPerformed
// Variables declaration - do not modify//GEN-BEGIN:variables

View file

@ -13,7 +13,6 @@ import mage.utils.StreamUtils;
import org.apache.log4j.Logger;
import javax.swing.*;
import java.awt.*;
import java.awt.event.ActionListener;
import java.io.*;
import java.net.*;
@ -739,11 +738,11 @@ public class ConnectDialog extends MageDialog {
}//GEN-LAST:event_btnFlagSearchActionPerformed
private void btnCheckStatusActionPerformed(java.awt.event.ActionEvent evt) {//GEN-FIRST:event_btnCheckStatusActionPerformed
AppUtil.openUrlInBrowser("http://xmage.today/servers/");
AppUtil.openUrlInSystemBrowser("http://xmage.today/servers/");
}//GEN-LAST:event_btnCheckStatusActionPerformed
private void btnWhatsNewActionPerformed(java.awt.event.ActionEvent evt) {//GEN-FIRST:event_btnWhatsNewActionPerformed
MageFrame.showWhatsNewDialog();
MageFrame.getInstance().showWhatsNewDialog(true);
}//GEN-LAST:event_btnWhatsNewActionPerformed
private void btnFindMainActionPerformed(java.awt.event.ActionEvent evt) {//GEN-FIRST:event_btnFindMainActionPerformed

View file

@ -64,7 +64,7 @@ public class ErrorDialog extends MageDialog {
CardUtil.urlEncode(title),
CardUtil.urlEncode(body)
);
AppUtil.openUrlInBrowser(url);
AppUtil.openUrlInSystemBrowser(url);
}
/** This method is called from within the constructor to

View file

@ -0,0 +1,73 @@
<?xml version="1.0" encoding="UTF-8" ?>
<Form version="1.3" maxVersion="1.9" type="org.netbeans.modules.form.forminfo.JInternalFrameFormInfo">
<SyntheticProperties>
<SyntheticProperty name="formSizePolicy" type="int" value="1"/>
</SyntheticProperties>
<AuxValues>
<AuxValue name="FormSettings_autoResourcing" type="java.lang.Integer" value="0"/>
<AuxValue name="FormSettings_autoSetComponentName" type="java.lang.Boolean" value="false"/>
<AuxValue name="FormSettings_generateFQN" type="java.lang.Boolean" value="true"/>
<AuxValue name="FormSettings_generateMnemonicsCode" type="java.lang.Boolean" value="false"/>
<AuxValue name="FormSettings_i18nAutoMode" type="java.lang.Boolean" value="false"/>
<AuxValue name="FormSettings_layoutCodeTarget" type="java.lang.Integer" value="1"/>
<AuxValue name="FormSettings_listenerGenerationStyle" type="java.lang.Integer" value="0"/>
<AuxValue name="FormSettings_variablesLocal" type="java.lang.Boolean" value="false"/>
<AuxValue name="FormSettings_variablesModifier" type="java.lang.Integer" value="2"/>
</AuxValues>
<Layout>
<DimensionLayout dim="0">
<Group type="103" groupAlignment="0" attributes="0">
<Group type="102" alignment="0" attributes="0">
<EmptySpace max="-2" attributes="0"/>
<Group type="103" groupAlignment="0" attributes="0">
<Component id="panelData" max="32767" attributes="0"/>
<Group type="102" attributes="0">
<Component id="buttonRefresh" min="-2" pref="100" max="-2" attributes="0"/>
<EmptySpace max="32767" attributes="0"/>
<Component id="buttonCancel" min="-2" pref="100" max="-2" attributes="0"/>
</Group>
</Group>
<EmptySpace max="-2" attributes="0"/>
</Group>
</Group>
</DimensionLayout>
<DimensionLayout dim="1">
<Group type="103" groupAlignment="0" attributes="0">
<Group type="102" alignment="0" attributes="0">
<EmptySpace max="-2" attributes="0"/>
<Component id="panelData" max="32767" attributes="0"/>
<EmptySpace max="-2" attributes="0"/>
<Group type="103" groupAlignment="3" attributes="0">
<Component id="buttonCancel" alignment="3" min="-2" pref="30" max="-2" attributes="0"/>
<Component id="buttonRefresh" alignment="3" min="-2" pref="30" max="-2" attributes="0"/>
</Group>
<EmptySpace max="-2" attributes="0"/>
</Group>
</Group>
</DimensionLayout>
</Layout>
<SubComponents>
<Component class="javax.swing.JButton" name="buttonCancel">
<Properties>
<Property name="text" type="java.lang.String" value="Close"/>
</Properties>
<Events>
<EventHandler event="actionPerformed" listener="java.awt.event.ActionListener" parameters="java.awt.event.ActionEvent" handler="buttonCancelActionPerformed"/>
</Events>
</Component>
<Component class="javax.swing.JButton" name="buttonRefresh">
<Properties>
<Property name="text" type="java.lang.String" value="Refresh"/>
</Properties>
<Events>
<EventHandler event="actionPerformed" listener="java.awt.event.ActionListener" parameters="java.awt.event.ActionEvent" handler="buttonRefreshActionPerformed"/>
</Events>
</Component>
<Container class="javax.swing.JPanel" name="panelData">
<Layout class="org.netbeans.modules.form.compat2.layouts.DesignBorderLayout"/>
</Container>
</SubComponents>
</Form>

View file

@ -0,0 +1,411 @@
package mage.client.dialog;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import javafx.application.Platform;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.concurrent.Worker;
import javafx.embed.swing.JFXPanel;
import javafx.scene.Scene;
import javafx.scene.web.WebEngine;
import javafx.scene.web.WebView;
import mage.client.MageFrame;
import mage.client.remote.XmageURLConnection;
import mage.client.util.AppUtil;
import mage.client.util.GUISizeHelper;
import org.apache.log4j.Logger;
import org.w3c.dom.events.EventListener;
import javax.swing.*;
import java.awt.*;
import java.awt.event.KeyEvent;
import java.lang.reflect.Type;
import java.net.*;
import java.util.List;
import java.util.stream.Collectors;
/**
* App GUI: what's new dialog with latest news.
* Uses system browser. Shows on app's start after page ready.
*
* @author JayDi85
*/
public class WhatsNewDialog extends MageDialog {
private static final Logger LOGGER = Logger.getLogger(WhatsNewDialog.class);
// cookies store tester: https://setcookie.net/
public static final String WHATS_NEW_PAGE = "https://jaydi85.github.io/xmage-web-news/news.html";
private static final String WHATS_NEW_VERSION_PAGE = "https://jaydi85.github.io/xmage-web-news/news_version.html"; // increment version=123 to auto-shown for all users
private static final int WHATS_NEW_MAX_LOAD_TIMEOUT_SECS = 20; // timeout for page loading (example: no network)
private static final boolean WHATS_NEW_DEBUG_ENABLE_CONTROLS = false; // default: false, enable it for debug/test
private final JFXPanel fxPanel;
private WebView webView;
private WebEngine engine;
private boolean isPageReady = false;
private SwingWorker<Void, Void> lastWaitingWorker = null;
public WhatsNewDialog() {
initComponents();
this.setDefaultCloseOperation(HIDE_ON_CLOSE);
fxPanel = new JFXPanel();
panelData.add(fxPanel);
webView = null;
engine = null;
createWebView();
}
private void showDialog() {
this.setModal(true);
this.setResizable(true);
getRootPane().setDefaultButton(buttonCancel);
this.setSize(GUISizeHelper.dialogGuiScaleSize(new Dimension(800, 600)));
// windows settings
MageFrame.getDesktop().remove(this);
if (this.isModal()) {
MageFrame.getDesktop().add(this, JLayeredPane.MODAL_LAYER);
} else {
MageFrame.getDesktop().add(this, JLayeredPane.PALETTE_LAYER);
}
this.makeWindowCentered();
// Close on "ESC"
registerKeyboardAction(e -> onCancel(), KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT);
this.setVisible(true);
}
private final SwingWorker<Void, Void> checkUpdatesWorker = new SwingWorker<Void, Void>() {
private String newVersion = "";
@Override
public Void doInBackground() {
// download version
String newsVersion = XmageURLConnection.downloadText(WHATS_NEW_VERSION_PAGE);
if (newsVersion.startsWith("version=")) {
newVersion = newsVersion.substring("version=".length());
}
return null;
}
@Override
public void done() {
SwingUtilities.invokeLater(() -> {
String oldVersion = PreferencesDialog.getCachedValue(PreferencesDialog.KEY_NEWS_PAGE_LAST_VERSION, "1");
boolean isHaveUpdates = newVersion.isEmpty() || !newVersion.equals(oldVersion);
if (isHaveUpdates) {
PreferencesDialog.saveValue(PreferencesDialog.KEY_NEWS_PAGE_LAST_VERSION, newVersion);
startBrowser(WHATS_NEW_PAGE);
startWaitingWorker();
}
});
}
};
private void startWaitingWorker() {
// wait page ready and open it on complete
if (this.lastWaitingWorker != null) {
this.lastWaitingWorker.cancel(true);
}
this.lastWaitingWorker = new SwingWorker<Void, Void>() {
@Override
public Void doInBackground() {
// waiting page loading
int waitedSecs = 0;
while (!isPageReady && waitedSecs <= WHATS_NEW_MAX_LOAD_TIMEOUT_SECS) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
}
waitedSecs++;
}
return null;
}
@Override
public void done() {
if (isPageReady) {
SwingUtilities.invokeLater(() -> {
showDialog();
});
}
}
};
this.lastWaitingWorker.execute();
}
public void checkUpdatesAndShow(boolean forceToShowPage) {
// lazy loading in background
// shows it on page ready or by force
if (!forceToShowPage) {
// checks version -> start loading -> show on isPageReady
checkUpdatesWorker.execute();
return;
}
// direct open
if (isPageReady) {
SwingUtilities.invokeLater(() -> {
showDialog();
});
} else {
checkUpdatesWorker.cancel(true);
startBrowser(WHATS_NEW_PAGE);
startWaitingWorker();
}
}
/**
* Store cookies in preferences
*/
private static class PersistentCookieStore implements CookieStore, Runnable {
private final CookieStore store;
public PersistentCookieStore() {
// improved store with save/load feature
store = new CookieManager().getCookieStore();
// load on startup
loadFromPrefs();
// save on app close
Runtime.getRuntime().addShutdownHook(new Thread(this));
}
private void saveToPrefs() {
// convert cookie to version 1, so it will get full data before save
// example: xxx
List<String> v1Cookies = store.getCookies().stream()
.peek(c -> c.setVersion(1))
.map(HttpCookie::toString)
.collect(Collectors.toList());
PreferencesDialog.saveValue(PreferencesDialog.KEY_NEWS_PAGE_COOKIES, new Gson().toJson(v1Cookies));
}
private void loadFromPrefs() {
Type type = new TypeToken<List<String>>() {
}.getType();
try {
String savedData = PreferencesDialog.getCachedValue(PreferencesDialog.KEY_NEWS_PAGE_COOKIES, "");
List<String> savedCookies = new Gson().fromJson(savedData, type);
if (savedCookies != null) {
savedCookies.forEach(savedCookie -> {
// load as version 1, but convert back to version 0 for compatibility
List<HttpCookie> v1Cookies = HttpCookie.parse("set-cookie2:" + savedCookie.replace("$", ""));
v1Cookies.forEach(realCookie -> {
realCookie.setVersion(0);
store.add(URI.create(realCookie.getDomain()), realCookie);
});
});
}
} catch (Exception e) {
LOGGER.error("News page: catch broken cookies", e);
}
}
@Override
public void run() {
saveToPrefs();
}
@Override
public void add(URI uri, HttpCookie cookie) {
store.add(uri, cookie);
}
@Override
public List<HttpCookie> get(URI uri) {
return store.get(uri);
}
@Override
public List<HttpCookie> getCookies() {
return store.getCookies();
}
@Override
public List<URI> getURIs() {
return store.getURIs();
}
@Override
public boolean remove(URI uri, HttpCookie cookie) {
return store.remove(uri, cookie);
}
@Override
public boolean removeAll() {
return store.removeAll();
}
}
private void createWebView() {
// init web engine and events
// https://docs.oracle.com/javafx/2/swing/swing-fx-interoperability.htm
// workaround for empty dialog on 2+ opens - keep jfx thread alive (by default it exits on parent window close)
// see https://stackoverflow.com/a/32104851
Platform.setImplicitExit(false);
Platform.runLater(() -> {
webView = new WebView();
engine = webView.getEngine();
engine.setJavaScriptEnabled(true);
engine.setUserAgent(engine.getUserAgent() + " " + XmageURLConnection.getDefaultUserAgent()); // keep system user-agent too
if (!WHATS_NEW_DEBUG_ENABLE_CONTROLS) {
webView.contextMenuEnabledProperty().setValue(false);
}
CookieManager cookieManager = new CookieManager(new PersistentCookieStore(), CookiePolicy.ACCEPT_ALL);
CookieHandler.setDefault(cookieManager);
// on error
engine.getLoadWorker().exceptionProperty().addListener(new ChangeListener<Throwable>() {
@Override
public void changed(ObservableValue<? extends Throwable> o, Throwable old, final Throwable value) {
if (engine.getLoadWorker().getState() == Worker.State.FAILED
|| engine.getLoadWorker().getState() == Worker.State.CANCELLED) {
LOGGER.error("News page: can't load page", value);
}
}
});
// on completed
engine.getLoadWorker().stateProperty().addListener(new ChangeListener<Worker.State>() {
@Override
public void changed(ObservableValue ov, Worker.State oldState, Worker.State newState) {
if (newState != Worker.State.SUCCEEDED) {
return;
}
if (!WHATS_NEW_DEBUG_ENABLE_CONTROLS) {
// 1. open all page links in real browser, not build-in window
EventListener listener = new EventListener() {
@Override
public void handleEvent(org.w3c.dom.events.Event ev) {
String href = ((org.w3c.dom.Element) ev.getTarget()).getAttribute("href");
ev.preventDefault();
// open browser (must check href on null anyway)
if (href != null && href.startsWith("http")) {
SwingUtilities.invokeLater(() -> AppUtil.openUrlInSystemBrowser(href));
}
}
};
org.w3c.dom.Document doc = engine.getDocument();
org.w3c.dom.NodeList listA = doc.getElementsByTagName("a");
for (int i = 0; i < listA.getLength(); i++) {
((org.w3c.dom.events.EventTarget) listA.item(i)).addEventListener("click", listener, false);
}
}
// 2. all done, build-in browser ready to show
isPageReady = true;
}
});
fxPanel.setScene(new Scene(webView));
});
}
public void startBrowser(final String startingUrl) {
Platform.runLater(() -> {
String link = startingUrl;
if (!link.startsWith("http")) {
link = "http://" + link;
}
engine.load(link);
});
}
private void onCancel() {
this.hideDialog();
}
/**
* This method is called from within the constructor to initialize the form.
* WARNING: Do NOT modify this code. The content of this method is always
* regenerated by the Form Editor.
*/
@SuppressWarnings("unchecked")
// <editor-fold defaultstate="collapsed" desc="Generated Code">//GEN-BEGIN:initComponents
private void initComponents() {
buttonCancel = new JButton();
buttonRefresh = new JButton();
panelData = new JPanel();
buttonCancel.setText("Close");
buttonCancel.addActionListener(new java.awt.event.ActionListener() {
public void actionPerformed(java.awt.event.ActionEvent evt) {
buttonCancelActionPerformed(evt);
}
});
buttonRefresh.setText("Refresh");
buttonRefresh.addActionListener(new java.awt.event.ActionListener() {
public void actionPerformed(java.awt.event.ActionEvent evt) {
buttonRefreshActionPerformed(evt);
}
});
panelData.setLayout(new BorderLayout());
GroupLayout layout = new GroupLayout(getContentPane());
getContentPane().setLayout(layout);
layout.setHorizontalGroup(
layout.createParallelGroup(GroupLayout.Alignment.LEADING)
.addGroup(layout.createSequentialGroup()
.addContainerGap()
.addGroup(layout.createParallelGroup(GroupLayout.Alignment.LEADING)
.addComponent(panelData, GroupLayout.DEFAULT_SIZE, GroupLayout.DEFAULT_SIZE, Short.MAX_VALUE)
.addGroup(layout.createSequentialGroup()
.addComponent(buttonRefresh, GroupLayout.PREFERRED_SIZE, 100, GroupLayout.PREFERRED_SIZE)
.addPreferredGap(LayoutStyle.ComponentPlacement.RELATED, GroupLayout.DEFAULT_SIZE, Short.MAX_VALUE)
.addComponent(buttonCancel, GroupLayout.PREFERRED_SIZE, 100, GroupLayout.PREFERRED_SIZE)))
.addContainerGap())
);
layout.setVerticalGroup(
layout.createParallelGroup(GroupLayout.Alignment.LEADING)
.addGroup(layout.createSequentialGroup()
.addContainerGap()
.addComponent(panelData, GroupLayout.DEFAULT_SIZE, GroupLayout.DEFAULT_SIZE, Short.MAX_VALUE)
.addPreferredGap(LayoutStyle.ComponentPlacement.RELATED)
.addGroup(layout.createParallelGroup(GroupLayout.Alignment.BASELINE)
.addComponent(buttonCancel, GroupLayout.PREFERRED_SIZE, 30, GroupLayout.PREFERRED_SIZE)
.addComponent(buttonRefresh, GroupLayout.PREFERRED_SIZE, 30, GroupLayout.PREFERRED_SIZE))
.addContainerGap())
);
pack();
}// </editor-fold>//GEN-END:initComponents
private void buttonCancelActionPerformed(java.awt.event.ActionEvent evt) {//GEN-FIRST:event_buttonCancelActionPerformed
onCancel();
}//GEN-LAST:event_buttonCancelActionPerformed
private void buttonRefreshActionPerformed(java.awt.event.ActionEvent evt) {//GEN-FIRST:event_buttonRefreshActionPerformed
startBrowser(WHATS_NEW_PAGE);
}//GEN-LAST:event_buttonRefreshActionPerformed
// Variables declaration - do not modify//GEN-BEGIN:variables
private JButton buttonCancel;
private JButton buttonRefresh;
private JPanel panelData;
// End of variables declaration//GEN-END:variables
}

View file

@ -141,10 +141,13 @@ public class XmageURLConnection {
this.connection.setRequestProperty("Accept-Encoding", "gzip");
}
this.connection.setRequestProperty("User-Agent", getDefaultUserAgent());
}
public static String getDefaultUserAgent() {
// user agent due standard notation User-Agent: <product> / <product-version> <comment>
// warning, dot not add os, language and other details
this.connection.setRequestProperty("User-Agent", String.format("XMage/%s build: %s",
version.toString(false), version.getBuildTime()));
return String.format("XMage/%s build: %s", version.toString(false), version.getBuildTime());
}
/**

View file

@ -1752,7 +1752,7 @@ public class TablesPanel extends javax.swing.JPanel {
}//GEN-LAST:event_btnStateFinishedActionPerformed
private void buttonWhatsNewActionPerformed(java.awt.event.ActionEvent evt) {//GEN-FIRST:event_buttonWhatsNewActionPerformed
MageFrame.showWhatsNewDialog();
MageFrame.getInstance().showWhatsNewDialog(true);
}//GEN-LAST:event_buttonWhatsNewActionPerformed
private void btnQuickStart2PlayerActionPerformed(java.awt.event.ActionEvent evt) {//GEN-FIRST:event_btnQuickStartDuelActionPerformed

View file

@ -47,7 +47,7 @@ public class AppUtil {
}
}
public static void openUrlInBrowser(String url) {
public static void openUrlInSystemBrowser(String url) {
Desktop desktop = Desktop.isDesktopSupported() ? Desktop.getDesktop() : null;
if (desktop != null && desktop.isSupported(Desktop.Action.BROWSE)) {
try {

View file

@ -1,12 +1,8 @@
package mage.client.util;
import java.awt.Desktop;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import javax.swing.*;
@ -48,14 +44,7 @@ public class URLHandler {
return;
}
if (e.getClickCount() > 0) {
if (Desktop.isDesktopSupported()) {
Desktop desktop = Desktop.getDesktop();
try {
URI uri = new URI(url);
desktop.browse(uri);
} catch (IOException | URISyntaxException ignore) {
}
}
AppUtil.openUrlInSystemBrowser(url);
}
}
};

View file

@ -343,7 +343,7 @@
<!-- json support -->
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.8.8</version>
<version>2.11.0</version>
</dependency>
<dependency>
<!-- extended lib from google (collections, io, etc) -->