From 88f72310007820daa8354f92fb84ddc0956108b8 Mon Sep 17 00:00:00 2001 From: Ralph Plawetzki Date: Sat, 28 Jun 2025 13:18:40 +0200 Subject: [PATCH 01/23] Invent UpdateService --- src/main/java/module-info.java | 3 + .../update/UpdateFailedException.java | 12 ++++ .../integrations/update/UpdateService.java | 67 +++++++++++++++++++ 3 files changed, 82 insertions(+) create mode 100644 src/main/java/org/cryptomator/integrations/update/UpdateFailedException.java create mode 100644 src/main/java/org/cryptomator/integrations/update/UpdateService.java diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 9925772..f40256a 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -6,6 +6,7 @@ import org.cryptomator.integrations.keychain.KeychainAccessProvider; import org.cryptomator.integrations.tray.TrayIntegrationProvider; import org.cryptomator.integrations.uiappearance.UiAppearanceProvider; +import org.cryptomator.integrations.update.UpdateService; module org.cryptomator.integrations.api { @@ -20,6 +21,7 @@ exports org.cryptomator.integrations.tray; exports org.cryptomator.integrations.uiappearance; exports org.cryptomator.integrations.quickaccess; + exports org.cryptomator.integrations.update; uses AutoStartProvider; uses KeychainAccessProvider; @@ -29,4 +31,5 @@ uses TrayMenuController; uses UiAppearanceProvider; uses QuickAccessService; + uses UpdateService; } \ No newline at end of file diff --git a/src/main/java/org/cryptomator/integrations/update/UpdateFailedException.java b/src/main/java/org/cryptomator/integrations/update/UpdateFailedException.java new file mode 100644 index 0000000..639fcc8 --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/update/UpdateFailedException.java @@ -0,0 +1,12 @@ +package org.cryptomator.integrations.update; + +public class UpdateFailedException extends Exception { + + public UpdateFailedException(String message) { + super(message); + } + + public UpdateFailedException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/org/cryptomator/integrations/update/UpdateService.java b/src/main/java/org/cryptomator/integrations/update/UpdateService.java new file mode 100644 index 0000000..262a06e --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/update/UpdateService.java @@ -0,0 +1,67 @@ +package org.cryptomator.integrations.update; + +import org.cryptomator.integrations.common.IntegrationsLoader; + +import java.util.Optional; + +/** + * This is the interface used by Cryptomator to provide a way to update Cryptomator in a convinient way. + * It's idependent of the supported platforms and package distribution channels. + */ +public interface UpdateService { + + static Optional get() { + return IntegrationsLoader.load(UpdateService.class); + } + + enum DistributionChannel { + LINUX_APPIMAGE, + LINUX_AUR, + LINUX_FLATPAK, + LINUX_NIXOS, + LINUX_PPA, + MAC_BREW, + MAC_DMG, + WINDOWS_EXE, + WINDOWS_MSI, + WINDOWS_WINGET + } + + /** + * @return true if this UppdateService can update the app. + * @implSpec This method must not throw any exceptions and should fail fast + * returning false if it's not possible to use this UppdateService + */ + boolean isSupported(); + + /** + * Checks whether the update itself is already published on the given channel. + * + * @param channel The {@link DistributionChannel} to check. + * @return null if an update is not available, the version of the available update as String otherwise. + */ + String isUpdateAvailable(DistributionChannel channel); + + /** + * Trigger updating the app. + * + * @throws UpdateFailedException If the udpate wasn't successful or was cancelled. + */ + void triggerUpdate() throws UpdateFailedException; + + /** + * A flag indicating whether elevated permissions or sudo is required during update + * (so the user can be prepared for a corresponding prompt) + * + * @return true if elevated permissions are required, false otherwise. + */ + boolean doesRequireElevatedPermissions(); + + /** + * Get a meaningful description of the update available to display it in the app + * like "Update via apt" + * + * @return The text to describes the update. + */ + String getDisplayName(); +} From a501a2b5e51c671709d874da6c8646337f04900d Mon Sep 17 00:00:00 2001 From: Ralph Plawetzki Date: Sat, 12 Jul 2025 19:04:04 +0200 Subject: [PATCH 02/23] Have multiple UpdateServices --- .../org/cryptomator/integrations/update/UpdateService.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/cryptomator/integrations/update/UpdateService.java b/src/main/java/org/cryptomator/integrations/update/UpdateService.java index 262a06e..6b6a6b5 100644 --- a/src/main/java/org/cryptomator/integrations/update/UpdateService.java +++ b/src/main/java/org/cryptomator/integrations/update/UpdateService.java @@ -2,7 +2,7 @@ import org.cryptomator.integrations.common.IntegrationsLoader; -import java.util.Optional; +import java.util.stream.Stream; /** * This is the interface used by Cryptomator to provide a way to update Cryptomator in a convinient way. @@ -10,8 +10,8 @@ */ public interface UpdateService { - static Optional get() { - return IntegrationsLoader.load(UpdateService.class); + static Stream get() { + return IntegrationsLoader.loadAll(UpdateService.class).filter(UpdateService::isSupported); } enum DistributionChannel { From 6af0aa8d794e88143842e5f5584373f6ca36a4b8 Mon Sep 17 00:00:00 2001 From: Ralph Plawetzki Date: Sun, 13 Jul 2025 16:55:36 +0200 Subject: [PATCH 03/23] Invent @DistributionChannel --- .../common/DistributionChannel.java | 41 +++++++++++++++++++ .../integrations/update/UpdateService.java | 18 ++------ 2 files changed, 44 insertions(+), 15 deletions(-) create mode 100644 src/main/java/org/cryptomator/integrations/common/DistributionChannel.java diff --git a/src/main/java/org/cryptomator/integrations/common/DistributionChannel.java b/src/main/java/org/cryptomator/integrations/common/DistributionChannel.java new file mode 100644 index 0000000..11498bb --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/common/DistributionChannel.java @@ -0,0 +1,41 @@ +package org.cryptomator.integrations.common; + +import org.jetbrains.annotations.ApiStatus; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Repeatable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE}) +@Repeatable(DistributionChannel.DistributionChannels.class) +@ApiStatus.Experimental +public @interface DistributionChannel { + Value value() default Value.UNKNOWN; + + @Documented + @Retention(RetentionPolicy.RUNTIME) + @Target({ElementType.TYPE}) + @interface DistributionChannels { + DistributionChannel[] value(); + } + + enum Value { + LINUX_APPIMAGE, + LINUX_AUR, + LINUX_FLATPAK, + LINUX_NIXOS, + LINUX_PPA, + MAC_BREW, + MAC_DMG, + WINDOWS_EXE, + WINDOWS_MSI, + WINDOWS_WINGET, + UNKNOWN; + + } +} diff --git a/src/main/java/org/cryptomator/integrations/update/UpdateService.java b/src/main/java/org/cryptomator/integrations/update/UpdateService.java index 6b6a6b5..6b34063 100644 --- a/src/main/java/org/cryptomator/integrations/update/UpdateService.java +++ b/src/main/java/org/cryptomator/integrations/update/UpdateService.java @@ -1,5 +1,6 @@ package org.cryptomator.integrations.update; +import org.cryptomator.integrations.common.DistributionChannel; import org.cryptomator.integrations.common.IntegrationsLoader; import java.util.stream.Stream; @@ -14,19 +15,6 @@ static Stream get() { return IntegrationsLoader.loadAll(UpdateService.class).filter(UpdateService::isSupported); } - enum DistributionChannel { - LINUX_APPIMAGE, - LINUX_AUR, - LINUX_FLATPAK, - LINUX_NIXOS, - LINUX_PPA, - MAC_BREW, - MAC_DMG, - WINDOWS_EXE, - WINDOWS_MSI, - WINDOWS_WINGET - } - /** * @return true if this UppdateService can update the app. * @implSpec This method must not throw any exceptions and should fail fast @@ -37,10 +25,10 @@ enum DistributionChannel { /** * Checks whether the update itself is already published on the given channel. * - * @param channel The {@link DistributionChannel} to check. + * @param channel The DistributionChannel.Value to check. * @return null if an update is not available, the version of the available update as String otherwise. */ - String isUpdateAvailable(DistributionChannel channel); + String isUpdateAvailable(DistributionChannel.Value channel); /** * Trigger updating the app. From a2a6f9853591fe940ed390bb85de69fcedbd5033 Mon Sep 17 00:00:00 2001 From: Ralph Plawetzki Date: Sun, 20 Jul 2025 07:33:26 +0200 Subject: [PATCH 04/23] Drop isUpdateAvailable in favor of getLatestReleaseChecker as it is more flexible --- .../org/cryptomator/integrations/update/UpdateService.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/cryptomator/integrations/update/UpdateService.java b/src/main/java/org/cryptomator/integrations/update/UpdateService.java index 6b34063..6caca78 100644 --- a/src/main/java/org/cryptomator/integrations/update/UpdateService.java +++ b/src/main/java/org/cryptomator/integrations/update/UpdateService.java @@ -23,12 +23,12 @@ static Stream get() { boolean isSupported(); /** - * Checks whether the update itself is already published on the given channel. + * Retrieve an object to check for the latest release published on the given channel. * * @param channel The DistributionChannel.Value to check. - * @return null if an update is not available, the version of the available update as String otherwise. + * @return An object that is capable of checking asynchronously for the latest release. */ - String isUpdateAvailable(DistributionChannel.Value channel); + Object getLatestReleaseChecker(DistributionChannel.Value channel); /** * Trigger updating the app. From f3f3c3581f4127266f9b975f188877f1478297a6 Mon Sep 17 00:00:00 2001 From: Ralph Plawetzki Date: Sun, 20 Jul 2025 10:56:55 +0200 Subject: [PATCH 05/23] Add spawnApp() --- .../org/cryptomator/integrations/update/UpdateService.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main/java/org/cryptomator/integrations/update/UpdateService.java b/src/main/java/org/cryptomator/integrations/update/UpdateService.java index 6caca78..45118d3 100644 --- a/src/main/java/org/cryptomator/integrations/update/UpdateService.java +++ b/src/main/java/org/cryptomator/integrations/update/UpdateService.java @@ -37,6 +37,13 @@ static Stream get() { */ void triggerUpdate() throws UpdateFailedException; + /** + * Start a new instance of the application. + * + * @return The PID of the new process. + */ + long spawnApp(); + /** * A flag indicating whether elevated permissions or sudo is required during update * (so the user can be prepared for a corresponding prompt) From 5f29005f528345d14775374f5e4c1045a3e3c085 Mon Sep 17 00:00:00 2001 From: Ralph Plawetzki Date: Sun, 20 Jul 2025 17:30:33 +0200 Subject: [PATCH 06/23] Add listener interfaces --- .../integrations/update/Progress.java | 19 +++++++++++++ .../integrations/update/ProgressListener.java | 6 ++++ .../integrations/update/UpdateAvailable.java | 13 +++++++++ .../update/UpdateAvailableListener.java | 6 ++++ .../integrations/update/UpdateService.java | 28 +++++++++++++++++++ 5 files changed, 72 insertions(+) create mode 100644 src/main/java/org/cryptomator/integrations/update/Progress.java create mode 100644 src/main/java/org/cryptomator/integrations/update/ProgressListener.java create mode 100644 src/main/java/org/cryptomator/integrations/update/UpdateAvailable.java create mode 100644 src/main/java/org/cryptomator/integrations/update/UpdateAvailableListener.java diff --git a/src/main/java/org/cryptomator/integrations/update/Progress.java b/src/main/java/org/cryptomator/integrations/update/Progress.java new file mode 100644 index 0000000..9aee040 --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/update/Progress.java @@ -0,0 +1,19 @@ +package org.cryptomator.integrations.update; + +public class Progress { + private final long status; + private final long progress; + + public Progress(long status, long progress) { + this.status = status; + this.progress = progress; + } + + public long getStatus() { + return status; + } + + public long getProgress() { + return progress; + } +} diff --git a/src/main/java/org/cryptomator/integrations/update/ProgressListener.java b/src/main/java/org/cryptomator/integrations/update/ProgressListener.java new file mode 100644 index 0000000..d9b8071 --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/update/ProgressListener.java @@ -0,0 +1,6 @@ +package org.cryptomator.integrations.update; + +@FunctionalInterface +public interface ProgressListener { + void onProgress(Progress progress); +} diff --git a/src/main/java/org/cryptomator/integrations/update/UpdateAvailable.java b/src/main/java/org/cryptomator/integrations/update/UpdateAvailable.java new file mode 100644 index 0000000..ff247fa --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/update/UpdateAvailable.java @@ -0,0 +1,13 @@ +package org.cryptomator.integrations.update; + +public class UpdateAvailable { + private final String remoteCommit; + + public UpdateAvailable(String remoteCommit) { + this.remoteCommit = remoteCommit; + } + + public String getRemoteCommit() { + return remoteCommit; + } +} diff --git a/src/main/java/org/cryptomator/integrations/update/UpdateAvailableListener.java b/src/main/java/org/cryptomator/integrations/update/UpdateAvailableListener.java new file mode 100644 index 0000000..7306205 --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/update/UpdateAvailableListener.java @@ -0,0 +1,6 @@ +package org.cryptomator.integrations.update; + +@FunctionalInterface +public interface UpdateAvailableListener { + void onUpdateAvailable(UpdateAvailable updateAvailable); +} diff --git a/src/main/java/org/cryptomator/integrations/update/UpdateService.java b/src/main/java/org/cryptomator/integrations/update/UpdateService.java index 45118d3..acf7eeb 100644 --- a/src/main/java/org/cryptomator/integrations/update/UpdateService.java +++ b/src/main/java/org/cryptomator/integrations/update/UpdateService.java @@ -59,4 +59,32 @@ static Stream get() { * @return The text to describes the update. */ String getDisplayName(); + + /** + * Register a listener to receive update available events. + * + * @param listener The listener to register. + */ + void addUpdateAvailableListener(UpdateAvailableListener listener); + + /** + * Unregister a previously registered update available listener. + * + * @param listener The listener to unregister. + */ + void removeUpdateAvailableListener(UpdateAvailableListener listener); + + /** + * Register a listener to receive update progress events. + * + * @param listener The listener to register. + */ + void addProgressListener(ProgressListener listener); + + /** + * Unregister a previously registered update progress listener. + * + * @param listener The listener to unregister. + */ + void removeProgressListener(ProgressListener listener); } From 28680db758077d4b60508c3e17f20e8f9c2aa010 Mon Sep 17 00:00:00 2001 From: Ralph Plawetzki Date: Tue, 29 Jul 2025 19:15:59 +0200 Subject: [PATCH 07/23] Implement missing signals --- .../integrations/update/Progress.java | 17 +++++++++-- .../integrations/update/SpawnExited.java | 14 +++++++++ .../update/SpawnExitedListener.java | 6 ++++ .../integrations/update/SpawnStarted.java | 14 +++++++++ .../update/SpawnStartedListener.java | 6 ++++ .../integrations/update/UpdateAvailable.java | 12 +++++++- .../integrations/update/UpdateService.java | 29 +++++++++++++++++++ 7 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 src/main/java/org/cryptomator/integrations/update/SpawnExited.java create mode 100644 src/main/java/org/cryptomator/integrations/update/SpawnExitedListener.java create mode 100644 src/main/java/org/cryptomator/integrations/update/SpawnStarted.java create mode 100644 src/main/java/org/cryptomator/integrations/update/SpawnStartedListener.java diff --git a/src/main/java/org/cryptomator/integrations/update/Progress.java b/src/main/java/org/cryptomator/integrations/update/Progress.java index 9aee040..675314e 100644 --- a/src/main/java/org/cryptomator/integrations/update/Progress.java +++ b/src/main/java/org/cryptomator/integrations/update/Progress.java @@ -1,19 +1,32 @@ package org.cryptomator.integrations.update; public class Progress { + private final long nOps; + private final long oP; private final long status; private final long progress; + private final String error; + private final String errorMessage; - public Progress(long status, long progress) { + public Progress(long nOps, long oP, long status, long progress, String error, String errorMessage) { + this.nOps = nOps; + this.oP = oP; this.status = status; this.progress = progress; + this.error = error; + this.errorMessage = errorMessage; } + public long getOP() { return oP; } + public long getNOps() { + return nOps; + } public long getStatus() { return status; } - public long getProgress() { return progress; } + public String getError() { return error; } + public String getErrorMessage() { return errorMessage; } } diff --git a/src/main/java/org/cryptomator/integrations/update/SpawnExited.java b/src/main/java/org/cryptomator/integrations/update/SpawnExited.java new file mode 100644 index 0000000..b42fe56 --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/update/SpawnExited.java @@ -0,0 +1,14 @@ +package org.cryptomator.integrations.update; + +public class SpawnExited { + private final long pid; + private final long exitStatus; + + public SpawnExited(long pid, long exitStatus) { + this.pid = pid; + this.exitStatus = exitStatus; + } + + public long getPid() { return pid; } + public long getExitStatus() { return exitStatus; } +} diff --git a/src/main/java/org/cryptomator/integrations/update/SpawnExitedListener.java b/src/main/java/org/cryptomator/integrations/update/SpawnExitedListener.java new file mode 100644 index 0000000..8a476f6 --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/update/SpawnExitedListener.java @@ -0,0 +1,6 @@ +package org.cryptomator.integrations.update; + +@FunctionalInterface +public interface SpawnExitedListener { + void onSpawnExited(SpawnExited spawnExited); +} diff --git a/src/main/java/org/cryptomator/integrations/update/SpawnStarted.java b/src/main/java/org/cryptomator/integrations/update/SpawnStarted.java new file mode 100644 index 0000000..8851317 --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/update/SpawnStarted.java @@ -0,0 +1,14 @@ +package org.cryptomator.integrations.update; + +public class SpawnStarted { + private final long pid; + private final long relPid; + + public SpawnStarted(long pid, long relPid) { + this.pid = pid; + this.relPid = relPid; + } + + public long getPid() { return pid; } + public long getRelPid() { return relPid; } +} diff --git a/src/main/java/org/cryptomator/integrations/update/SpawnStartedListener.java b/src/main/java/org/cryptomator/integrations/update/SpawnStartedListener.java new file mode 100644 index 0000000..ac2e6bc --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/update/SpawnStartedListener.java @@ -0,0 +1,6 @@ +package org.cryptomator.integrations.update; + +@FunctionalInterface +public interface SpawnStartedListener { + void onSpawnStarted(SpawnStarted spawnStarted); +} diff --git a/src/main/java/org/cryptomator/integrations/update/UpdateAvailable.java b/src/main/java/org/cryptomator/integrations/update/UpdateAvailable.java index ff247fa..c05a95d 100644 --- a/src/main/java/org/cryptomator/integrations/update/UpdateAvailable.java +++ b/src/main/java/org/cryptomator/integrations/update/UpdateAvailable.java @@ -1,12 +1,22 @@ package org.cryptomator.integrations.update; public class UpdateAvailable { + private final String runningCommit; + private final String localCommit; private final String remoteCommit; - public UpdateAvailable(String remoteCommit) { + public UpdateAvailable(String runningCommit, String localCommit, String remoteCommit) { + this.runningCommit = runningCommit; + this.localCommit = localCommit; this.remoteCommit = remoteCommit; } + public String getRunningCommit() { + return runningCommit; + } + public String getLocalCommit() { + return localCommit; + } public String getRemoteCommit() { return remoteCommit; } diff --git a/src/main/java/org/cryptomator/integrations/update/UpdateService.java b/src/main/java/org/cryptomator/integrations/update/UpdateService.java index acf7eeb..da39b32 100644 --- a/src/main/java/org/cryptomator/integrations/update/UpdateService.java +++ b/src/main/java/org/cryptomator/integrations/update/UpdateService.java @@ -87,4 +87,33 @@ static Stream get() { * @param listener The listener to unregister. */ void removeProgressListener(ProgressListener listener); + + /** + * Register a listener to receive an event containing the pid of a spawned process. + * + * @param listener The listener to register. + */ + void addSpawnStartedListener(SpawnStartedListener listener); + + /** + * Unregister a previously registered spawned process listener. + * + * @param listener The listener to unregister. + */ + void removeSpawnStartedListener(SpawnStartedListener listener); + + /** + * Register a listener to receive an event containing the pid + * and exit status of a process that exits. + * + * @param listener The listener to register. + */ + void addSpawnExitedListener(SpawnExitedListener listener); + + /** + * Unregister a previously registered process exits listener. + * + * @param listener The listener to unregister. + */ + void removeSpawnExitedListener(SpawnExitedListener listener); } From eaa63e5fe924d4c79a77da71d5af60b2374d7f2f Mon Sep 17 00:00:00 2001 From: Ralph Plawetzki Date: Sat, 2 Aug 2025 11:29:31 +0200 Subject: [PATCH 08/23] Correlate with API as suggested in a separate PoC: UpdateMechanism and UpdateProcess --- .../common/DistributionChannel.java | 41 -------------- .../integrations/update/Progress.java | 16 +----- .../integrations/update/SpawnExited.java | 14 ----- .../update/SpawnExitedListener.java | 6 --- .../integrations/update/SpawnStarted.java | 14 ----- .../update/SpawnStartedListener.java | 6 --- .../integrations/update/UpdateAvailable.java | 23 -------- .../update/UpdateAvailableListener.java | 6 --- .../integrations/update/UpdateService.java | 54 +------------------ 9 files changed, 2 insertions(+), 178 deletions(-) delete mode 100644 src/main/java/org/cryptomator/integrations/common/DistributionChannel.java delete mode 100644 src/main/java/org/cryptomator/integrations/update/SpawnExited.java delete mode 100644 src/main/java/org/cryptomator/integrations/update/SpawnExitedListener.java delete mode 100644 src/main/java/org/cryptomator/integrations/update/SpawnStarted.java delete mode 100644 src/main/java/org/cryptomator/integrations/update/SpawnStartedListener.java delete mode 100644 src/main/java/org/cryptomator/integrations/update/UpdateAvailable.java delete mode 100644 src/main/java/org/cryptomator/integrations/update/UpdateAvailableListener.java diff --git a/src/main/java/org/cryptomator/integrations/common/DistributionChannel.java b/src/main/java/org/cryptomator/integrations/common/DistributionChannel.java deleted file mode 100644 index 11498bb..0000000 --- a/src/main/java/org/cryptomator/integrations/common/DistributionChannel.java +++ /dev/null @@ -1,41 +0,0 @@ -package org.cryptomator.integrations.common; - -import org.jetbrains.annotations.ApiStatus; - -import java.lang.annotation.Documented; -import java.lang.annotation.ElementType; -import java.lang.annotation.Repeatable; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@Documented -@Retention(RetentionPolicy.RUNTIME) -@Target({ElementType.TYPE}) -@Repeatable(DistributionChannel.DistributionChannels.class) -@ApiStatus.Experimental -public @interface DistributionChannel { - Value value() default Value.UNKNOWN; - - @Documented - @Retention(RetentionPolicy.RUNTIME) - @Target({ElementType.TYPE}) - @interface DistributionChannels { - DistributionChannel[] value(); - } - - enum Value { - LINUX_APPIMAGE, - LINUX_AUR, - LINUX_FLATPAK, - LINUX_NIXOS, - LINUX_PPA, - MAC_BREW, - MAC_DMG, - WINDOWS_EXE, - WINDOWS_MSI, - WINDOWS_WINGET, - UNKNOWN; - - } -} diff --git a/src/main/java/org/cryptomator/integrations/update/Progress.java b/src/main/java/org/cryptomator/integrations/update/Progress.java index 675314e..be7e682 100644 --- a/src/main/java/org/cryptomator/integrations/update/Progress.java +++ b/src/main/java/org/cryptomator/integrations/update/Progress.java @@ -1,32 +1,18 @@ package org.cryptomator.integrations.update; public class Progress { - private final long nOps; - private final long oP; private final long status; private final long progress; - private final String error; - private final String errorMessage; - public Progress(long nOps, long oP, long status, long progress, String error, String errorMessage) { - this.nOps = nOps; - this.oP = oP; + public Progress(long status, long progress) { this.status = status; this.progress = progress; - this.error = error; - this.errorMessage = errorMessage; } - public long getOP() { return oP; } - public long getNOps() { - return nOps; - } public long getStatus() { return status; } public long getProgress() { return progress; } - public String getError() { return error; } - public String getErrorMessage() { return errorMessage; } } diff --git a/src/main/java/org/cryptomator/integrations/update/SpawnExited.java b/src/main/java/org/cryptomator/integrations/update/SpawnExited.java deleted file mode 100644 index b42fe56..0000000 --- a/src/main/java/org/cryptomator/integrations/update/SpawnExited.java +++ /dev/null @@ -1,14 +0,0 @@ -package org.cryptomator.integrations.update; - -public class SpawnExited { - private final long pid; - private final long exitStatus; - - public SpawnExited(long pid, long exitStatus) { - this.pid = pid; - this.exitStatus = exitStatus; - } - - public long getPid() { return pid; } - public long getExitStatus() { return exitStatus; } -} diff --git a/src/main/java/org/cryptomator/integrations/update/SpawnExitedListener.java b/src/main/java/org/cryptomator/integrations/update/SpawnExitedListener.java deleted file mode 100644 index 8a476f6..0000000 --- a/src/main/java/org/cryptomator/integrations/update/SpawnExitedListener.java +++ /dev/null @@ -1,6 +0,0 @@ -package org.cryptomator.integrations.update; - -@FunctionalInterface -public interface SpawnExitedListener { - void onSpawnExited(SpawnExited spawnExited); -} diff --git a/src/main/java/org/cryptomator/integrations/update/SpawnStarted.java b/src/main/java/org/cryptomator/integrations/update/SpawnStarted.java deleted file mode 100644 index 8851317..0000000 --- a/src/main/java/org/cryptomator/integrations/update/SpawnStarted.java +++ /dev/null @@ -1,14 +0,0 @@ -package org.cryptomator.integrations.update; - -public class SpawnStarted { - private final long pid; - private final long relPid; - - public SpawnStarted(long pid, long relPid) { - this.pid = pid; - this.relPid = relPid; - } - - public long getPid() { return pid; } - public long getRelPid() { return relPid; } -} diff --git a/src/main/java/org/cryptomator/integrations/update/SpawnStartedListener.java b/src/main/java/org/cryptomator/integrations/update/SpawnStartedListener.java deleted file mode 100644 index ac2e6bc..0000000 --- a/src/main/java/org/cryptomator/integrations/update/SpawnStartedListener.java +++ /dev/null @@ -1,6 +0,0 @@ -package org.cryptomator.integrations.update; - -@FunctionalInterface -public interface SpawnStartedListener { - void onSpawnStarted(SpawnStarted spawnStarted); -} diff --git a/src/main/java/org/cryptomator/integrations/update/UpdateAvailable.java b/src/main/java/org/cryptomator/integrations/update/UpdateAvailable.java deleted file mode 100644 index c05a95d..0000000 --- a/src/main/java/org/cryptomator/integrations/update/UpdateAvailable.java +++ /dev/null @@ -1,23 +0,0 @@ -package org.cryptomator.integrations.update; - -public class UpdateAvailable { - private final String runningCommit; - private final String localCommit; - private final String remoteCommit; - - public UpdateAvailable(String runningCommit, String localCommit, String remoteCommit) { - this.runningCommit = runningCommit; - this.localCommit = localCommit; - this.remoteCommit = remoteCommit; - } - - public String getRunningCommit() { - return runningCommit; - } - public String getLocalCommit() { - return localCommit; - } - public String getRemoteCommit() { - return remoteCommit; - } -} diff --git a/src/main/java/org/cryptomator/integrations/update/UpdateAvailableListener.java b/src/main/java/org/cryptomator/integrations/update/UpdateAvailableListener.java deleted file mode 100644 index 7306205..0000000 --- a/src/main/java/org/cryptomator/integrations/update/UpdateAvailableListener.java +++ /dev/null @@ -1,6 +0,0 @@ -package org.cryptomator.integrations.update; - -@FunctionalInterface -public interface UpdateAvailableListener { - void onUpdateAvailable(UpdateAvailable updateAvailable); -} diff --git a/src/main/java/org/cryptomator/integrations/update/UpdateService.java b/src/main/java/org/cryptomator/integrations/update/UpdateService.java index da39b32..fefc6f8 100644 --- a/src/main/java/org/cryptomator/integrations/update/UpdateService.java +++ b/src/main/java/org/cryptomator/integrations/update/UpdateService.java @@ -1,6 +1,5 @@ package org.cryptomator.integrations.update; -import org.cryptomator.integrations.common.DistributionChannel; import org.cryptomator.integrations.common.IntegrationsLoader; import java.util.stream.Stream; @@ -25,10 +24,9 @@ static Stream get() { /** * Retrieve an object to check for the latest release published on the given channel. * - * @param channel The DistributionChannel.Value to check. * @return An object that is capable of checking asynchronously for the latest release. */ - Object getLatestReleaseChecker(DistributionChannel.Value channel); + Object getLatestReleaseChecker(); /** * Trigger updating the app. @@ -52,28 +50,6 @@ static Stream get() { */ boolean doesRequireElevatedPermissions(); - /** - * Get a meaningful description of the update available to display it in the app - * like "Update via apt" - * - * @return The text to describes the update. - */ - String getDisplayName(); - - /** - * Register a listener to receive update available events. - * - * @param listener The listener to register. - */ - void addUpdateAvailableListener(UpdateAvailableListener listener); - - /** - * Unregister a previously registered update available listener. - * - * @param listener The listener to unregister. - */ - void removeUpdateAvailableListener(UpdateAvailableListener listener); - /** * Register a listener to receive update progress events. * @@ -88,32 +64,4 @@ static Stream get() { */ void removeProgressListener(ProgressListener listener); - /** - * Register a listener to receive an event containing the pid of a spawned process. - * - * @param listener The listener to register. - */ - void addSpawnStartedListener(SpawnStartedListener listener); - - /** - * Unregister a previously registered spawned process listener. - * - * @param listener The listener to unregister. - */ - void removeSpawnStartedListener(SpawnStartedListener listener); - - /** - * Register a listener to receive an event containing the pid - * and exit status of a process that exits. - * - * @param listener The listener to register. - */ - void addSpawnExitedListener(SpawnExitedListener listener); - - /** - * Unregister a previously registered process exits listener. - * - * @param listener The listener to unregister. - */ - void removeSpawnExitedListener(SpawnExitedListener listener); } From 84076df3e2d35faa4d6ab4dcf7f27e2bca53b0b8 Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Fri, 8 Aug 2025 17:53:16 +0200 Subject: [PATCH 09/23] Reduce API surface --- src/main/java/module-info.java | 5 +- .../update/DownloadUpdateProcess.java | 174 ++++++++++++++++++ .../integrations/update/Progress.java | 18 -- .../integrations/update/ProgressListener.java | 6 - .../update/UpdateFailedException.java | 7 +- .../integrations/update/UpdateMechanism.java | 29 +++ .../integrations/update/UpdateProcess.java | 54 ++++++ .../integrations/update/UpdateService.java | 67 ------- 8 files changed, 266 insertions(+), 94 deletions(-) create mode 100644 src/main/java/org/cryptomator/integrations/update/DownloadUpdateProcess.java delete mode 100644 src/main/java/org/cryptomator/integrations/update/Progress.java delete mode 100644 src/main/java/org/cryptomator/integrations/update/ProgressListener.java create mode 100644 src/main/java/org/cryptomator/integrations/update/UpdateMechanism.java create mode 100644 src/main/java/org/cryptomator/integrations/update/UpdateProcess.java delete mode 100644 src/main/java/org/cryptomator/integrations/update/UpdateService.java diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index f40256a..ea65cc4 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -6,12 +6,13 @@ import org.cryptomator.integrations.keychain.KeychainAccessProvider; import org.cryptomator.integrations.tray.TrayIntegrationProvider; import org.cryptomator.integrations.uiappearance.UiAppearanceProvider; -import org.cryptomator.integrations.update.UpdateService; +import org.cryptomator.integrations.update.UpdateMechanism; module org.cryptomator.integrations.api { requires static org.jetbrains.annotations; requires org.slf4j; + requires java.net.http; exports org.cryptomator.integrations.autostart; exports org.cryptomator.integrations.common; @@ -31,5 +32,5 @@ uses TrayMenuController; uses UiAppearanceProvider; uses QuickAccessService; - uses UpdateService; + uses UpdateMechanism; } \ No newline at end of file diff --git a/src/main/java/org/cryptomator/integrations/update/DownloadUpdateProcess.java b/src/main/java/org/cryptomator/integrations/update/DownloadUpdateProcess.java new file mode 100644 index 0000000..45e5ce1 --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/update/DownloadUpdateProcess.java @@ -0,0 +1,174 @@ +package org.cryptomator.integrations.update; + +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InterruptedIOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.channels.Channels; +import java.nio.channels.FileChannel; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.LongAdder; + +public abstract class DownloadUpdateProcess implements UpdateProcess { + + protected final Path workDir; + private final String downloadFileName; + private final URI uri; + private final byte[] checksum; + private final AtomicLong totalBytes; + private final LongAdder loadedBytes = new LongAdder(); + private final Thread downloadThread; + private final CountDownLatch downloadCompleted = new CountDownLatch(1); + protected volatile IOException downloadException; + + /** + * Creates a new DownloadUpdateProcess instance. + * @param workDir A temporary directory where to download the update file. + * @param downloadFileName The name of the file to which the update will be downloaded + * @param uri The URI from which the update will be downloaded. + * @param checksum (optional) The expected SHA-256 checksum of the downloaded file, can be null if not required. + * @param estDownloadSize The estimated size of the download in bytes. + */ + protected DownloadUpdateProcess(Path workDir, String downloadFileName, URI uri, byte[] checksum, long estDownloadSize) { + this.workDir = workDir; + this.downloadFileName = downloadFileName; + this.uri = uri; + this.checksum = checksum; + this.totalBytes = new AtomicLong(estDownloadSize); + this.downloadThread = Thread.ofVirtual().start(this::download); + } + + @Override + public double preparationProgress() { + return (double) loadedBytes.sum() / totalBytes.get(); + } + + @Override + public void await() throws InterruptedException { + downloadCompleted.await(); + } + + @Override + public boolean await(long timeout, TimeUnit unit) throws InterruptedException { + return downloadCompleted.await(timeout, unit); + } + + protected boolean isDone() { + try { + return downloadCompleted.await(0, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + + @Override + public void cancel() { + downloadThread.interrupt(); + } + + private void download() { + try { + download(workDir.resolve(downloadFileName)); + } catch (IOException e) { + // TODO: eventually handle this via structured concurrency? + downloadException = e; + } finally { + downloadCompleted.countDown(); + } + } + + /** + * Downloads the update from the given URI and saves it to the specified filename in the working directory. + * @param downloadPath The path to where to save the downloaded file. + * @throws IOException indicating I/O errors during the download or file writing process or due to checksum mismatch + */ + protected void download(Path downloadPath) throws IOException { + var request = HttpRequest.newBuilder().uri(uri).GET().build(); + try (HttpClient client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.ALWAYS).build()) { // TODO: make http client injectable? + // make download request + var response = client.send(request, HttpResponse.BodyHandlers.ofInputStream()); + if (response.statusCode() != 200) { + throw new IOException("Failed to download update, status code: " + response.statusCode()); + } + + // update totalBytes + response.headers().firstValueAsLong("Content-Length").ifPresent(totalBytes::set); + + // prepare checksum calculation + MessageDigest sha256; + try { + sha256 = MessageDigest.getInstance("SHA-256"); // Initialize SHA-256 digest, not used here but can be extended for checksum validation + } catch (NoSuchAlgorithmException e) { + throw new AssertionError("Every implementation of the Java platform is required to support [...] SHA-256", e); + } + + // write bytes to file + try (var in = new DownloadInputStream(response.body(), loadedBytes, sha256); + var src = Channels.newChannel(in); + var dst = FileChannel.open(downloadPath, StandardOpenOption.WRITE, StandardOpenOption.CREATE_NEW)) { + dst.transferFrom(src, 0, totalBytes.get()); + } + + // verify checksum if provided + byte[] calculatedChecksum = sha256.digest(); + if (!MessageDigest.isEqual(calculatedChecksum, checksum)) { + throw new IOException("Checksum verification failed for downloaded file: " + downloadPath); + } + + // post-download processing + postDownload(downloadPath); + } catch (InterruptedException e) { + throw new InterruptedIOException("Download interrupted"); + } + } + + protected void postDownload(Path downloadPath) throws IOException { + // Default implementation does nothing, can be overridden by subclasses for specific post-download actions + } + + /** + * An InputStream decorator that counts the number of bytes read and updates a MessageDigest for checksum calculation. + */ + private static class DownloadInputStream extends FilterInputStream { + + private final LongAdder counter; + private final MessageDigest digest; + + protected DownloadInputStream(InputStream in, LongAdder counter, MessageDigest digest) { + super(in); + this.counter = counter; + this.digest = digest; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + int n = super.read(b, off, len); + digest.update(b, off, n); + counter.add(n); + return n; + } + + @Override + public int read() throws IOException { + int b = super.read(); + if (b != -1) { + digest.update((byte) b); + counter.increment(); + } + return b; + } + + } + +} diff --git a/src/main/java/org/cryptomator/integrations/update/Progress.java b/src/main/java/org/cryptomator/integrations/update/Progress.java deleted file mode 100644 index be7e682..0000000 --- a/src/main/java/org/cryptomator/integrations/update/Progress.java +++ /dev/null @@ -1,18 +0,0 @@ -package org.cryptomator.integrations.update; - -public class Progress { - private final long status; - private final long progress; - - public Progress(long status, long progress) { - this.status = status; - this.progress = progress; - } - - public long getStatus() { - return status; - } - public long getProgress() { - return progress; - } -} diff --git a/src/main/java/org/cryptomator/integrations/update/ProgressListener.java b/src/main/java/org/cryptomator/integrations/update/ProgressListener.java deleted file mode 100644 index d9b8071..0000000 --- a/src/main/java/org/cryptomator/integrations/update/ProgressListener.java +++ /dev/null @@ -1,6 +0,0 @@ -package org.cryptomator.integrations.update; - -@FunctionalInterface -public interface ProgressListener { - void onProgress(Progress progress); -} diff --git a/src/main/java/org/cryptomator/integrations/update/UpdateFailedException.java b/src/main/java/org/cryptomator/integrations/update/UpdateFailedException.java index 639fcc8..8d4d582 100644 --- a/src/main/java/org/cryptomator/integrations/update/UpdateFailedException.java +++ b/src/main/java/org/cryptomator/integrations/update/UpdateFailedException.java @@ -1,6 +1,11 @@ package org.cryptomator.integrations.update; -public class UpdateFailedException extends Exception { +import org.jetbrains.annotations.ApiStatus; + +import java.io.IOException; + +@ApiStatus.Experimental +public class UpdateFailedException extends IOException { public UpdateFailedException(String message) { super(message); diff --git a/src/main/java/org/cryptomator/integrations/update/UpdateMechanism.java b/src/main/java/org/cryptomator/integrations/update/UpdateMechanism.java new file mode 100644 index 0000000..f6a9ec6 --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/update/UpdateMechanism.java @@ -0,0 +1,29 @@ +package org.cryptomator.integrations.update; + +import org.cryptomator.integrations.common.IntegrationsLoader; +import org.cryptomator.integrations.common.NamedServiceProvider; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Blocking; + +@ApiStatus.Experimental +public interface UpdateMechanism extends NamedServiceProvider { + + static UpdateMechanism get() { + return IntegrationsLoader.load(UpdateMechanism.class).orElseThrow(); // Fallback "show download page" mechanism always available. + } + + /** + * Checks whether an update is available. + * @return true if an update is available, false otherwise. + */ + @Blocking + boolean isUpdateAvailable(); // TODO: let it throw? + + /** + * Performs as much as possible to prepare the update. This may include downloading the update, checking signatures, etc. + * @return a new {@link UpdateProcess} that can be used to monitor the progress of the update preparation. The task will complete when the preparation is done. + * @throws UpdateFailedException If no update process can be started, e.g. due to network or I/O issues. + */ + UpdateProcess prepareUpdate() throws UpdateFailedException; + +} \ No newline at end of file diff --git a/src/main/java/org/cryptomator/integrations/update/UpdateProcess.java b/src/main/java/org/cryptomator/integrations/update/UpdateProcess.java new file mode 100644 index 0000000..41f3edd --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/update/UpdateProcess.java @@ -0,0 +1,54 @@ +package org.cryptomator.integrations.update; + +import org.jetbrains.annotations.ApiStatus; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +@ApiStatus.Experimental +public interface UpdateProcess { + + /** + * A thread-safe method to check the progress of the update preparation. + * @return a value between 0.0 and 1.0 indicating the progress of the update preparation. + */ + double preparationProgress(); + + /** + * Cancels the update process and cleans up any resources that were used during the preparation. + */ + void cancel(); + + /** + * Blocks the current thread until the update preparation is complete or an error occurs. + *

+ * If the preparation is already complete, this method returns immediately. + * + * @throws InterruptedException if the current thread is interrupted while waiting. + */ + void await() throws InterruptedException; + + /** + * Blocks the current thread until the update preparation is complete or an error occurs, or until the specified timeout expires. + *

+ * If the preparation is already complete, this method returns immediately. + * + * @param timeout the maximum time to wait + * @param unit the time unit of the {@code timeout} argument + * @return true if the update is prepared + */ + boolean await(long timeout, TimeUnit unit) throws InterruptedException; + + /** + * Once the update preparation is complete, this method can be called to launch the external update process. + *

+ * This method shall be called after making sure that the application is ready to be restarted, e.g. after locking all vaults. + * + * @return a {@link ProcessHandle} that represents the external update process. + * @throws IllegalStateException if the update preparation is not complete or if the update process cannot be launched. + * @throws IOException if the update preparation failed or starting the update process failed + */ + ProcessHandle applyUpdate() throws IllegalStateException, IOException; + + +} \ No newline at end of file diff --git a/src/main/java/org/cryptomator/integrations/update/UpdateService.java b/src/main/java/org/cryptomator/integrations/update/UpdateService.java deleted file mode 100644 index fefc6f8..0000000 --- a/src/main/java/org/cryptomator/integrations/update/UpdateService.java +++ /dev/null @@ -1,67 +0,0 @@ -package org.cryptomator.integrations.update; - -import org.cryptomator.integrations.common.IntegrationsLoader; - -import java.util.stream.Stream; - -/** - * This is the interface used by Cryptomator to provide a way to update Cryptomator in a convinient way. - * It's idependent of the supported platforms and package distribution channels. - */ -public interface UpdateService { - - static Stream get() { - return IntegrationsLoader.loadAll(UpdateService.class).filter(UpdateService::isSupported); - } - - /** - * @return true if this UppdateService can update the app. - * @implSpec This method must not throw any exceptions and should fail fast - * returning false if it's not possible to use this UppdateService - */ - boolean isSupported(); - - /** - * Retrieve an object to check for the latest release published on the given channel. - * - * @return An object that is capable of checking asynchronously for the latest release. - */ - Object getLatestReleaseChecker(); - - /** - * Trigger updating the app. - * - * @throws UpdateFailedException If the udpate wasn't successful or was cancelled. - */ - void triggerUpdate() throws UpdateFailedException; - - /** - * Start a new instance of the application. - * - * @return The PID of the new process. - */ - long spawnApp(); - - /** - * A flag indicating whether elevated permissions or sudo is required during update - * (so the user can be prepared for a corresponding prompt) - * - * @return true if elevated permissions are required, false otherwise. - */ - boolean doesRequireElevatedPermissions(); - - /** - * Register a listener to receive update progress events. - * - * @param listener The listener to register. - */ - void addProgressListener(ProgressListener listener); - - /** - * Unregister a previously registered update progress listener. - * - * @param listener The listener to unregister. - */ - void removeProgressListener(ProgressListener listener); - -} From dde78a1a2f9a1acd8bc6efc3354521ff2d7ac671 Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Sat, 9 Aug 2025 11:41:31 +0200 Subject: [PATCH 10/23] API refinement --- .../update/DownloadUpdateProcess.java | 23 +++-- .../integrations/update/SemVerComparator.java | 84 +++++++++++++++++++ .../integrations/update/UpdateMechanism.java | 19 ++++- .../integrations/update/UpdateProcess.java | 2 +- .../update/SemVerComparatorTest.java | 77 +++++++++++++++++ 5 files changed, 197 insertions(+), 8 deletions(-) create mode 100644 src/main/java/org/cryptomator/integrations/update/SemVerComparator.java create mode 100644 src/test/java/org/cryptomator/integrations/update/SemVerComparatorTest.java diff --git a/src/main/java/org/cryptomator/integrations/update/DownloadUpdateProcess.java b/src/main/java/org/cryptomator/integrations/update/DownloadUpdateProcess.java index 45e5ce1..6c3d15c 100644 --- a/src/main/java/org/cryptomator/integrations/update/DownloadUpdateProcess.java +++ b/src/main/java/org/cryptomator/integrations/update/DownloadUpdateProcess.java @@ -45,12 +45,21 @@ protected DownloadUpdateProcess(Path workDir, String downloadFileName, URI uri, this.uri = uri; this.checksum = checksum; this.totalBytes = new AtomicLong(estDownloadSize); - this.downloadThread = Thread.ofVirtual().start(this::download); + this.downloadThread = Thread.ofVirtual().unstarted(this::download); + } + + protected void startDownload() { + downloadThread.start(); } @Override public double preparationProgress() { - return (double) loadedBytes.sum() / totalBytes.get(); + long total = totalBytes.get(); + if (total <= 0) { + return -1.0; + } else { + return (double) loadedBytes.sum() / totalBytes.get(); + } } @Override @@ -117,12 +126,12 @@ protected void download(Path downloadPath) throws IOException { try (var in = new DownloadInputStream(response.body(), loadedBytes, sha256); var src = Channels.newChannel(in); var dst = FileChannel.open(downloadPath, StandardOpenOption.WRITE, StandardOpenOption.CREATE_NEW)) { - dst.transferFrom(src, 0, totalBytes.get()); + dst.transferFrom(src, 0, Long.MAX_VALUE); } // verify checksum if provided byte[] calculatedChecksum = sha256.digest(); - if (!MessageDigest.isEqual(calculatedChecksum, checksum)) { + if (checksum != null && !MessageDigest.isEqual(calculatedChecksum, checksum)) { throw new IOException("Checksum verification failed for downloaded file: " + downloadPath); } @@ -154,8 +163,10 @@ protected DownloadInputStream(InputStream in, LongAdder counter, MessageDigest d @Override public int read(byte[] b, int off, int len) throws IOException { int n = super.read(b, off, len); - digest.update(b, off, n); - counter.add(n); + if (n == -1) { + digest.update(b, off, n); + counter.add(n); + } return n; } diff --git a/src/main/java/org/cryptomator/integrations/update/SemVerComparator.java b/src/main/java/org/cryptomator/integrations/update/SemVerComparator.java new file mode 100644 index 0000000..aff1ca8 --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/update/SemVerComparator.java @@ -0,0 +1,84 @@ +package org.cryptomator.integrations.update; + +import java.util.Comparator; +import java.util.regex.Pattern; + +/** + * Compares version strings according to SemVer 2.0.0. + */ +public class SemVerComparator implements Comparator { + + public static final SemVerComparator INSTANCE = new SemVerComparator(); + + private static final Pattern VERSION_SEP = Pattern.compile("\\."); // http://semver.org/spec/v2.0.0.html#spec-item-2 + private static final String PRE_RELEASE_SEP = "-"; // http://semver.org/spec/v2.0.0.html#spec-item-9 + private static final String BUILD_SEP = "+"; // http://semver.org/spec/v2.0.0.html#spec-item-10 + + @Override + public int compare(String version1, String version2) { + // "Build metadata SHOULD be ignored when determining version precedence. + // Thus, two versions that differ only in the build metadata, have the same precedence." + String trimmedV1 = substringBefore(version1, BUILD_SEP); + String trimmedV2 = substringBefore(version2, BUILD_SEP); + + if (trimmedV1.equals(trimmedV2)) { + return 0; + } + + String v1MajorMinorPatch = substringBefore(trimmedV1, PRE_RELEASE_SEP); + String v2MajorMinorPatch = substringBefore(trimmedV2, PRE_RELEASE_SEP); + String v1PreReleaseVersion = substringAfter(trimmedV1, PRE_RELEASE_SEP); + String v2PreReleaseVersion = substringAfter(trimmedV2, PRE_RELEASE_SEP); + return compare(v1MajorMinorPatch, v1PreReleaseVersion, v2MajorMinorPatch, v2PreReleaseVersion); + } + + private static int compare(String v1MajorMinorPatch, String v1PreReleaseVersion, String v2MajorMinorPatch, String v2PreReleaseVersion) { + int comparisonResult = compareNumericallyThenLexicographically(v1MajorMinorPatch, v2MajorMinorPatch); + if (comparisonResult == 0) { + if (v1PreReleaseVersion.isEmpty()) { + return 1; // 1.0.0 > 1.0.0-BETA + } else if (v2PreReleaseVersion.isEmpty()) { + return -1; // 1.0.0-BETA < 1.0.0 + } else { + return compareNumericallyThenLexicographically(v1PreReleaseVersion, v2PreReleaseVersion); + } + } else { + return comparisonResult; + } + } + + private static int compareNumericallyThenLexicographically(String version1, String version2) { + final String[] vComps1 = VERSION_SEP.split(version1); + final String[] vComps2 = VERSION_SEP.split(version2); + final int commonCompCount = Math.min(vComps1.length, vComps2.length); + + for (int i = 0; i < commonCompCount; i++) { + int subversionComparisonResult; + try { + final int v1 = Integer.parseInt(vComps1[i]); + final int v2 = Integer.parseInt(vComps2[i]); + subversionComparisonResult = v1 - v2; + } catch (NumberFormatException ex) { + // ok, lets compare this fragment lexicographically + subversionComparisonResult = vComps1[i].compareTo(vComps2[i]); + } + if (subversionComparisonResult != 0) { + return subversionComparisonResult; + } + } + + // all in common so far? longest version string is considered the higher version: + return vComps1.length - vComps2.length; + } + + private static String substringBefore(String str, String separator) { + int index = str.indexOf(separator); + return index == -1 ? str : str.substring(0, index); + } + + private static String substringAfter(String str, String separator) { + int index = str.indexOf(separator); + return index == -1 ? "" : str.substring(index + separator.length()); + } + +} \ No newline at end of file diff --git a/src/main/java/org/cryptomator/integrations/update/UpdateMechanism.java b/src/main/java/org/cryptomator/integrations/update/UpdateMechanism.java index f6a9ec6..2630adf 100644 --- a/src/main/java/org/cryptomator/integrations/update/UpdateMechanism.java +++ b/src/main/java/org/cryptomator/integrations/update/UpdateMechanism.java @@ -9,15 +9,32 @@ public interface UpdateMechanism extends NamedServiceProvider { static UpdateMechanism get() { + // TODO: load preferred udpate mechanism, if specified in system properties. return IntegrationsLoader.load(UpdateMechanism.class).orElseThrow(); // Fallback "show download page" mechanism always available. } + /** + * Checks whether an update is available by comparing the given version strings. + * @param updateVersion The version string of the update, e.g. "1.2.3". + * @param installedVersion The version string of the currently installed application, e.g. "1.2.3-beta4". + * @return true if an update is available, false otherwise. Always true for SNAPSHOT versions. + */ + static boolean isUpdateAvailable(String updateVersion, String installedVersion) { + if (installedVersion.contains("SNAPSHOT")) { + return true; // SNAPSHOT versions are always considered to be outdated. + } else { + return SemVerComparator.INSTANCE.compare(updateVersion, installedVersion) > 0; + } + } + /** * Checks whether an update is available. + * @param currentVersion The full version string of the currently installed application, e.g. "1.2.3-beta4". * @return true if an update is available, false otherwise. + * @throws UpdateFailedException If the availability of an update could not be determined */ @Blocking - boolean isUpdateAvailable(); // TODO: let it throw? + boolean isUpdateAvailable(String currentVersion) throws UpdateFailedException; /** * Performs as much as possible to prepare the update. This may include downloading the update, checking signatures, etc. diff --git a/src/main/java/org/cryptomator/integrations/update/UpdateProcess.java b/src/main/java/org/cryptomator/integrations/update/UpdateProcess.java index 41f3edd..1b5811a 100644 --- a/src/main/java/org/cryptomator/integrations/update/UpdateProcess.java +++ b/src/main/java/org/cryptomator/integrations/update/UpdateProcess.java @@ -10,7 +10,7 @@ public interface UpdateProcess { /** * A thread-safe method to check the progress of the update preparation. - * @return a value between 0.0 and 1.0 indicating the progress of the update preparation. + * @return a value between 0.0 and 1.0 indicating the progress of the update preparation or -1.0 indicating indeterminate progress. */ double preparationProgress(); diff --git a/src/test/java/org/cryptomator/integrations/update/SemVerComparatorTest.java b/src/test/java/org/cryptomator/integrations/update/SemVerComparatorTest.java new file mode 100644 index 0000000..a83ecdf --- /dev/null +++ b/src/test/java/org/cryptomator/integrations/update/SemVerComparatorTest.java @@ -0,0 +1,77 @@ +package org.cryptomator.integrations.update; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.util.Comparator; + +public class SemVerComparatorTest { + + private final Comparator semVerComparator = SemVerComparator.INSTANCE; + + // equal versions + + @ParameterizedTest + @CsvSource({ + "1.23.4, 1.23.4", + "1.23.4-alpha, 1.23.4-alpha", + "1.23.4+20170101, 1.23.4+20171231", + "1.23.4-alpha+20170101, 1.23.4-alpha+20171231" + }) + public void compareEqualVersions(String left, String right) { + Assertions.assertEquals(0, Integer.signum(semVerComparator.compare(left, right))); + } + + // newer versions in first argument + + @ParameterizedTest + @CsvSource({ + "1.23.5, 1.23.4", + "1.24.4, 1.23.4", + "1.23.4, 1.23", + "1.23.4, 1.23.4-SNAPSHOT", + "1.23.4, 1.23.4-56.78", + "1.23.4-beta, 1.23.4-alpha", + "1.23.4-alpha.1, 1.23.4-alpha", + "1.23.4-56.79, 1.23.4-56.78" + }) + public void compareHigherToLowerVersions(String higherVersion, String lowerVersion) { + Assertions.assertEquals(1, Integer.signum(semVerComparator.compare(higherVersion, lowerVersion))); + } + + // newer versions in second argument + + @ParameterizedTest + @CsvSource({ + "1.23.4, 1.23.5", + "1.23.4, 1.24.4", + "1.23, 1.23.4", + "1.23.4-SNAPSHOT, 1.23.4", + "1.23.4-56.78, 1.23.4", + "1.23.4-alpha, 1.23.4-beta", + "1.23.4-alpha, 1.23.4-alpha.1", + "1.23.4-56.78, 1.23.4-56.79" + }) + public void compareLowerToHigherVersions(String lowerVersion, String higherVersion) { + Assertions.assertEquals(-1, Integer.signum(semVerComparator.compare(lowerVersion, higherVersion))); + } + + // test vector from https://semver.org/spec/v2.0.0.html#spec-item-11: + // Example: 1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-alpha.beta < 1.0.0-beta < 1.0.0-beta.2 < 1.0.0-beta.11 < 1.0.0-rc.1 < 1.0.0. + @ParameterizedTest + @CsvSource({ + "1.0.0-alpha, 1.0.0-alpha.1", + "1.0.0-alpha.1, 1.0.0-alpha.beta", + "1.0.0-alpha.beta, 1.0.0-beta", + "1.0.0-beta, 1.0.0-beta.2", + "1.0.0-beta.2, 1.0.0-beta.11", + "1.0.0-beta.11, 1.0.0-rc.1", + "1.0.0-rc.1, 1.0.0" + }) + public void testPrecedenceSpec(String left, String right) { + Assertions.assertEquals(-1, Integer.signum(semVerComparator.compare(left, right))); + } + +} From 0f765d6dced03c6f1c5acaac8b0dcf81f60d2f76 Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Sat, 9 Aug 2025 12:07:22 +0200 Subject: [PATCH 11/23] added test case --- .../integrations/update/SemVerComparatorTest.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/test/java/org/cryptomator/integrations/update/SemVerComparatorTest.java b/src/test/java/org/cryptomator/integrations/update/SemVerComparatorTest.java index a83ecdf..ac080d8 100644 --- a/src/test/java/org/cryptomator/integrations/update/SemVerComparatorTest.java +++ b/src/test/java/org/cryptomator/integrations/update/SemVerComparatorTest.java @@ -35,7 +35,8 @@ public void compareEqualVersions(String left, String right) { "1.23.4, 1.23.4-56.78", "1.23.4-beta, 1.23.4-alpha", "1.23.4-alpha.1, 1.23.4-alpha", - "1.23.4-56.79, 1.23.4-56.78" + "1.23.4-56.79, 1.23.4-56.78", + "1.23.4-alpha, 1.23.4-1", }) public void compareHigherToLowerVersions(String higherVersion, String lowerVersion) { Assertions.assertEquals(1, Integer.signum(semVerComparator.compare(higherVersion, lowerVersion))); @@ -52,7 +53,8 @@ public void compareHigherToLowerVersions(String higherVersion, String lowerVersi "1.23.4-56.78, 1.23.4", "1.23.4-alpha, 1.23.4-beta", "1.23.4-alpha, 1.23.4-alpha.1", - "1.23.4-56.78, 1.23.4-56.79" + "1.23.4-56.78, 1.23.4-56.79", + "1.23.4-1, 1.23.4-alpha", }) public void compareLowerToHigherVersions(String lowerVersion, String higherVersion) { Assertions.assertEquals(-1, Integer.signum(semVerComparator.compare(lowerVersion, higherVersion))); From 5dadcbe613df2f69c05fdcb614f92df4865629ae Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Tue, 21 Oct 2025 09:26:13 +0200 Subject: [PATCH 12/23] multi-step update api --- .../common/IntegrationsLoader.java | 13 +++ ...teProcess.java => DownloadUpdateStep.java} | 69 ++++++------- .../integrations/update/UpdateMechanism.java | 16 ++-- .../integrations/update/UpdateProcess.java | 54 ----------- .../integrations/update/UpdateStep.java | 96 +++++++++++++++++++ .../update/UpdateStepAdapter.java | 65 +++++++++++++ 6 files changed, 214 insertions(+), 99 deletions(-) rename src/main/java/org/cryptomator/integrations/update/{DownloadUpdateProcess.java => DownloadUpdateStep.java} (72%) delete mode 100644 src/main/java/org/cryptomator/integrations/update/UpdateProcess.java create mode 100644 src/main/java/org/cryptomator/integrations/update/UpdateStep.java create mode 100644 src/main/java/org/cryptomator/integrations/update/UpdateStepAdapter.java diff --git a/src/main/java/org/cryptomator/integrations/common/IntegrationsLoader.java b/src/main/java/org/cryptomator/integrations/common/IntegrationsLoader.java index a75febd..c0f5116 100644 --- a/src/main/java/org/cryptomator/integrations/common/IntegrationsLoader.java +++ b/src/main/java/org/cryptomator/integrations/common/IntegrationsLoader.java @@ -36,6 +36,19 @@ public static Optional load(Class clazz) { return loadAll(clazz).findFirst(); } + /** + * Loads a specific service provider by its implementation class name. + * @param clazz Service class + * @param implementationClassName fully qualified class name of the implementation + * @return Optional of the service provider if found + * @param Type of the service + */ + public static Optional loadSpecific(Class clazz, String implementationClassName) { + return ServiceLoader.load(clazz, ClassLoaderFactory.forPluginDir()).stream() + .filter(provider -> provider.type().getName().equals(implementationClassName)) + .map(ServiceLoader.Provider::get) + .findAny(); + } /** * Loads all suited service providers ordered by priority in descending order. diff --git a/src/main/java/org/cryptomator/integrations/update/DownloadUpdateProcess.java b/src/main/java/org/cryptomator/integrations/update/DownloadUpdateStep.java similarity index 72% rename from src/main/java/org/cryptomator/integrations/update/DownloadUpdateProcess.java rename to src/main/java/org/cryptomator/integrations/update/DownloadUpdateStep.java index 6c3d15c..8e6b415 100644 --- a/src/main/java/org/cryptomator/integrations/update/DownloadUpdateProcess.java +++ b/src/main/java/org/cryptomator/integrations/update/DownloadUpdateStep.java @@ -19,11 +19,10 @@ import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.LongAdder; -public abstract class DownloadUpdateProcess implements UpdateProcess { +public abstract class DownloadUpdateStep implements UpdateStep { - protected final Path workDir; - private final String downloadFileName; - private final URI uri; + protected final URI source; + protected final Path destination; private final byte[] checksum; private final AtomicLong totalBytes; private final LongAdder loadedBytes = new LongAdder(); @@ -33,22 +32,30 @@ public abstract class DownloadUpdateProcess implements UpdateProcess { /** * Creates a new DownloadUpdateProcess instance. - * @param workDir A temporary directory where to download the update file. - * @param downloadFileName The name of the file to which the update will be downloaded - * @param uri The URI from which the update will be downloaded. + * @param source The URI from which the update will be downloaded. + * @param destination The path to theworking directory where the downloaded file will be saved. * @param checksum (optional) The expected SHA-256 checksum of the downloaded file, can be null if not required. * @param estDownloadSize The estimated size of the download in bytes. */ - protected DownloadUpdateProcess(Path workDir, String downloadFileName, URI uri, byte[] checksum, long estDownloadSize) { - this.workDir = workDir; - this.downloadFileName = downloadFileName; - this.uri = uri; + protected DownloadUpdateStep(URI source, Path destination, byte[] checksum, long estDownloadSize) { + this.source = source; + this.destination = destination; this.checksum = checksum; this.totalBytes = new AtomicLong(estDownloadSize); this.downloadThread = Thread.ofVirtual().unstarted(this::download); } - protected void startDownload() { + @Override + public String description() { + return switch (downloadThread.getState()) { + case NEW -> "Download... "; + case TERMINATED -> "Downloaded."; + default -> "Downloading... %1.0f%%".formatted(preparationProgress() * 100); + }; + } + + @Override + public void start() { downloadThread.start(); } @@ -72,25 +79,16 @@ public boolean await(long timeout, TimeUnit unit) throws InterruptedException { return downloadCompleted.await(timeout, unit); } - protected boolean isDone() { - try { - return downloadCompleted.await(0, TimeUnit.MILLISECONDS); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return false; - } - } - @Override public void cancel() { downloadThread.interrupt(); } - private void download() { - try { - download(workDir.resolve(downloadFileName)); + protected void download() { + var request = HttpRequest.newBuilder().uri(source).GET().build(); + try (HttpClient client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.ALWAYS).build()) { + downloadInternal(client, request); } catch (IOException e) { - // TODO: eventually handle this via structured concurrency? downloadException = e; } finally { downloadCompleted.countDown(); @@ -99,12 +97,12 @@ private void download() { /** * Downloads the update from the given URI and saves it to the specified filename in the working directory. - * @param downloadPath The path to where to save the downloaded file. + * @param client the HttpClient to use for the download + * @param request the HttpRequest which downloads the file * @throws IOException indicating I/O errors during the download or file writing process or due to checksum mismatch */ - protected void download(Path downloadPath) throws IOException { - var request = HttpRequest.newBuilder().uri(uri).GET().build(); - try (HttpClient client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.ALWAYS).build()) { // TODO: make http client injectable? + protected void downloadInternal(HttpClient client, HttpRequest request) throws IOException { + try { // make download request var response = client.send(request, HttpResponse.BodyHandlers.ofInputStream()); if (response.statusCode() != 200) { @@ -125,27 +123,20 @@ protected void download(Path downloadPath) throws IOException { // write bytes to file try (var in = new DownloadInputStream(response.body(), loadedBytes, sha256); var src = Channels.newChannel(in); - var dst = FileChannel.open(downloadPath, StandardOpenOption.WRITE, StandardOpenOption.CREATE_NEW)) { + var dst = FileChannel.open(destination, StandardOpenOption.WRITE, StandardOpenOption.CREATE_NEW)) { dst.transferFrom(src, 0, Long.MAX_VALUE); } // verify checksum if provided byte[] calculatedChecksum = sha256.digest(); if (checksum != null && !MessageDigest.isEqual(calculatedChecksum, checksum)) { - throw new IOException("Checksum verification failed for downloaded file: " + downloadPath); + throw new IOException("Checksum verification failed for downloaded file: " + destination); } - - // post-download processing - postDownload(downloadPath); } catch (InterruptedException e) { throw new InterruptedIOException("Download interrupted"); } } - protected void postDownload(Path downloadPath) throws IOException { - // Default implementation does nothing, can be overridden by subclasses for specific post-download actions - } - /** * An InputStream decorator that counts the number of bytes read and updates a MessageDigest for checksum calculation. */ @@ -163,7 +154,7 @@ protected DownloadInputStream(InputStream in, LongAdder counter, MessageDigest d @Override public int read(byte[] b, int off, int len) throws IOException { int n = super.read(b, off, len); - if (n == -1) { + if (n != -1) { digest.update(b, off, n); counter.add(n); } diff --git a/src/main/java/org/cryptomator/integrations/update/UpdateMechanism.java b/src/main/java/org/cryptomator/integrations/update/UpdateMechanism.java index 2630adf..af2c7e0 100644 --- a/src/main/java/org/cryptomator/integrations/update/UpdateMechanism.java +++ b/src/main/java/org/cryptomator/integrations/update/UpdateMechanism.java @@ -5,12 +5,16 @@ import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Blocking; +import java.util.Optional; + @ApiStatus.Experimental public interface UpdateMechanism extends NamedServiceProvider { - static UpdateMechanism get() { - // TODO: load preferred udpate mechanism, if specified in system properties. - return IntegrationsLoader.load(UpdateMechanism.class).orElseThrow(); // Fallback "show download page" mechanism always available. + String UPDATE_MECHANISM_PROPERTY = "cryptomator.updateMechanism"; + + static Optional get() { + return Optional.ofNullable(System.getProperty(UPDATE_MECHANISM_PROPERTY)) + .flatMap(name -> IntegrationsLoader.loadSpecific(UpdateMechanism.class, name)); } /** @@ -37,10 +41,10 @@ static boolean isUpdateAvailable(String updateVersion, String installedVersion) boolean isUpdateAvailable(String currentVersion) throws UpdateFailedException; /** - * Performs as much as possible to prepare the update. This may include downloading the update, checking signatures, etc. - * @return a new {@link UpdateProcess} that can be used to monitor the progress of the update preparation. The task will complete when the preparation is done. + * Returns the first step to prepare the update. This can be anything like downloading the update, checking signatures, etc. + * @return a new {@link UpdateStep} that can be used to monitor the progress of the update preparation. The task will complete when the preparation is done. * @throws UpdateFailedException If no update process can be started, e.g. due to network or I/O issues. */ - UpdateProcess prepareUpdate() throws UpdateFailedException; + UpdateStep firstStep() throws UpdateFailedException; } \ No newline at end of file diff --git a/src/main/java/org/cryptomator/integrations/update/UpdateProcess.java b/src/main/java/org/cryptomator/integrations/update/UpdateProcess.java deleted file mode 100644 index 1b5811a..0000000 --- a/src/main/java/org/cryptomator/integrations/update/UpdateProcess.java +++ /dev/null @@ -1,54 +0,0 @@ -package org.cryptomator.integrations.update; - -import org.jetbrains.annotations.ApiStatus; - -import java.io.IOException; -import java.util.concurrent.TimeUnit; - -@ApiStatus.Experimental -public interface UpdateProcess { - - /** - * A thread-safe method to check the progress of the update preparation. - * @return a value between 0.0 and 1.0 indicating the progress of the update preparation or -1.0 indicating indeterminate progress. - */ - double preparationProgress(); - - /** - * Cancels the update process and cleans up any resources that were used during the preparation. - */ - void cancel(); - - /** - * Blocks the current thread until the update preparation is complete or an error occurs. - *

- * If the preparation is already complete, this method returns immediately. - * - * @throws InterruptedException if the current thread is interrupted while waiting. - */ - void await() throws InterruptedException; - - /** - * Blocks the current thread until the update preparation is complete or an error occurs, or until the specified timeout expires. - *

- * If the preparation is already complete, this method returns immediately. - * - * @param timeout the maximum time to wait - * @param unit the time unit of the {@code timeout} argument - * @return true if the update is prepared - */ - boolean await(long timeout, TimeUnit unit) throws InterruptedException; - - /** - * Once the update preparation is complete, this method can be called to launch the external update process. - *

- * This method shall be called after making sure that the application is ready to be restarted, e.g. after locking all vaults. - * - * @return a {@link ProcessHandle} that represents the external update process. - * @throws IllegalStateException if the update preparation is not complete or if the update process cannot be launched. - * @throws IOException if the update preparation failed or starting the update process failed - */ - ProcessHandle applyUpdate() throws IllegalStateException, IOException; - - -} \ No newline at end of file diff --git a/src/main/java/org/cryptomator/integrations/update/UpdateStep.java b/src/main/java/org/cryptomator/integrations/update/UpdateStep.java new file mode 100644 index 0000000..7755be9 --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/update/UpdateStep.java @@ -0,0 +1,96 @@ +package org.cryptomator.integrations.update; + +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NonBlocking; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Range; + +import java.io.IOException; +import java.util.concurrent.Callable; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +@ApiStatus.Experimental +public interface UpdateStep { + + static UpdateStep of(String name, Callable nextStep) { + return new UpdateStepAdapter() { + + @Override + public UpdateStep call() throws Exception { + return nextStep.call(); + } + + @Override + public String description() { + return name; + } + }; + } + + /** + * A short description of this update step. + * @return a human-readable description of this update step. + */ + String description(); + + /** + * Starts work on this update step in a non-blocking manner. + * @throws IllegalThreadStateException if this step has already been started. + */ + @NonBlocking + void start() throws IllegalThreadStateException; + + /** + * A thread-safe method to check the progress of the update preparation. + * @return a value between 0.0 and 1.0 indicating the progress of the update preparation or -1.0 indicating indeterminate progress. + */ + double preparationProgress(); + + /** + * Cancels this update step and cleans up any temporary resources. + */ + void cancel(); + + /** + * Blocks the current thread until this update step completed or an error occured. + *

+ * If the step is already complete, this method returns immediately. + * + * @throws InterruptedException if the current thread is interrupted while waiting. + */ + void await() throws InterruptedException; + + /** + * Blocks the current thread until this update step completed or an error occured, or until the specified timeout expires. + *

+ * If the step is already complete, this method returns immediately. + * + * @param timeout the maximum time to wait + * @param unit the time unit of the {@code timeout} argument + * @return true if the update is prepared + */ + boolean await(long timeout, TimeUnit unit) throws InterruptedException; + + default boolean isDone() { + try { + return await(0, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + + /** + * Once the update preparation is complete, this method can be called to launch the external update process. + *

+ * This method shall be called after making sure that the application is ready to be restarted, e.g. after locking all vaults. + * + * @return the next {@link UpdateStep step} of the update process or null if this was the final step. + * @throws IllegalStateException if the update preparation is not complete or if the update process cannot be launched. + * @throws IOException if the update preparation failed + */ + @Nullable + UpdateStep nextStep() throws IllegalStateException, IOException; + +} \ No newline at end of file diff --git a/src/main/java/org/cryptomator/integrations/update/UpdateStepAdapter.java b/src/main/java/org/cryptomator/integrations/update/UpdateStepAdapter.java new file mode 100644 index 0000000..dc0cd0d --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/update/UpdateStepAdapter.java @@ -0,0 +1,65 @@ +package org.cryptomator.integrations.update; + +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.time.Duration; +import java.util.concurrent.Callable; +import java.util.concurrent.TimeUnit; + +public abstract class UpdateStepAdapter implements Callable, Runnable, UpdateStep { + + protected final Thread thread; + protected volatile UpdateStep result; + protected volatile Exception exception; + + public UpdateStepAdapter() { + this.thread = Thread.ofVirtual().name("UpdateStep", 0).unstarted(this); + } + + @Override + public final void run() { + try { + this.result = this.call(); + } catch (Exception e) { + this.exception = e; + } + } + + @Override + public void start() throws IllegalThreadStateException { + thread.start(); + } + + @Override + public double preparationProgress() { + return -1.0; + } + + @Override + public void cancel() { + thread.interrupt(); + } + + @Override + public void await() throws InterruptedException { + thread.join(); + } + + @Override + public boolean await(long timeout, TimeUnit unit) throws InterruptedException { + return thread.join(Duration.of(timeout, unit.toChronoUnit())); + } + + @Override + public @Nullable UpdateStep nextStep() throws IllegalStateException, IOException { + if (!isDone()) { + throw new IllegalStateException("Update step not completed yet"); + } + return switch (exception) { + case null -> result; + case IOException e -> throw e; + default -> throw new IOException("Update step failed", exception); + }; + } +} From 61de9f3c8284f6eb47dd545b245cf09ff129df31 Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Tue, 21 Oct 2025 11:31:53 +0200 Subject: [PATCH 13/23] add magic constants for UI to react to --- .../integrations/update/NoopUpdateStep.java | 30 +++++++++++++++++++ .../integrations/update/UpdateStep.java | 14 +++++++++ 2 files changed, 44 insertions(+) create mode 100644 src/main/java/org/cryptomator/integrations/update/NoopUpdateStep.java diff --git a/src/main/java/org/cryptomator/integrations/update/NoopUpdateStep.java b/src/main/java/org/cryptomator/integrations/update/NoopUpdateStep.java new file mode 100644 index 0000000..8638588 --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/update/NoopUpdateStep.java @@ -0,0 +1,30 @@ +package org.cryptomator.integrations.update; + +import java.util.concurrent.TimeUnit; + +record NoopUpdateStep(String description) implements UpdateStep { + + @Override + public void start() {} + + @Override + public double preparationProgress() { + return -1.0; + } + + @Override + public void cancel() {} + + @Override + public void await() {} + + @Override + public boolean await(long timeout, TimeUnit unit) { + return true; // always done + } + + @Override + public UpdateStep nextStep() { + return null; + } +} diff --git a/src/main/java/org/cryptomator/integrations/update/UpdateStep.java b/src/main/java/org/cryptomator/integrations/update/UpdateStep.java index 7755be9..c413bdf 100644 --- a/src/main/java/org/cryptomator/integrations/update/UpdateStep.java +++ b/src/main/java/org/cryptomator/integrations/update/UpdateStep.java @@ -13,6 +13,19 @@ @ApiStatus.Experimental public interface UpdateStep { + /** + * A magic constant indicating that the application shall terminate. + *

+ * This step can be returned as the last step of the update process, usually immediately after a restart has been scheduled. + */ + UpdateStep EXIT = new NoopUpdateStep("Exiting..."); + + /** + * A magic constant indicating that the update process shall be retried. + */ + UpdateStep RETRY = new NoopUpdateStep("Retry"); + + static UpdateStep of(String name, Callable nextStep) { return new UpdateStepAdapter() { @@ -89,6 +102,7 @@ default boolean isDone() { * @return the next {@link UpdateStep step} of the update process or null if this was the final step. * @throws IllegalStateException if the update preparation is not complete or if the update process cannot be launched. * @throws IOException if the update preparation failed + * @implSpec The returned {@link UpdateStep} must either be stateless or a new instance must be returned on each call. */ @Nullable UpdateStep nextStep() throws IllegalStateException, IOException; From ffc366694f9167ebde79d7711cb74905b01f7906 Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Sun, 2 Nov 2025 15:40:49 +0100 Subject: [PATCH 14/23] update update mechanism --- pom.xml | 2 +- .../org/cryptomator/integrations/update/UpdateInfo.java | 4 ++++ .../cryptomator/integrations/update/UpdateMechanism.java | 8 ++++++-- 3 files changed, 11 insertions(+), 3 deletions(-) create mode 100644 src/main/java/org/cryptomator/integrations/update/UpdateInfo.java diff --git a/pom.xml b/pom.xml index 5b576ae..2dbdbe5 100644 --- a/pom.xml +++ b/pom.xml @@ -34,7 +34,7 @@ 5.12.1 - 5.16.1 + 5.20.0 3.14.0 diff --git a/src/main/java/org/cryptomator/integrations/update/UpdateInfo.java b/src/main/java/org/cryptomator/integrations/update/UpdateInfo.java new file mode 100644 index 0000000..72b89aa --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/update/UpdateInfo.java @@ -0,0 +1,4 @@ +package org.cryptomator.integrations.update; + +public record UpdateInfo(String version, UpdateMechanism updateMechanism) { +} diff --git a/src/main/java/org/cryptomator/integrations/update/UpdateMechanism.java b/src/main/java/org/cryptomator/integrations/update/UpdateMechanism.java index af2c7e0..c14aba1 100644 --- a/src/main/java/org/cryptomator/integrations/update/UpdateMechanism.java +++ b/src/main/java/org/cryptomator/integrations/update/UpdateMechanism.java @@ -4,7 +4,9 @@ import org.cryptomator.integrations.common.NamedServiceProvider; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Blocking; +import org.jetbrains.annotations.Nullable; +import java.net.http.HttpClient; import java.util.Optional; @ApiStatus.Experimental @@ -34,11 +36,13 @@ static boolean isUpdateAvailable(String updateVersion, String installedVersion) /** * Checks whether an update is available. * @param currentVersion The full version string of the currently installed application, e.g. "1.2.3-beta4". - * @return true if an update is available, false otherwise. + * @param httpClient An HTTP client that can be used to check for updates. + * @return An {@link UpdateInfo} if an update is available, or null otherwise. * @throws UpdateFailedException If the availability of an update could not be determined */ @Blocking - boolean isUpdateAvailable(String currentVersion) throws UpdateFailedException; + @Nullable + UpdateInfo checkForUpdate(String currentVersion, HttpClient httpClient) throws UpdateFailedException; /** * Returns the first step to prepare the update. This can be anything like downloading the update, checking signatures, etc. From 2493753b421a367bb345fe4843010d52b310860a Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Tue, 4 Nov 2025 11:11:46 +0100 Subject: [PATCH 15/23] Allow UpdateInfo to carry mechanism-specific data --- .../integrations/update/UpdateInfo.java | 17 ++++++++++++++++- .../integrations/update/UpdateMechanism.java | 7 ++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/cryptomator/integrations/update/UpdateInfo.java b/src/main/java/org/cryptomator/integrations/update/UpdateInfo.java index 72b89aa..3d76740 100644 --- a/src/main/java/org/cryptomator/integrations/update/UpdateInfo.java +++ b/src/main/java/org/cryptomator/integrations/update/UpdateInfo.java @@ -1,4 +1,19 @@ package org.cryptomator.integrations.update; -public record UpdateInfo(String version, UpdateMechanism updateMechanism) { +public interface UpdateInfo { + + static UpdateInfo of(String version, UpdateMechanism updateMechanism) { + record UpdateInfoImpl(String version, UpdateMechanism updateMechanism) implements UpdateInfo {} + return new UpdateInfoImpl(version, updateMechanism); + } + + /** + * @return The version string of the available update. + */ + String version(); + + /** + * @return The update mechanism that provided this update info. + */ + UpdateMechanism updateMechanism(); } diff --git a/src/main/java/org/cryptomator/integrations/update/UpdateMechanism.java b/src/main/java/org/cryptomator/integrations/update/UpdateMechanism.java index c14aba1..f9afde5 100644 --- a/src/main/java/org/cryptomator/integrations/update/UpdateMechanism.java +++ b/src/main/java/org/cryptomator/integrations/update/UpdateMechanism.java @@ -10,7 +10,7 @@ import java.util.Optional; @ApiStatus.Experimental -public interface UpdateMechanism extends NamedServiceProvider { +public interface UpdateMechanism extends NamedServiceProvider { String UPDATE_MECHANISM_PROPERTY = "cryptomator.updateMechanism"; @@ -42,13 +42,14 @@ static boolean isUpdateAvailable(String updateVersion, String installedVersion) */ @Blocking @Nullable - UpdateInfo checkForUpdate(String currentVersion, HttpClient httpClient) throws UpdateFailedException; + T checkForUpdate(String currentVersion, HttpClient httpClient) throws UpdateFailedException; /** * Returns the first step to prepare the update. This can be anything like downloading the update, checking signatures, etc. + * @param updateInfo The {@link UpdateInfo} representing the update to be prepared. * @return a new {@link UpdateStep} that can be used to monitor the progress of the update preparation. The task will complete when the preparation is done. * @throws UpdateFailedException If no update process can be started, e.g. due to network or I/O issues. */ - UpdateStep firstStep() throws UpdateFailedException; + UpdateStep firstStep(T updateInfo) throws UpdateFailedException; } \ No newline at end of file From b0d9fe443efbb583108921aa6257f1c54d151820 Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Tue, 4 Nov 2025 21:40:27 +0100 Subject: [PATCH 16/23] improve type safety --- .../integrations/update/BasicUpdateInfo.java | 4 ++++ .../integrations/update/UpdateInfo.java | 20 +++++++++++++------ .../integrations/update/UpdateMechanism.java | 5 ++++- 3 files changed, 22 insertions(+), 7 deletions(-) create mode 100644 src/main/java/org/cryptomator/integrations/update/BasicUpdateInfo.java diff --git a/src/main/java/org/cryptomator/integrations/update/BasicUpdateInfo.java b/src/main/java/org/cryptomator/integrations/update/BasicUpdateInfo.java new file mode 100644 index 0000000..161f62d --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/update/BasicUpdateInfo.java @@ -0,0 +1,4 @@ +package org.cryptomator.integrations.update; + +public record BasicUpdateInfo(String version, UpdateMechanism updateMechanism) implements UpdateInfo { +} diff --git a/src/main/java/org/cryptomator/integrations/update/UpdateInfo.java b/src/main/java/org/cryptomator/integrations/update/UpdateInfo.java index 3d76740..b0dec37 100644 --- a/src/main/java/org/cryptomator/integrations/update/UpdateInfo.java +++ b/src/main/java/org/cryptomator/integrations/update/UpdateInfo.java @@ -1,11 +1,8 @@ package org.cryptomator.integrations.update; -public interface UpdateInfo { +import org.jetbrains.annotations.NotNull; - static UpdateInfo of(String version, UpdateMechanism updateMechanism) { - record UpdateInfoImpl(String version, UpdateMechanism updateMechanism) implements UpdateInfo {} - return new UpdateInfoImpl(version, updateMechanism); - } +public interface UpdateInfo> { /** * @return The version string of the available update. @@ -15,5 +12,16 @@ record UpdateInfoImpl(String version, UpdateMechanism updateMechanism) implement /** * @return The update mechanism that provided this update info. */ - UpdateMechanism updateMechanism(); + UpdateMechanism updateMechanism(); + + /** + * Typesafe equivalent to {@code updateMechanism().firstStep(this)}. + * @return Result of {@link UpdateMechanism#firstStep(UpdateInfo)}. + * @throws UpdateFailedException If no update process can be started, e.g. due to network or I/O issues. + */ + @NotNull + default UpdateStep useToPrepareFirstStep() throws UpdateFailedException { + @SuppressWarnings("unchecked") T self = (T) this; + return updateMechanism().firstStep(self); + } } diff --git a/src/main/java/org/cryptomator/integrations/update/UpdateMechanism.java b/src/main/java/org/cryptomator/integrations/update/UpdateMechanism.java index f9afde5..cd95805 100644 --- a/src/main/java/org/cryptomator/integrations/update/UpdateMechanism.java +++ b/src/main/java/org/cryptomator/integrations/update/UpdateMechanism.java @@ -4,16 +4,18 @@ import org.cryptomator.integrations.common.NamedServiceProvider; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Blocking; +import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.net.http.HttpClient; import java.util.Optional; @ApiStatus.Experimental -public interface UpdateMechanism extends NamedServiceProvider { +public interface UpdateMechanism> extends NamedServiceProvider { String UPDATE_MECHANISM_PROPERTY = "cryptomator.updateMechanism"; + @SuppressWarnings("rawtypes") static Optional get() { return Optional.ofNullable(System.getProperty(UPDATE_MECHANISM_PROPERTY)) .flatMap(name -> IntegrationsLoader.loadSpecific(UpdateMechanism.class, name)); @@ -50,6 +52,7 @@ static boolean isUpdateAvailable(String updateVersion, String installedVersion) * @return a new {@link UpdateStep} that can be used to monitor the progress of the update preparation. The task will complete when the preparation is done. * @throws UpdateFailedException If no update process can be started, e.g. due to network or I/O issues. */ + @NotNull UpdateStep firstStep(T updateInfo) throws UpdateFailedException; } \ No newline at end of file From 41d8e4cdefb300f892d450c4bf2d7c40495d5cb5 Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Thu, 6 Nov 2025 13:08:56 +0100 Subject: [PATCH 17/23] delete unused class --- .../org/cryptomator/integrations/update/BasicUpdateInfo.java | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 src/main/java/org/cryptomator/integrations/update/BasicUpdateInfo.java diff --git a/src/main/java/org/cryptomator/integrations/update/BasicUpdateInfo.java b/src/main/java/org/cryptomator/integrations/update/BasicUpdateInfo.java deleted file mode 100644 index 161f62d..0000000 --- a/src/main/java/org/cryptomator/integrations/update/BasicUpdateInfo.java +++ /dev/null @@ -1,4 +0,0 @@ -package org.cryptomator.integrations.update; - -public record BasicUpdateInfo(String version, UpdateMechanism updateMechanism) implements UpdateInfo { -} From 5856f2818a60a2626bc331fdb27c28aba70ce3a8 Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Thu, 6 Nov 2025 13:15:52 +0100 Subject: [PATCH 18/23] append CHANGELOG --- CHANGELOG.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index acb0f51..dc01080 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The changelog starts with version 1.7.0. Changes to prior versions can be found on the [Github release page](https://github.com/cryptomator/integrations-api/releases). -## [Unreleased] +## [Unreleased](https://github.com/cryptomator/integrations-api/compare/1.7.0...HEAD) -No changes yet. +### Added -## [1.7.0] - 2025-09-17 +* Experimental [Update API](https://github.com/cryptomator/integrations-api/blob/a052dd06a38f5410f6d9c9c7061c036efee83480/src/main/java/org/cryptomator/integrations/update/UpdateMechanism.java) + +## [1.7.0](https://github.com/cryptomator/integrations-api/releases/tag/1.7.0) - 2025-09-17 ### Changed From 6e3bb1545cded5b297d768e5f75ccf59fd11a18c Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Thu, 6 Nov 2025 21:42:07 +0100 Subject: [PATCH 19/23] share classes for `DownloadUpdateMechanism` --- pom.xml | 11 ++ src/main/java/module-info.java | 2 + .../integrations/Localization.java | 14 ++ .../update/DownloadUpdateInfo.java | 8 ++ .../update/DownloadUpdateMechanism.java | 126 ++++++++++++++++++ .../update/DownloadUpdateStep.java | 8 +- .../integrations/update/UpdateStep.java | 13 +- src/main/resources/IntegrationsApi.properties | 6 + 8 files changed, 178 insertions(+), 10 deletions(-) create mode 100644 src/main/java/org/cryptomator/integrations/Localization.java create mode 100644 src/main/java/org/cryptomator/integrations/update/DownloadUpdateInfo.java create mode 100644 src/main/java/org/cryptomator/integrations/update/DownloadUpdateMechanism.java create mode 100644 src/main/resources/IntegrationsApi.properties diff --git a/pom.xml b/pom.xml index 9b6c098..ae31a2e 100644 --- a/pom.xml +++ b/pom.xml @@ -30,6 +30,7 @@ 21 2.0.17 + 2.20.0 26.0.2-1 @@ -59,6 +60,16 @@ slf4j-api ${slf4j.version} + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + ${jackson.version} + org.jetbrains diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index ea65cc4..17321f2 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -12,6 +12,8 @@ module org.cryptomator.integrations.api { requires static org.jetbrains.annotations; requires org.slf4j; + requires com.fasterxml.jackson.databind; + requires com.fasterxml.jackson.datatype.jsr310; requires java.net.http; exports org.cryptomator.integrations.autostart; diff --git a/src/main/java/org/cryptomator/integrations/Localization.java b/src/main/java/org/cryptomator/integrations/Localization.java new file mode 100644 index 0000000..9b153ac --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/Localization.java @@ -0,0 +1,14 @@ +package org.cryptomator.integrations; + +import java.util.ResourceBundle; + +public enum Localization { + INSTANCE; + + private final ResourceBundle resourceBundle = ResourceBundle.getBundle("IntegrationsApi"); + + public static ResourceBundle get() { + return INSTANCE.resourceBundle; + } + +} diff --git a/src/main/java/org/cryptomator/integrations/update/DownloadUpdateInfo.java b/src/main/java/org/cryptomator/integrations/update/DownloadUpdateInfo.java new file mode 100644 index 0000000..739316e --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/update/DownloadUpdateInfo.java @@ -0,0 +1,8 @@ +package org.cryptomator.integrations.update; + +public record DownloadUpdateInfo( + DownloadUpdateMechanism updateMechanism, + String version, + DownloadUpdateMechanism.Asset asset +) implements UpdateInfo { +} diff --git a/src/main/java/org/cryptomator/integrations/update/DownloadUpdateMechanism.java b/src/main/java/org/cryptomator/integrations/update/DownloadUpdateMechanism.java new file mode 100644 index 0000000..4306bfc --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/update/DownloadUpdateMechanism.java @@ -0,0 +1,126 @@ +package org.cryptomator.integrations.update; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.jetbrains.annotations.Blocking; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HexFormat; +import java.util.List; + +public abstract class DownloadUpdateMechanism implements UpdateMechanism { + + private static final Logger LOG = LoggerFactory .getLogger(DownloadUpdateMechanism.class); + private static final String LATEST_VERSION_API_URL = "https://api.cryptomator.org/connect/apps/desktop/latest-version?format=1"; + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Override + public DownloadUpdateInfo checkForUpdate(String currentVersion, HttpClient httpClient) { + try { + HttpRequest request = HttpRequest.newBuilder().uri(URI.create(LATEST_VERSION_API_URL)).build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); + if (response.statusCode() != 200) { + throw new RuntimeException("Failed to fetch release: " + response.statusCode()); + } + var release = MAPPER.readValue(response.body(), LatestVersionResponse.class); + return checkForUpdate(currentVersion, release); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + LOG.debug("Update check interrupted."); + return null; + } catch (IOException e) { + LOG.warn("Update check failed", e); + return null; + } + } + + /** + * Returns the first step to prepare the update. This downloads the {@link DownloadUpdateInfo#asset() asset} to a temporary location and verifies its checksum. + * @param updateInfo The {@link DownloadUpdateInfo} retrieved from {@link #checkForUpdate(String, HttpClient)}. + * @return a new {@link UpdateStep} that can be used to monitor the download progress. + * @throws UpdateFailedException When failing to prepare a temporary download location. + */ + @Override + public UpdateStep firstStep(DownloadUpdateInfo updateInfo) throws UpdateFailedException { + try { + Path workDir = Files.createTempDirectory("cryptomator-update"); + return new FirstStep(workDir, updateInfo); + } catch (IOException e) { + throw new UpdateFailedException("Failed to create temporary directory for update", e); + } + } + + /** + * Second step that is executed after the download has completed in the {@link #firstStep(DownloadUpdateInfo) first step}. + * @param workDir A temporary working directory to which the asset has been downloaded. + * @param assetPath The path of the downloaded asset. + * @param updateInfo The {@link DownloadUpdateInfo} representing the update. + * @return The next step of the update process. + * @throws IllegalStateException if preconditions aren't met. + * @throws IOException indicating an error preventing the next step from starting. + * @implSpec The returned {@link UpdateStep} must either be stateless or a new instance must be returned on each call. + */ + public abstract UpdateStep secondStep(Path workDir, Path assetPath, DownloadUpdateInfo updateInfo) throws IllegalStateException, IOException; + + @Nullable + @Blocking + protected abstract DownloadUpdateInfo checkForUpdate(String currentVersion, LatestVersionResponse response); + + @JsonIgnoreProperties(ignoreUnknown = true) + public record LatestVersionResponse( + @JsonProperty("latestVersion") LatestVersion latestVersion, + @JsonProperty("assets") List assets + ) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record LatestVersion( + @JsonProperty("mac") String macVersion, + @JsonProperty("win") String winVersion, + @JsonProperty("linux") String linuxVersion + ) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Asset( + @JsonProperty("name") String name, + @JsonProperty("digest") String digest, // TODO: verify this starts with "sha256:"? + @JsonProperty("size") long size, + @JsonProperty("downloadUrl") String downloadUrl + ) {} + + private class FirstStep extends DownloadUpdateStep { + private final Path workDir; + private final DownloadUpdateInfo updateInfo; + + public FirstStep(Path workDir, DownloadUpdateInfo updateInfo) { + super(URI.create(updateInfo.asset().downloadUrl), + workDir.resolve(updateInfo.asset().name), + HexFormat.of().withLowerCase().parseHex(updateInfo.asset().digest.substring(7)), // remove "sha256:" prefix + updateInfo.asset().size); + this.workDir = workDir; + this.updateInfo = updateInfo; + } + + @Override + public @Nullable UpdateStep nextStep() throws IllegalStateException, IOException { + if (!isDone()) { + throw new IllegalStateException("Download not yet completed."); + } else if (downloadException != null) { + throw new UpdateFailedException("Download failed.", downloadException); + } + return secondStep(workDir, destination, updateInfo); + } + } + +} diff --git a/src/main/java/org/cryptomator/integrations/update/DownloadUpdateStep.java b/src/main/java/org/cryptomator/integrations/update/DownloadUpdateStep.java index 8e6b415..6ce60be 100644 --- a/src/main/java/org/cryptomator/integrations/update/DownloadUpdateStep.java +++ b/src/main/java/org/cryptomator/integrations/update/DownloadUpdateStep.java @@ -1,5 +1,7 @@ package org.cryptomator.integrations.update; +import org.cryptomator.integrations.Localization; + import java.io.FilterInputStream; import java.io.IOException; import java.io.InputStream; @@ -48,9 +50,9 @@ protected DownloadUpdateStep(URI source, Path destination, byte[] checksum, long @Override public String description() { return switch (downloadThread.getState()) { - case NEW -> "Download... "; - case TERMINATED -> "Downloaded."; - default -> "Downloading... %1.0f%%".formatted(preparationProgress() * 100); + case NEW -> Localization.get().getString("org.cryptomator.api.update.download.new"); + case TERMINATED -> Localization.get().getString("org.cryptomator.api.update.download.done"); + default -> Localization.get().getString("org.cryptomator.api.update.download.progress").formatted(preparationProgress() * 100); }; } diff --git a/src/main/java/org/cryptomator/integrations/update/UpdateStep.java b/src/main/java/org/cryptomator/integrations/update/UpdateStep.java index c413bdf..d4cc816 100644 --- a/src/main/java/org/cryptomator/integrations/update/UpdateStep.java +++ b/src/main/java/org/cryptomator/integrations/update/UpdateStep.java @@ -1,5 +1,6 @@ package org.cryptomator.integrations.update; +import org.cryptomator.integrations.Localization; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NonBlocking; import org.jetbrains.annotations.Nullable; @@ -18,12 +19,12 @@ public interface UpdateStep { *

* This step can be returned as the last step of the update process, usually immediately after a restart has been scheduled. */ - UpdateStep EXIT = new NoopUpdateStep("Exiting..."); + UpdateStep EXIT = new NoopUpdateStep(Localization.get().getString("org.cryptomator.api.update.updateStep.EXIT")); /** * A magic constant indicating that the update process shall be retried. */ - UpdateStep RETRY = new NoopUpdateStep("Retry"); + UpdateStep RETRY = new NoopUpdateStep(Localization.get().getString("org.cryptomator.api.update.updateStep.RETRY")); static UpdateStep of(String name, Callable nextStep) { @@ -95,13 +96,11 @@ default boolean isDone() { } /** - * Once the update preparation is complete, this method can be called to launch the external update process. - *

- * This method shall be called after making sure that the application is ready to be restarted, e.g. after locking all vaults. + * After running this step to completion, this method returns the next step of the update process. * * @return the next {@link UpdateStep step} of the update process or null if this was the final step. - * @throws IllegalStateException if the update preparation is not complete or if the update process cannot be launched. - * @throws IOException if the update preparation failed + * @throws IllegalStateException if this step didn't complete yet or other preconditions aren't met. + * @throws IOException indicating an error before reaching the next step, e.g. during execution of this step. * @implSpec The returned {@link UpdateStep} must either be stateless or a new instance must be returned on each call. */ @Nullable diff --git a/src/main/resources/IntegrationsApi.properties b/src/main/resources/IntegrationsApi.properties new file mode 100644 index 0000000..f5bb882 --- /dev/null +++ b/src/main/resources/IntegrationsApi.properties @@ -0,0 +1,6 @@ +org.cryptomator.api.update.download.new=Download... +org.cryptomator.api.update.download.progress=Downloading... %1.0f%% +org.cryptomator.api.update.download.done=Downloaded. + +org.cryptomator.api.update.updateStep.EXIT=Exiting... +org.cryptomator.api.update.updateStep.RETRY=Retry \ No newline at end of file From d75729efc6b734e0f3e9f7ae07da8dc592e372d6 Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Wed, 12 Nov 2025 13:26:43 +0100 Subject: [PATCH 20/23] apply suggestions from code review --- .../integrations/update/DownloadUpdateMechanism.java | 11 +++++++---- .../integrations/update/DownloadUpdateStep.java | 8 +++++--- .../cryptomator/integrations/update/UpdateStep.java | 4 ++-- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/main/java/org/cryptomator/integrations/update/DownloadUpdateMechanism.java b/src/main/java/org/cryptomator/integrations/update/DownloadUpdateMechanism.java index d9d9a4d..991c85e 100644 --- a/src/main/java/org/cryptomator/integrations/update/DownloadUpdateMechanism.java +++ b/src/main/java/org/cryptomator/integrations/update/DownloadUpdateMechanism.java @@ -22,7 +22,7 @@ public abstract class DownloadUpdateMechanism implements UpdateMechanism { - private static final Logger LOG = LoggerFactory .getLogger(DownloadUpdateMechanism.class); + private static final Logger LOG = LoggerFactory.getLogger(DownloadUpdateMechanism.class); private static final String LATEST_VERSION_API_URL = "https://api.cryptomator.org/connect/apps/desktop/latest-version?format=1"; private static final ObjectMapper MAPPER = new ObjectMapper(); @@ -32,7 +32,8 @@ public DownloadUpdateInfo checkForUpdate(String currentVersion, HttpClient httpC HttpRequest request = HttpRequest.newBuilder().uri(URI.create(LATEST_VERSION_API_URL)).build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); if (response.statusCode() != 200) { - throw new RuntimeException("Failed to fetch release: " + response.statusCode()); + LOG.warn("Failed to fetch release: HTTP {}", response.statusCode()); + return null; } var release = MAPPER.readValue(response.body(), LatestVersionResponse.class); return checkForUpdate(currentVersion, release); @@ -94,7 +95,7 @@ public record LatestVersion( @JsonIgnoreProperties(ignoreUnknown = true) public record Asset( @JsonProperty("name") String name, - @JsonProperty("digest") String digest, // TODO: verify this starts with "sha256:"? + @JsonProperty("digest") String digest, @JsonProperty("size") long size, @JsonProperty("downloadUrl") String downloadUrl ) {} @@ -106,7 +107,9 @@ private class FirstStep extends DownloadUpdateStep { public FirstStep(Path workDir, DownloadUpdateInfo updateInfo) { var uri = URI.create(updateInfo.asset().downloadUrl); var destination = workDir.resolve(updateInfo.asset().name); - var digest = HexFormat.of().withLowerCase().parseHex(updateInfo.asset().digest.substring(7)); // remove "sha256:" prefix + var digest = updateInfo.asset().digest().startsWith("sha256:") + ? HexFormat.of().withLowerCase().parseHex(updateInfo.asset().digest.substring(7)) // remove "sha256:" prefix + : null; var size = updateInfo.asset().size; super(uri, destination, digest, size); this.workDir = workDir; diff --git a/src/main/java/org/cryptomator/integrations/update/DownloadUpdateStep.java b/src/main/java/org/cryptomator/integrations/update/DownloadUpdateStep.java index 6ce60be..51ad682 100644 --- a/src/main/java/org/cryptomator/integrations/update/DownloadUpdateStep.java +++ b/src/main/java/org/cryptomator/integrations/update/DownloadUpdateStep.java @@ -1,6 +1,7 @@ package org.cryptomator.integrations.update; import org.cryptomator.integrations.Localization; +import org.jetbrains.annotations.Nullable; import java.io.FilterInputStream; import java.io.IOException; @@ -16,6 +17,7 @@ import java.nio.file.StandardOpenOption; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.time.Duration; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; @@ -35,11 +37,11 @@ public abstract class DownloadUpdateStep implements UpdateStep { /** * Creates a new DownloadUpdateProcess instance. * @param source The URI from which the update will be downloaded. - * @param destination The path to theworking directory where the downloaded file will be saved. + * @param destination The path where to save the downloaded file. * @param checksum (optional) The expected SHA-256 checksum of the downloaded file, can be null if not required. * @param estDownloadSize The estimated size of the download in bytes. */ - protected DownloadUpdateStep(URI source, Path destination, byte[] checksum, long estDownloadSize) { + protected DownloadUpdateStep(URI source, Path destination, @Nullable byte[] checksum, long estDownloadSize) { this.source = source; this.destination = destination; this.checksum = checksum; @@ -88,7 +90,7 @@ public void cancel() { protected void download() { var request = HttpRequest.newBuilder().uri(source).GET().build(); - try (HttpClient client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.ALWAYS).build()) { + try (HttpClient client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.ALWAYS).connectTimeout(Duration.ofSeconds(10)).build()) { downloadInternal(client, request); } catch (IOException e) { downloadException = e; diff --git a/src/main/java/org/cryptomator/integrations/update/UpdateStep.java b/src/main/java/org/cryptomator/integrations/update/UpdateStep.java index d4cc816..367df90 100644 --- a/src/main/java/org/cryptomator/integrations/update/UpdateStep.java +++ b/src/main/java/org/cryptomator/integrations/update/UpdateStep.java @@ -67,7 +67,7 @@ public String description() { void cancel(); /** - * Blocks the current thread until this update step completed or an error occured. + * Blocks the current thread until this update step completed or an error occurred. *

* If the step is already complete, this method returns immediately. * @@ -76,7 +76,7 @@ public String description() { void await() throws InterruptedException; /** - * Blocks the current thread until this update step completed or an error occured, or until the specified timeout expires. + * Blocks the current thread until this update step completed or an error occurred, or until the specified timeout expires. *

* If the step is already complete, this method returns immediately. * From 73332c8c8c82471bdc298a0559f336ce7b8de49f Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Wed, 12 Nov 2025 13:30:38 +0100 Subject: [PATCH 21/23] no need for java.time.* deserialization --- pom.xml | 5 ----- src/main/java/module-info.java | 1 - 2 files changed, 6 deletions(-) diff --git a/pom.xml b/pom.xml index 459f8aa..065e69c 100644 --- a/pom.xml +++ b/pom.xml @@ -65,11 +65,6 @@ jackson-databind ${jackson.version} - - com.fasterxml.jackson.datatype - jackson-datatype-jsr310 - ${jackson.version} - org.jetbrains diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 17321f2..f6c292f 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -13,7 +13,6 @@ requires static org.jetbrains.annotations; requires org.slf4j; requires com.fasterxml.jackson.databind; - requires com.fasterxml.jackson.datatype.jsr310; requires java.net.http; exports org.cryptomator.integrations.autostart; From a522f36cf45884127e2431dd18222391669d5992 Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Wed, 12 Nov 2025 14:00:30 +0100 Subject: [PATCH 22/23] implement more suggestions from code review [deploy] --- .../integrations/update/DownloadUpdateStep.java | 17 +++++++++++++++-- .../integrations/update/UpdateStep.java | 2 ++ src/main/resources/IntegrationsApi.properties | 1 + 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/cryptomator/integrations/update/DownloadUpdateStep.java b/src/main/java/org/cryptomator/integrations/update/DownloadUpdateStep.java index 51ad682..ca57441 100644 --- a/src/main/java/org/cryptomator/integrations/update/DownloadUpdateStep.java +++ b/src/main/java/org/cryptomator/integrations/update/DownloadUpdateStep.java @@ -13,6 +13,7 @@ import java.net.http.HttpResponse; import java.nio.channels.Channels; import java.nio.channels.FileChannel; +import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.security.MessageDigest; @@ -54,7 +55,14 @@ public String description() { return switch (downloadThread.getState()) { case NEW -> Localization.get().getString("org.cryptomator.api.update.download.new"); case TERMINATED -> Localization.get().getString("org.cryptomator.api.update.download.done"); - default -> Localization.get().getString("org.cryptomator.api.update.download.progress").formatted(preparationProgress() * 100); + default -> { + double progress = preparationProgress(); + if (progress < 0.0) { + yield Localization.get().getString("org.cryptomator.api.update.download.indeterminateProgress"); + } else { + yield Localization.get().getString("org.cryptomator.api.update.download.progress").formatted(progress * 100.0); + } + } }; } @@ -86,6 +94,11 @@ public boolean await(long timeout, TimeUnit unit) throws InterruptedException { @Override public void cancel() { downloadThread.interrupt(); + try { + Files.deleteIfExists(destination); + } catch (IOException e) { + // ignore, this is a best-effort cleanup + } } protected void download() { @@ -119,7 +132,7 @@ protected void downloadInternal(HttpClient client, HttpRequest request) throws I // prepare checksum calculation MessageDigest sha256; try { - sha256 = MessageDigest.getInstance("SHA-256"); // Initialize SHA-256 digest, not used here but can be extended for checksum validation + sha256 = MessageDigest.getInstance("SHA-256"); } catch (NoSuchAlgorithmException e) { throw new AssertionError("Every implementation of the Java platform is required to support [...] SHA-256", e); } diff --git a/src/main/java/org/cryptomator/integrations/update/UpdateStep.java b/src/main/java/org/cryptomator/integrations/update/UpdateStep.java index 367df90..b2cd9b5 100644 --- a/src/main/java/org/cryptomator/integrations/update/UpdateStep.java +++ b/src/main/java/org/cryptomator/integrations/update/UpdateStep.java @@ -68,6 +68,7 @@ public String description() { /** * Blocks the current thread until this update step completed or an error occurred. + * If this step failed, an exception will be rethrown as soon as attempting to invoke {@link #nextStep()}. *

* If the step is already complete, this method returns immediately. * @@ -77,6 +78,7 @@ public String description() { /** * Blocks the current thread until this update step completed or an error occurred, or until the specified timeout expires. + * If this step failed, an exception will be rethrown as soon as attempting to invoke {@link #nextStep()}. *

* If the step is already complete, this method returns immediately. * diff --git a/src/main/resources/IntegrationsApi.properties b/src/main/resources/IntegrationsApi.properties index f5bb882..1e9fdd1 100644 --- a/src/main/resources/IntegrationsApi.properties +++ b/src/main/resources/IntegrationsApi.properties @@ -1,4 +1,5 @@ org.cryptomator.api.update.download.new=Download... +org.cryptomator.api.update.download.indeterminateProgress=Downloading... org.cryptomator.api.update.download.progress=Downloading... %1.0f%% org.cryptomator.api.update.download.done=Downloaded. From 0fa2e52c5c1591db196b966d2b8bb197e2f617b9 Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Mon, 17 Nov 2025 13:11:03 +0100 Subject: [PATCH 23/23] update changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc01080..a5ea371 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ Changes to prior versions can be found on the [Github release page](https://gith ### Added -* Experimental [Update API](https://github.com/cryptomator/integrations-api/blob/a052dd06a38f5410f6d9c9c7061c036efee83480/src/main/java/org/cryptomator/integrations/update/UpdateMechanism.java) +* Experimental [Update API](https://github.com/cryptomator/integrations-api/blob/a522f36cf45884127e2431dd18222391669d5992/src/main/java/org/cryptomator/integrations/update/UpdateMechanism.java) (#72) ## [1.7.0](https://github.com/cryptomator/integrations-api/releases/tag/1.7.0) - 2025-09-17