Skip to content

Commit 55301b0

Browse files
committed
feat: Add automatic update system with hash verification
Implement comprehensive automatic update checking and installation system for installed Meteor addons. The system verifies updates using SHA-256 hashes from GitHub releases to ensure integrity. Features: - Background update checker that runs on startup after addon metadata loads - SHA-256 hash verification for all downloads via GitHub API - Progress tracking with visual progress bar widget - Batch update installation with backup/rollback capability - Manual update check from addon detail screen - Changelog viewer for release notes - Update notification screen showing all available updates New Systems: - UpdateChecker: Scans installed addons against GitHub releases - UpdateDownloadManager: Handles async downloads with progress callbacks - UpdateInstaller: Manages safe installation with backup recovery Architecture: - Callback-based flow: AddonManager → UpdateChecker → UpdatesAvailableScreen - Background thread downloads with render thread GUI updates - Hash verification before and after download - Graceful fallback if GitHub API unavailable or hash missing Technical Details: - Uses GitHub Release API to fetch latest versions and assets - Computes local JAR hashes and compares with remote checksums - Downloads only if hashes differ (version-agnostic detection) - Atomic file operations with .bak backup before replacement - Progress tracking via byte stream monitoring
1 parent 3ee647d commit 55301b0

13 files changed

Lines changed: 1869 additions & 5 deletions

.claude/settings.local.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
"mcp__gradle-mcp-server__gradle_build",
1010
"WebFetch(domain:maven.fabricmc.net)",
1111
"mcp__code-search-mcp__search_text",
12-
"Bash(grep:*)"
12+
"Bash(grep:*)",
13+
"WebFetch(domain:github.blog)"
1314
],
1415
"deny": [],
1516
"ask": []

src/main/java/com/cope/meteoraddons/MeteorAddonsAddon.java

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
package com.cope.meteoraddons;
22

3+
import com.cope.meteoraddons.gui.screens.UpdatesAvailableScreen;
34
import com.cope.meteoraddons.gui.tabs.AddonsTab;
45
import com.cope.meteoraddons.systems.AddonManager;
56
import com.cope.meteoraddons.systems.IconPreloadSystem;
7+
import com.cope.meteoraddons.systems.UpdateChecker;
68
import meteordevelopment.meteorclient.addons.GithubRepo;
79
import meteordevelopment.meteorclient.addons.MeteorAddon;
10+
import meteordevelopment.meteorclient.gui.GuiThemes;
811
import meteordevelopment.meteorclient.gui.tabs.Tabs;
912
import meteordevelopment.meteorclient.systems.Systems;
1013
import org.slf4j.Logger;
1114
import org.slf4j.LoggerFactory;
1215

