From 88f72310007820daa8354f92fb84ddc0956108b8 Mon Sep 17 00:00:00 2001 From: Ralph Plawetzki Date: Sat, 28 Jun 2025 13:18:40 +0200 Subject: [PATCH 01/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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)));