44import com .fastasyncworldedit .core .FaweVersion ;
55import com .fastasyncworldedit .core .configuration .Caption ;
66import com .fastasyncworldedit .core .configuration .Settings ;
7+ import com .sk89q .util .StringUtil ;
78import com .sk89q .worldedit .extension .platform .Actor ;
89import com .sk89q .worldedit .internal .util .LogManagerCompat ;
910import com .sk89q .worldedit .util .formatting .text .TextComponent ;
1011import com .sk89q .worldedit .util .formatting .text .event .ClickEvent ;
12+ import com .sk89q .worldedit .util .formatting .text .format .TextColor ;
1113import org .apache .logging .log4j .Logger ;
12- import org .w3c . dom . Document ;
14+ import org .jetbrains . annotations . VisibleForTesting ;
1315
14- import javax .xml .XMLConstants ;
15- import javax .xml .parsers .DocumentBuilder ;
16- import javax .xml .parsers .DocumentBuilderFactory ;
17- import java .io .InputStream ;
1816import java .net .URI ;
1917import java .net .http .HttpClient ;
2018import java .net .http .HttpRequest ;
2119import java .net .http .HttpResponse ;
22- import java .time .Duration ;
23- import java .time .temporal .ChronoUnit ;
20+ import java .util .Arrays ;
21+ import java .util .List ;
22+ import java .util .concurrent .CompletableFuture ;
23+ import java .util .concurrent .TimeUnit ;
24+ import java .util .regex .Matcher ;
25+ import java .util .regex .Pattern ;
2426
2527public class UpdateNotification {
2628
29+ private static final String GITHUB_LAST_RELEASE = "https://api.github.com/repos/IntellectualSites/FastAsyncWorldEdit/releases/latest" ;
30+ private static final String JENKINS_LAST_BUILD = "https://ci.athion.net/job/FastAsyncWorldEdit/lastSuccessfulBuild/api/json" ;
31+
32+ private static final String LINK_DOWNLOAD_SPIGOTMC = "https://www.spigotmc.org/resources/13932" ;
33+ private static final String LINK_DOWNLOAD_JENKINS = "https://ci.athion.net/job/FastAsyncWorldEdit" ;
34+ private static final String LINK_DOWNLOAD_MODRINTH = "https://modrinth.com/plugin/fastasyncworldedit" ;
35+ private static final String LINK_DOWNLOAD_HANGAR = "https://hangar.papermc.io/IntellectualSites/FastAsyncWorldEdit" ;
36+
37+ private static final String CONSOLE_NOTIFICATION_OUTDATED_RELEASE = """
38+ A new release for FastAsyncWorldEdit is available: {}. You are currently on {}.
39+ Download from {}, {} or {}""" ;
40+ private static final String CONSOLE_NOTIFICATION_OUTDATED_BUILD = """
41+ An update for FastAsyncWorldEdit is available. You are {} build(s) out of date.
42+ You are running build {}, the latest version is build {}.
43+ Update at {}""" ;
44+
2745 private static final Logger LOGGER = LogManagerCompat .getLogger ();
46+ private static final HttpClient HTTP_CLIENT = HttpClient .newHttpClient ();
47+ private static final Pattern GITHUB_RESPONSE_TAG_NAME_PATTERN = Pattern .compile ("\" tag_name\" :\" ([\\ d.]+)\" " );
48+ private static final Pattern JENKINS_RESPONSE_BUILD_PATTERN = Pattern .compile ("\" number\" :(\\ d+)" );
2849
29- private static boolean hasUpdate ;
30- private static String faweVersion = "" ;
50+ private static volatile int [] lastRelease ;
51+ private static volatile int lastBuild = - 1 ;
3152
3253 /**
3354 * Check whether a new build with a higher build number than the current build is available.
3455 */
3556 public static void doUpdateCheck () {
36- if (Settings .settings ().ENABLED_COMPONENTS .UPDATE_NOTIFICATIONS ) {
37- final HttpRequest request = HttpRequest
38- .newBuilder ()
39- .uri (URI .create ("https://ci.athion.net/job/FastAsyncWorldEdit/api/xml/" ))
40- .timeout (Duration .of (10L , ChronoUnit .SECONDS ))
41- .build ();
42- HttpClient .newHttpClient ()
43- .sendAsync (request , HttpResponse .BodyHandlers .ofInputStream ())
44- .whenComplete ((response , thrown ) -> {
45- if (thrown != null ) {
46- LOGGER .error ("Update check failed: {} " , thrown .getMessage ());
47- }
48- processResponseBody (response .body ());
49- });
50-
57+ if (hasUpdateInfo ()) {
58+ return ;
59+ }
60+ final FaweVersion installedVersion = Fawe .instance ().getVersion ();
61+ if (installedVersion == null || (installedVersion .build == 0 && installedVersion .snapshot )) {
62+ LOGGER .warn ("You are using a snapshot or a custom version of FAWE. " +
63+ "This is not an official build distributed via https://www.spigotmc.org/resources/13932/" );
64+ return ;
65+ }
66+ if (Settings .settings ().ENABLED_COMPONENTS .SNAPSHOT_UPDATE_NOTIFICATIONS ) {
67+ checkLatestBuild ().orTimeout (10 , TimeUnit .SECONDS ).whenComplete ((build , throwable ) -> {
68+ if (throwable != null ) {
69+ LOGGER .error ("Failed to check for latest build" , throwable );
70+ return ;
71+ }
72+ lastBuild = build ;
73+ int difference = lastBuild - installedVersion .build ;
74+ if (difference < 1 ) {
75+ return ;
76+ }
77+ LOGGER .warn (CONSOLE_NOTIFICATION_OUTDATED_BUILD , difference , installedVersion .build , lastBuild , LINK_DOWNLOAD_JENKINS );
78+ });
79+ }
80+ if (Settings .settings ().ENABLED_COMPONENTS .RELEASE_UPDATE_NOTIFICATIONS ) {
81+ checkLatestRelease ().orTimeout (10 , TimeUnit .SECONDS ).whenComplete ((version , throwable ) -> {
82+ if (throwable != null ) {
83+ LOGGER .error ("Failed to check for latest release" , throwable );
84+ return ;
85+ }
86+ lastRelease = version ;
87+ if (installedVersion .semver != null && hasUpdateSemver (installedVersion .semver , version )) {
88+ LOGGER .warn (CONSOLE_NOTIFICATION_OUTDATED_RELEASE ,
89+ StringUtil .joinString (lastRelease , "." , 0 ),
90+ StringUtil .joinString (installedVersion .semver , "." , 0 ),
91+ LINK_DOWNLOAD_MODRINTH , LINK_DOWNLOAD_HANGAR , LINK_DOWNLOAD_SPIGOTMC
92+ );
93+ }
94+ });
5195 }
5296 }
5397
54- private static void processResponseBody (InputStream body ) {
55- try {
56- DocumentBuilderFactory dbf = DocumentBuilderFactory .newInstance ();
57- dbf .setFeature (XMLConstants .FEATURE_SECURE_PROCESSING , true );
58- DocumentBuilder db = dbf .newDocumentBuilder ();
59- Document doc = db .parse (body );
60- faweVersion = doc .getElementsByTagName ("lastSuccessfulBuild" ).item (0 ).getFirstChild ().getTextContent ();
61- FaweVersion faweVersion = Fawe .instance ().getVersion ();
62- if (faweVersion .build == 0 && faweVersion .snapshot ) {
63- LOGGER .warn ("You are using a snapshot or a custom version of FAWE. This is not an official build distributed " +
64- "via https://www.spigotmc.org/resources/13932/" );
65- return ;
98+ private static CompletableFuture <int []> checkLatestRelease () {
99+ return HTTP_CLIENT .sendAsync (
100+ HttpRequest .newBuilder ().GET ().uri (URI .create (GITHUB_LAST_RELEASE )).build (),
101+ HttpResponse .BodyHandlers .ofString ()
102+ ).thenApply (response -> {
103+ if (response .statusCode () != 200 ) {
104+ throw new UpdateCheckException ("GitHub returned status code " + response .statusCode ());
66105 }
67- if (faweVersion .snapshot && faweVersion .build < Integer .parseInt (UpdateNotification .faweVersion )) {
68- hasUpdate = true ;
69- int versionDifference = Integer .parseInt (UpdateNotification .faweVersion ) - faweVersion .build ;
70- LOGGER .warn (
71- """
72- An update for FastAsyncWorldEdit is available. You are {} build(s) out of date.
73- You are running build {}, the latest version is build {}.
74- Update at https://www.spigotmc.org/resources/13932/""" ,
75- versionDifference ,
76- faweVersion .build ,
77- UpdateNotification .faweVersion
78- );
106+ return response .body ();
107+ }).thenApply (body -> {
108+ final Matcher matcher = GITHUB_RESPONSE_TAG_NAME_PATTERN .matcher (body );
109+ if (!matcher .find ()) {
110+ throw new UpdateCheckException ("Couldn't find tag name in response" );
79111 }
80- } catch (Exception ignored ) {
81- LOGGER .error ("Unable to check for updates. Skipping." );
82- }
112+ try {
113+ return Arrays .stream (matcher .group (1 ).split ("\\ ." )).toList ().stream ().mapToInt (Integer ::parseInt ).toArray ();
114+ } catch (NumberFormatException e ) {
115+ throw new UpdateCheckException ("Couldn't parse version" , e );
116+ }
117+ }).thenApply (version -> {
118+ if (version .length != 3 ) {
119+ throw new UpdateCheckException ("Retrieved malformed version (%s)" .formatted (Arrays .toString (version )));
120+ }
121+ return version ;
122+ });
123+ }
124+
125+ private static CompletableFuture <Integer > checkLatestBuild () {
126+ return HTTP_CLIENT .sendAsync (
127+ HttpRequest .newBuilder ().GET ().uri (URI .create (JENKINS_LAST_BUILD )).build (),
128+ HttpResponse .BodyHandlers .ofString ()
129+ ).thenApply (response -> {
130+ if (response .statusCode () != 200 ) {
131+ throw new UpdateCheckException ("Jenkins returned status code " + response .statusCode ());
132+ }
133+ return response .body ();
134+ }).thenApply (body -> {
135+ final Matcher matcher = JENKINS_RESPONSE_BUILD_PATTERN .matcher (body );
136+ if (!matcher .find ()) {
137+ throw new UpdateCheckException ("Couldn't find latest build in response" );
138+ }
139+ try {
140+ return Integer .parseInt (matcher .group (1 ));
141+ } catch (NumberFormatException e ) {
142+ throw new UpdateCheckException ("Couldn't parse build" , e );
143+ }
144+ });
83145 }
84146
85147 /**
@@ -88,21 +150,86 @@ private static void processResponseBody(InputStream body) {
88150 * @param actor The player to notify.
89151 */
90152 public static void doUpdateNotification (Actor actor ) {
91- if (Settings .settings ().ENABLED_COMPONENTS .UPDATE_NOTIFICATIONS ) {
92- if (actor .hasPermission ("fawe.admin" ) && UpdateNotification .hasUpdate ) {
93- FaweVersion faweVersion = Fawe .instance ().getVersion ();
94- int versionDifference = Integer .parseInt (UpdateNotification .faweVersion ) - faweVersion .build ;
153+ if (!isAnyUpdateCheckEnabled () || !actor .hasPermission ("fawe.admin" ) || !hasUpdateInfo ()) {
154+ return ;
155+ }
156+ final FaweVersion installed = Fawe .instance ().getVersion ();
157+ if (installed == null ) {
158+ return ;
159+ }
160+ if (lastBuild != -1 && Settings .settings ().ENABLED_COMPONENTS .SNAPSHOT_UPDATE_NOTIFICATIONS ) {
161+ int difference = lastBuild - installed .build ;
162+ if (difference > 0 ) {
163+ actor .print (Caption .of (
164+ "fawe.info.update-available.build" ,
165+ difference , installed .build , lastBuild ,
166+ TextComponent .of (LINK_DOWNLOAD_JENKINS ).clickEvent (ClickEvent .openUrl (LINK_DOWNLOAD_JENKINS ))
167+ ));
168+ }
169+ }
170+ if (installed .semver != null && lastRelease != null && Settings .settings ().ENABLED_COMPONENTS .RELEASE_UPDATE_NOTIFICATIONS ) {
171+ if (hasUpdateSemver (installed .semver , lastRelease )) {
95172 actor .print (Caption .of (
96- "fawe.info.update-available" ,
97- versionDifference ,
98- faweVersion .build ,
99- UpdateNotification .faweVersion ,
100- TextComponent
101- .of ("https://www.spigotmc.org/resources/13932/" )
102- .clickEvent (ClickEvent .openUrl ("https://www.spigotmc.org/resources/13932/" ))
173+ "fawe.info.update-available.release" ,
174+ StringUtil .joinString (lastRelease , "." , 0 ),
175+ StringUtil .joinString (installed .semver , "." , 0 ),
176+ TextComponent .empty ().children (List .of (
177+ TextComponent
178+ .of ("Modrinth" )
179+ .color (TextColor .GREEN )
180+ .clickEvent (ClickEvent .openUrl (LINK_DOWNLOAD_MODRINTH )),
181+ TextComponent .empty ().color (TextColor .GRAY )
182+ )),
183+ TextComponent .empty ().children (List .of (
184+ TextComponent
185+ .of ("Hangar" )
186+ .color (TextColor .BLUE )
187+ .clickEvent (ClickEvent .openUrl (LINK_DOWNLOAD_HANGAR )),
188+ TextComponent .empty ().color (TextColor .GRAY )
189+ )),
190+ TextComponent .empty ().children (List .of (
191+ TextComponent
192+ .of ("SpigotMC" )
193+ .color (TextColor .GOLD )
194+ .clickEvent (ClickEvent .openUrl (LINK_DOWNLOAD_SPIGOTMC )),
195+ TextComponent .empty ().color (TextColor .GRAY )
196+ ))
103197 ));
104198 }
105199 }
106200 }
107201
202+ @ VisibleForTesting
203+ static boolean hasUpdateSemver (int [] installed , int [] latest ) {
204+ for (int i = 0 ; i < Math .max (installed .length , latest .length ); i ++) {
205+ final int installedPart = i < installed .length ? installed [i ] : 0 ;
206+ final int latestPart = i < latest .length ? latest [i ] : 0 ;
207+ if (installedPart != latestPart ) {
208+ return installedPart < latestPart ;
209+ }
210+ }
211+ return false ;
212+ }
213+
214+ private static boolean hasUpdateInfo () {
215+ return lastRelease != null || lastBuild != -1 ;
216+ }
217+
218+ private static boolean isAnyUpdateCheckEnabled () {
219+ return Settings .settings ().ENABLED_COMPONENTS .RELEASE_UPDATE_NOTIFICATIONS
220+ || Settings .settings ().ENABLED_COMPONENTS .SNAPSHOT_UPDATE_NOTIFICATIONS ;
221+ }
222+
223+ private static final class UpdateCheckException extends RuntimeException {
224+
225+ public UpdateCheckException (final String message ) {
226+ super ("Failed to check for update: " + message );
227+ }
228+
229+ public UpdateCheckException (final String message , final Throwable cause ) {
230+ super ("Failed to check for update: " + message , cause );
231+ }
232+
233+ }
234+
108235}
0 commit comments