16+
import static meteordevelopment.meteorclient.MeteorClient.mc;
17+
1318
/**
1419
* Entry point for Meteor Addons addon.
1520
* Enables browsing, installing, and updating Meteor Client addons from within the client.
@@ -25,14 +30,33 @@ public void onInitialize() {
2530
Systems.add(iconPreloadSystem);
2631
LOG.info("IconPreloadSystem registered");
2732

28-
Systems.add(new AddonManager());
33+
// Initialize UpdateChecker
34+
UpdateChecker updateChecker = new UpdateChecker();
35+
Systems.add(updateChecker);
36+
LOG.info("UpdateChecker system registered");
37+
38+
// Set up update notification callback
39+
updateChecker.setOnUpdatesFound(updates -> {
40+
LOG.info("Found {} addon updates available", updates.size());
41+
// Show the updates screen on the next tick (ensures we're on render thread)
42+
mc.execute(() -> {
43+
mc.setScreen(new UpdatesAvailableScreen(GuiThemes.get(), updates));
44+
});
45+
});
46+
47+
// Set up AddonManager with callback to trigger update check
48+
AddonManager addonManager = new AddonManager();
49+
addonManager.setOnLoadComplete(() -> {
50+
LOG.info("Addon metadata loaded, starting update check...");
51+
updateChecker.checkForUpdates();
52+
});
53+
54+
Systems.add(addonManager);
2955
LOG.info("AddonManager system initialized");
3056

3157
Tabs.add(new AddonsTab());
3258
LOG.info("Addons tab registered");
3359

34-
35-
3660
LOG.info("Meteor Addons Addon initialized successfully");
3761
}
3862

src/main/java/com/cope/meteoraddons/gui/screens/AddonDetailScreen.java

Lines changed: 149 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
package com.cope.meteoraddons.gui.screens;
22

3+
import com.cope.meteoraddons.MeteorAddonsAddon;
34
import com.cope.meteoraddons.addons.Addon;
5+
import com.cope.meteoraddons.addons.InstalledAddon;
46
import com.cope.meteoraddons.addons.OnlineAddon;
57
import com.cope.meteoraddons.config.IconSizeConfig;
68
import com.cope.meteoraddons.models.AddonMetadata;
9+
import com.cope.meteoraddons.models.UpdateInfo;
710
import com.cope.meteoraddons.systems.AddonManager;
11+
import com.cope.meteoraddons.util.GitHubReleaseAPI;
12+
import com.cope.meteoraddons.util.HashUtil;
813
import com.cope.meteoraddons.util.IconCache;
914
import meteordevelopment.meteorclient.gui.GuiTheme;
15+
import meteordevelopment.meteorclient.gui.GuiThemes;
1016
import meteordevelopment.meteorclient.gui.WindowScreen;
1117
import meteordevelopment.meteorclient.gui.widgets.containers.WHorizontalList;
1218
import meteordevelopment.meteorclient.gui.widgets.containers.WSection;
@@ -19,7 +25,10 @@
1925

2026
import com.cope.meteoraddons.util.TimeUtil;
2127

28+
import java.nio.file.Path;
29+
import java.util.Collections;
2230
import java.util.List;
31+
import java.util.Optional;
2332

2433
import static meteordevelopment.meteorclient.MeteorClient.mc;
2534
import static meteordevelopment.meteorclient.utils.Utils.getWindowWidth;
@@ -115,7 +124,7 @@ public void initWidgets() {
115124
// Actions (Buttons)
116125
WHorizontalList actions = add(theme.horizontalList()).right().widget();
117126

118-
// Download/Install Button
127+
// Download/Install Button (for non-installed addons)
119128
if (!addon.isInstalled() && addon instanceof OnlineAddon) {
120129
WButton downloadButton = actions.add(theme.button("Download")).widget();
121130
downloadButton.action = () -> {
@@ -133,6 +142,26 @@ public void initWidgets() {
133142
};
134143
}
135144

145+
// Check for Updates Button (for installed addons)
146+
if (addon.isInstalled() && addon instanceof InstalledAddon installedAddon) {
147+
WButton checkUpdateBtn = actions.add(theme.button("Check for Updates")).widget();
148+
checkUpdateBtn.action = () -> {
149+
checkUpdateBtn.set("Checking...");
150+
MeteorExecutor.execute(() -> {
151+
Optional<UpdateInfo> update = checkForUpdate(installedAddon);
152+
mc.execute(() -> {
153+
if (update.isPresent()) {
154+
checkUpdateBtn.set("Update Found!");
155+
// Show updates screen with this single update
156+
mc.setScreen(new UpdatesAvailableScreen(GuiThemes.get(), Collections.singletonList(update.get())));
157+
} else {
158+
checkUpdateBtn.set("Up to date");
159+
}
160+
});
161+
});
162+
};
163+
}
164+
136165
// Link Buttons
137166
if (addon.getGithubUrl().isPresent()) {
138167
WButton btn = actions.add(theme.button("GitHub")).widget();
@@ -189,4 +218,123 @@ private boolean addFeatureList(WSection section, String label, List<String> item
189218

190219
return true;
191220
}
221+
222+
/**
223+
* Check for updates for a specific installed addon.
224+
*/
225+
private Optional<UpdateInfo> checkForUpdate(InstalledAddon installed) {
226+
MeteorAddonsAddon.LOG.info("Checking for updates for: {}", installed.getName());
227+
228+
// Get GitHub URL from the installed addon
229+
Optional<String> githubUrlOpt = installed.getGithubUrl();
230+
if (githubUrlOpt.isEmpty()) {
231+
MeteorAddonsAddon.LOG.warn("No GitHub URL for {}", installed.getName());
232+
return Optional.empty();
233+
}
234+
235+
String githubUrl = githubUrlOpt.get();
236+
MeteorAddonsAddon.LOG.info("GitHub URL: {}", githubUrl);
237+
238+
// Parse owner/repo
239+
Optional<String[]> parsed = GitHubReleaseAPI.parseGitHubUrl(githubUrl);
240+
if (parsed.isEmpty()) {
241+
MeteorAddonsAddon.LOG.warn("Failed to parse GitHub URL: {}", githubUrl);
242+
return Optional.empty();
243+
}
244+
245+
String[] ownerRepo = parsed.get();
246+
MeteorAddonsAddon.LOG.info("Parsed: owner={}, repo={}", ownerRepo[0], ownerRepo[1]);
247+
248+
// Get local JAR path
249+
Path localJarPath = getJarPath(installed);
250+
if (localJarPath == null) {
251+
MeteorAddonsAddon.LOG.warn("Could not determine JAR path for {}", installed.getName());
252+
return Optional.empty();
253+
}
254+
MeteorAddonsAddon.LOG.info("Local JAR path: {}", localJarPath);
255+
256+
// Compute local hash
257+
String localHash = HashUtil.computeSha256(localJarPath);
258+
if (localHash == null) {
259+
MeteorAddonsAddon.LOG.warn("Failed to compute local hash for {}", installed.getName());
260+
return Optional.empty();
261+
}
262+
MeteorAddonsAddon.LOG.info("Local SHA256: {}", localHash);
263+
264+
// Fetch release info from GitHub
265+
Optional<GitHubReleaseAPI.ReleaseInfo> releaseOpt = GitHubReleaseAPI.getLatestRelease(ownerRepo[0], ownerRepo[1]);
266+
if (releaseOpt.isEmpty()) {
267+
MeteorAddonsAddon.LOG.warn("No release found for {}/{}", ownerRepo[0], ownerRepo[1]);
268+
return Optional.empty();
269+
}
270+
271+
GitHubReleaseAPI.ReleaseInfo release = releaseOpt.get();
272+
MeteorAddonsAddon.LOG.info("Latest release: {} ({})", release.getName(), release.getTagName());
273+
274+
// Find JAR asset
275+
Optional<GitHubReleaseAPI.AssetInfo> assetOpt = GitHubReleaseAPI.findJarAsset(release);
276+
if (assetOpt.isEmpty()) {
277+
MeteorAddonsAddon.LOG.warn("No JAR asset found in release for {}", installed.getName());
278+
return Optional.empty();
279+
}
280+
281+
GitHubReleaseAPI.AssetInfo asset = assetOpt.get();
282+
String remoteHash = asset.getSha256();
283+
MeteorAddonsAddon.LOG.info("Remote JAR: {}", asset.getFileName());
284+
MeteorAddonsAddon.LOG.info("Remote SHA256: {}", remoteHash);
285+
286+
if (remoteHash == null || remoteHash.isEmpty()) {
287+
MeteorAddonsAddon.LOG.warn("No SHA256 digest available for {} (GitHub may not have computed it yet)", installed.getName());
288+
return Optional.empty();
289+
}
290+
291+
// Compare hashes
292+
if (!HashUtil.hashesMatch(localHash, remoteHash)) {
293+
MeteorAddonsAddon.LOG.info("UPDATE AVAILABLE for {}: hashes differ", installed.getName());
294+
295+
return Optional.of(new UpdateInfo(
296+
installed,
297+
installed.getName(),
298+
installed.getVersion(),
299+
release.getVersion(),
300+
release.getChangelog(),
301+
asset.getDownloadUrl(),
302+
remoteHash,
303+
localHash,
304+
localJarPath
305+
));
306+
} else {
307+
MeteorAddonsAddon.LOG.info("{} is up to date (hashes match)", installed.getName());
308+
return Optional.empty();
309+
}
310+
}
311+
312+
/**
313+
* Get the JAR file path for an installed addon.
314+
*/
315+
private Path getJarPath(InstalledAddon addon) {
316+
try {
317+
List<Path> rootPaths = addon.getModContainer().getRootPaths();
318+
if (!rootPaths.isEmpty()) {
319+
Path rootPath = rootPaths.get(0);
320+
String pathStr = rootPath.toUri().toString();
321+
MeteorAddonsAddon.LOG.debug("Root path URI: {}", pathStr);
322+
323+
if (pathStr.startsWith("jar:file:")) {
324+
// Extract JAR path from jar:file:/path/to/mod.jar!/
325+
int exclamation = pathStr.indexOf('!');
326+
if (exclamation > 0) {
327+
String jarUriStr = pathStr.substring(4, exclamation); // Get "file:/path/to/mod.jar"
328+
java.net.URI jarUri = new java.net.URI(jarUriStr);
329+
return Path.of(jarUri);
330+
}
331+
} else if (pathStr.endsWith(".jar")) {
332+
return rootPath;
333+
}
334+
}
335+
} catch (Exception e) {
336+
MeteorAddonsAddon.LOG.warn("Failed to get JAR path for {}: {}", addon.getName(), e.getMessage());
337+
}
338+
return null;
339+
}
192340
}

0 commit comments

Comments
 (0)