Skip to content

Commit e93adfb

Browse files
Merge pull request #72 from cryptomator/feature/update-mechanism
Update Mechanism
2 parents d7d273b + 0fa2e52 commit e93adfb

17 files changed

+852
-3
lines changed

CHANGELOG.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
77
The changelog starts with version 1.7.0.
88
Changes to prior versions can be found on the [Github release page](https://github.com/cryptomator/integrations-api/releases).
99

10-
## [Unreleased]
10+
## [Unreleased](https://github.com/cryptomator/integrations-api/compare/1.7.0...HEAD)
1111

12-
No changes yet.
12+
### Added
1313

14-
## [1.7.0] - 2025-09-17
14+
* Experimental [Update API](https://github.com/cryptomator/integrations-api/blob/a522f36cf45884127e2431dd18222391669d5992/src/main/java/org/cryptomator/integrations/update/UpdateMechanism.java) (#72)
15+
16+
## [1.7.0](https://github.com/cryptomator/integrations-api/releases/tag/1.7.0) - 2025-09-17
1517

1618
### Changed
1719

pom.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
<jdk.version>25</jdk.version>
3131

3232
<slf4j.version>2.0.17</slf4j.version>
33+
<jackson.version>2.20.0</jackson.version>
3334
<jetbrains-annotation.version>26.0.2-1</jetbrains-annotation.version>
3435

3536
<!-- Test dependencies -->
@@ -59,6 +60,11 @@
5960
<artifactId>slf4j-api</artifactId>
6061
<version>${slf4j.version}</version>
6162
</dependency>
63+
<dependency>
64+
<groupId>com.fasterxml.jackson.core</groupId>
65+
<artifactId>jackson-databind</artifactId>
66+
<version>${jackson.version}</version>
67+
</dependency>
6268

6369
<dependency>
6470
<groupId>org.jetbrains</groupId>

src/main/java/module-info.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@
66
import org.cryptomator.integrations.keychain.KeychainAccessProvider;
77
import org.cryptomator.integrations.tray.TrayIntegrationProvider;
88
import org.cryptomator.integrations.uiappearance.UiAppearanceProvider;
9+
import org.cryptomator.integrations.update.UpdateMechanism;
910

1011

1112
module org.cryptomator.integrations.api {
1213
requires static org.jetbrains.annotations;
1314
requires org.slf4j;
15+
requires com.fasterxml.jackson.databind;
16+
requires java.net.http;
1417

1518
exports org.cryptomator.integrations.autostart;
1619
exports org.cryptomator.integrations.common;
@@ -20,6 +23,7 @@
2023
exports org.cryptomator.integrations.tray;
2124
exports org.cryptomator.integrations.uiappearance;
2225
exports org.cryptomator.integrations.quickaccess;
26+
exports org.cryptomator.integrations.update;
2327

2428
uses AutoStartProvider;
2529
uses KeychainAccessProvider;
@@ -29,4 +33,5 @@
2933
uses TrayMenuController;
3034
uses UiAppearanceProvider;
3135
uses QuickAccessService;
36+
uses UpdateMechanism;
3237
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package org.cryptomator.integrations;
2+
3+
import java.util.ResourceBundle;
4+
5+
public enum Localization {
6+
INSTANCE;
7+
8+
private final ResourceBundle resourceBundle = ResourceBundle.getBundle("IntegrationsApi");
9+
10+
public static ResourceBundle get() {
11+
return INSTANCE.resourceBundle;
12+
}
13+
14+
}

src/main/java/org/cryptomator/integrations/common/IntegrationsLoader.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,19 @@ public static <T> Optional<T> load(Class<T> clazz) {
3636
return loadAll(clazz).findFirst();
3737
}
3838

39+
/**
40+
* Loads a specific service provider by its implementation class name.
41+
* @param clazz Service class
42+
* @param implementationClassName fully qualified class name of the implementation
43+
* @return Optional of the service provider if found
44+
* @param <T> Type of the service
45+
*/
46+
public static <T> Optional<T> loadSpecific(Class<T> clazz, String implementationClassName) {
47+
return ServiceLoader.load(clazz, ClassLoaderFactory.forPluginDir()).stream()
48+
.filter(provider -> provider.type().getName().equals(implementationClassName))
49+
.map(ServiceLoader.Provider::get)
50+
.findAny();
51+
}
3952

4053
/**
4154
* Loads all suited service providers ordered by priority in descending order.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package org.cryptomator.integrations.update;
2+
3+
public record DownloadUpdateInfo(
4+
DownloadUpdateMechanism updateMechanism,
5+
String version,
6+
DownloadUpdateMechanism.Asset asset
7+
) implements UpdateInfo<DownloadUpdateInfo> {
8+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package org.cryptomator.integrations.update;
2+
3+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
4+
import com.fasterxml.jackson.annotation.JsonProperty;
5+
import com.fasterxml.jackson.databind.ObjectMapper;
6+
import org.jetbrains.annotations.Blocking;
7+
import org.jetbrains.annotations.NotNull;
8+
import org.jetbrains.annotations.Nullable;
9+
import org.slf4j.Logger;
10+
import org.slf4j.LoggerFactory;
11+
12+
import java.io.IOException;
13+
import java.io.InputStream;
14+
import java.net.URI;
15+
import java.net.http.HttpClient;
16+
import java.net.http.HttpRequest;
17+
import java.net.http.HttpResponse;
18+
import java.nio.file.Files;
19+
import java.nio.file.Path;
20+
import java.util.HexFormat;
21+
import java.util.List;
22+
23+
public abstract class DownloadUpdateMechanism implements UpdateMechanism<DownloadUpdateInfo> {
24+
25+
private static final Logger LOG = LoggerFactory.getLogger(DownloadUpdateMechanism.class);
26+
private static final String LATEST_VERSION_API_URL = "https://api.cryptomator.org/connect/apps/desktop/latest-version?format=1";
27+
private static final ObjectMapper MAPPER = new ObjectMapper();
28+
29+
@Override
30+
public DownloadUpdateInfo checkForUpdate(String currentVersion, HttpClient httpClient) {
31+
try {
32+
HttpRequest request = HttpRequest.newBuilder().uri(URI.create(LATEST_VERSION_API_URL)).build();
33+
HttpResponse<InputStream> response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
34+
if (response.statusCode() != 200) {
35+
LOG.warn("Failed to fetch release: HTTP {}", response.statusCode());
36+
return null;
37+
}
38+
var release = MAPPER.readValue(response.body(), LatestVersionResponse.class);
39+
return checkForUpdate(currentVersion, release);
40+
} catch (InterruptedException e) {
41+
Thread.currentThread().interrupt();
42+
LOG.debug("Update check interrupted.");
43+
return null;
44+
} catch (IOException e) {
45+
LOG.warn("Update check failed", e);
46+
return null;
47+
}
48+
}
49+
50+
/**
51+
* Returns the first step to prepare the update. This downloads the {@link DownloadUpdateInfo#asset() asset} to a temporary location and verifies its checksum.
52+
* @param updateInfo The {@link DownloadUpdateInfo} retrieved from {@link #checkForUpdate(String, HttpClient)}.
53+
* @return a new {@link UpdateStep} that can be used to monitor the download progress.
54+
* @throws UpdateFailedException When failing to prepare a temporary download location.
55+
*/
56+
@Override
57+
public UpdateStep firstStep(DownloadUpdateInfo updateInfo) throws UpdateFailedException {
58+
try {
59+
Path workDir = Files.createTempDirectory("cryptomator-update");
60+
return new FirstStep(workDir, updateInfo);
61+
} catch (IOException e) {
62+
throw new UpdateFailedException("Failed to create temporary directory for update", e);
63+
}
64+
}
65+
66+
/**
67+
* Second step that is executed after the download has completed in the {@link #firstStep(DownloadUpdateInfo) first step}.
68+
* @param workDir A temporary working directory to which the asset has been downloaded.
69+
* @param assetPath The path of the downloaded asset.
70+
* @param updateInfo The {@link DownloadUpdateInfo} representing the update.
71+
* @return The next step of the update process.
72+
* @throws IllegalStateException if preconditions aren't met.
73+
* @throws IOException indicating an error preventing the next step from starting.
74+
* @implSpec The returned {@link UpdateStep} must either be stateless or a new instance must be returned on each call.
75+
*/
76+
public abstract UpdateStep secondStep(Path workDir, Path assetPath, DownloadUpdateInfo updateInfo) throws IllegalStateException, IOException;
77+
78+
@Nullable
79+
@Blocking
80+
protected abstract DownloadUpdateInfo checkForUpdate(String currentVersion, LatestVersionResponse response);
81+
82+
@JsonIgnoreProperties(ignoreUnknown = true)
83+
public record LatestVersionResponse(
84+
@JsonProperty("latestVersion") LatestVersion latestVersion,
85+
@JsonProperty("assets") List<Asset> assets
86+
) {}
87+
88+
@JsonIgnoreProperties(ignoreUnknown = true)
89+
public record LatestVersion(
90+
@JsonProperty("mac") String macVersion,
91+
@JsonProperty("win") String winVersion,
92+
@JsonProperty("linux") String linuxVersion
93+
) {}
94+
95+
@JsonIgnoreProperties(ignoreUnknown = true)
96+
public record Asset(
97+
@JsonProperty("name") String name,
98+
@JsonProperty("digest") String digest,
99+
@JsonProperty("size") long size,
100+
@JsonProperty("downloadUrl") String downloadUrl
101+
) {}
102+
103+
private class FirstStep extends DownloadUpdateStep {
104+
private final Path workDir;
105+
private final DownloadUpdateInfo updateInfo;
106+
107+
public FirstStep(Path workDir, DownloadUpdateInfo updateInfo) {
108+
var uri = URI.create(updateInfo.asset().downloadUrl);
109+
var destination = workDir.resolve(updateInfo.asset().name);
110+
var digest = updateInfo.asset().digest().startsWith("sha256:")
111+
? HexFormat.of().withLowerCase().parseHex(updateInfo.asset().digest.substring(7)) // remove "sha256:" prefix
112+
: null;
113+
var size = updateInfo.asset().size;
114+
super(uri, destination, digest, size);
115+
this.workDir = workDir;
116+
this.updateInfo = updateInfo;
117+
}
118+
119+
@Override
120+
public @Nullable UpdateStep nextStep() throws IllegalStateException, IOException {
121+
if (!isDone()) {
122+
throw new IllegalStateException("Download not yet completed.");
123+
} else if (downloadException != null) {
124+
throw new UpdateFailedException("Download failed.", downloadException);
125+
}
126+
return secondStep(workDir, destination, updateInfo);
127+
}
128+
}
129+
130+
}

0 commit comments

Comments
 (0)