diff --git a/CHANGELOG.md b/CHANGELOG.md
index 75d818e..1c2c717 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,7 +9,8 @@ Changes to prior versions can be found on the [GitHub release page](https://gith
## Unreleased
-No changes yet.
+### Added
+* Update mechanism `InstallerUpdateMechanism`, which downloads, verifies and runs the EXE or MSI installer matching the current installation. Self-updates are only performed if the registry value `HKLM\SOFTWARE\Skymatic GmbH\Cryptomator\InstallType` is `EXE` or `MSI`.
## [1.6.2](https://github.com/cryptomator/integrations-win/releases/1.6.2) - 2026-09-29
diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java
index 0ebecda..af5a6fb 100644
--- a/src/main/java/module-info.java
+++ b/src/main/java/module-info.java
@@ -3,12 +3,14 @@
import org.cryptomator.integrations.quickaccess.QuickAccessService;
import org.cryptomator.integrations.revealpath.RevealPathService;
import org.cryptomator.integrations.uiappearance.UiAppearanceProvider;
+import org.cryptomator.integrations.update.UpdateMechanism;
import org.cryptomator.windows.autostart.WindowsAutoStart;
import org.cryptomator.windows.keychain.WindowsHelloKeychainAccess;
import org.cryptomator.windows.keychain.WindowsProtectedKeychainAccess;
import org.cryptomator.windows.quickaccess.ExplorerQuickAccessService;
import org.cryptomator.windows.revealpath.ExplorerRevealPathService;
import org.cryptomator.windows.uiappearance.WinUiAppearanceProvider;
+import org.cryptomator.windows.update.InstallerUpdateMechanism;
module org.cryptomator.integrations.win {
requires org.cryptomator.integrations.api;
@@ -24,5 +26,6 @@
provides UiAppearanceProvider with WinUiAppearanceProvider;
provides RevealPathService with ExplorerRevealPathService;
provides QuickAccessService with ExplorerQuickAccessService;
+ provides UpdateMechanism with InstallerUpdateMechanism;
}
\ No newline at end of file
diff --git a/src/main/java/org/cryptomator/windows/common/RegistryKey.java b/src/main/java/org/cryptomator/windows/common/RegistryKey.java
index 42fe644..dfc5495 100644
--- a/src/main/java/org/cryptomator/windows/common/RegistryKey.java
+++ b/src/main/java/org/cryptomator/windows/common/RegistryKey.java
@@ -47,8 +47,23 @@ public class RegistryKey implements AutoCloseable {
* @throws RegistryValueException if winreg.h:RegGetValueW returns a result != ERROR_SUCCESS
*/
public String getStringValue(String name, boolean isExpandable) throws RegistryValueException {
+ return getStringValue(null, name, isExpandable);
+ }
+
+ /**
+ * Gets a REG_SZ or REG_EXPAND_SZ value of a subkey of this registry key, only requiring read access to the subkey.
+ *
+ * The size of the data is restricted to at most {@value MAX_DATA_SIZE}. If the the value exceeds the size, a runtime exception is thrown.
+ *
+ * @param subkey name/path of the subkey containing the value or {@code null} to read the value of this key
+ * @param name name of the value
+ * @param isExpandable flag indicating if the value is of type REG_EXPAND_SZ
+ * @return the data of the value
+ * @throws RegistryValueException if winreg.h:RegGetValueW returns a result != ERROR_SUCCESS
+ */
+ public String getStringValue(String subkey, String name, boolean isExpandable) throws RegistryValueException {
try (var arena = Arena.ofConfined()) {
- var data = getValue(arena, name, isExpandable ? RRF_RT_REG_EXPAND_SZ() : RRF_RT_REG_SZ());
+ var data = getValue(arena, subkey, name, isExpandable ? RRF_RT_REG_EXPAND_SZ() : RRF_RT_REG_SZ());
return data.getString(0, StandardCharsets.UTF_16LE);
}
}
@@ -62,12 +77,13 @@ public String getStringValue(String name, boolean isExpandable) throws RegistryV
*/
public int getDwordValue(String name) throws RegistryValueException {
try (var arena = Arena.ofConfined()) {
- var data = getValue(arena, name, RRF_RT_REG_DWORD());
+ var data = getValue(arena, null, name, RRF_RT_REG_DWORD());
return data.get(ValueLayout.JAVA_INT, 0);
}
}
- private MemorySegment getValue(Arena arena, String name, int dwFlags) throws RegistryValueException {
+ private MemorySegment getValue(Arena arena, String subkey, String name, int dwFlags) throws RegistryValueException {
+ var lpSubKey = subkey == null ? NULL : arena.allocateFrom(subkey, StandardCharsets.UTF_16LE);
var lpValueName = arena.allocateFrom(name, StandardCharsets.UTF_16LE);
var lpDataSize = arena.allocateFrom(ValueLayout.JAVA_INT, 0);
@@ -82,14 +98,14 @@ private MemorySegment getValue(Arena arena, String name, int dwFlags) throws Reg
lpData = arena.allocate(bufferSize);
lpDataSize.set(ValueLayout.JAVA_INT, 0, bufferSize);
- result = Winreg_h.RegGetValueW(handle, NULL, lpValueName, dwFlags, NULL, lpData, lpDataSize);
+ result = Winreg_h.RegGetValueW(handle, lpSubKey, lpValueName, dwFlags, NULL, lpData, lpDataSize);
} while (result == ERROR_MORE_DATA());
if (result == ERROR_SUCCESS()) {
return lpData;
} else {
- throw new RegistryValueException("winreg_h:RegGetValue", path, name, result);
+ throw new RegistryValueException("winreg_h:RegGetValue", subkey == null ? path : path + "\\" + subkey, name, result);
}
}
diff --git a/src/main/java/org/cryptomator/windows/update/InstallerUpdateMechanism.java b/src/main/java/org/cryptomator/windows/update/InstallerUpdateMechanism.java
new file mode 100644
index 0000000..7caa83f
--- /dev/null
+++ b/src/main/java/org/cryptomator/windows/update/InstallerUpdateMechanism.java
@@ -0,0 +1,172 @@
+package org.cryptomator.windows.update;
+
+import org.cryptomator.integrations.common.LocalizedDisplayName;
+import org.cryptomator.integrations.common.OperatingSystem;
+import org.cryptomator.integrations.update.DownloadUpdateInfo;
+import org.cryptomator.integrations.update.DownloadUpdateMechanism;
+import org.cryptomator.integrations.update.UpdateFailedException;
+import org.cryptomator.integrations.update.UpdateMechanism;
+import org.cryptomator.integrations.update.UpdateStep;
+import org.cryptomator.windows.common.Localization;
+import org.cryptomator.windows.common.RegistryKey;
+import org.cryptomator.windows.common.RegistryValueException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.io.InterruptedIOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.List;
+import java.util.Locale;
+import java.util.Optional;
+import java.util.function.Supplier;
+
+import static org.cryptomator.windows.capi.common.Windows_h.ERROR_FILE_NOT_FOUND;
+
+/**
+ * Updates Cryptomator by downloading and running the same kind of installer (EXE bundle or MSI) that was used to install it, unless it is managed by a package manager.
+ */
+@OperatingSystem(OperatingSystem.Value.WINDOWS)
+@LocalizedDisplayName(bundle = "WinIntegrationsBundle", key = "org.cryptomator.windows.update.installer.displayName")
+public class InstallerUpdateMechanism extends DownloadUpdateMechanism {
+
+ private static final Logger LOG = LoggerFactory.getLogger(InstallerUpdateMechanism.class);
+ // written by the Cryptomator installer. Package managers (winget, Chocolatey, ...) set the INSTALLTYPE installer property to a different value, opting out of self-updates.
+ private static final String INSTALL_TYPE_REG_KEY = "SOFTWARE\\Skymatic GmbH\\Cryptomator";
+ private static final String INSTALL_TYPE_REG_VALUE = "InstallType";
+ private static final String EXPECTED_SIGNER_SUBJECT_PATTERN = "CN=Skymatic GmbH,*";
+ private static final Path SYSTEM32 = Path.of(Optional.ofNullable(System.getenv("SystemRoot")).orElse("C:\\Windows"), "System32");
+
+ private final Supplier installType;
+
+ public InstallerUpdateMechanism() {
+ this(InstallerUpdateMechanism::readInstallType);
+ }
+
+ // visible for testing
+ InstallerUpdateMechanism(Supplier installType) {
+ this.installType = installType;
+ }
+
+ @Override
+ protected DownloadUpdateInfo checkForUpdate(String currentVersion, LatestVersionResponse response) {
+ String arch = switch (System.getProperty("os.arch")) {
+ case "aarch64", "arm64" -> "arm64";
+ default -> "x64";
+ };
+ // stick to the installer type used for the current installation, so that the entry in "Apps & Features" stays accurate
+ String installType = this.installType.get();
+ String extension = switch (installType == null ? "" : installType.toUpperCase(Locale.ROOT)) {
+ case "EXE" -> ".exe";
+ case "MSI" -> ".msi";
+ default -> null;
+ };
+ if (extension == null) {
+ LOG.info("Install type is {}, not updating.", installType);
+ return null;
+ }
+ String suffix = "-" + arch + extension;
+ var updateVersion = response.latestVersion().winVersion();
+ var asset = response.assets().stream().filter(a -> a.name().endsWith(suffix)).findAny().orElse(null);
+
+ if (updateVersion != null && asset != null && UpdateMechanism.isUpdateAvailable(updateVersion, currentVersion)) {
+ return new DownloadUpdateInfo(this, updateVersion, asset);
+ } else {
+ return null;
+ }
+ }
+
+ @Override
+ public UpdateStep secondStep(Path workDir, Path assetPath, DownloadUpdateInfo updateInfo) {
+ return UpdateStep.of(Localization.get().getString("org.cryptomator.windows.update.installer.verifying"), () -> this.verify(workDir, assetPath));
+ }
+
+ private UpdateStep verify(Path workDir, Path assetPath) throws IOException {
+ // Verify the Authenticode signature of the downloaded installer. The script must not contain double quotes, as it is passed as a command line argument.
+ var script = """
+ $s = Get-AuthenticodeSignature -LiteralPath $env:INSTALLER_PATH; \
+ Write-Output $s.Status, $s.SignerCertificate.Subject; \
+ if ($s.Status -ne 'Valid' -or $s.SignerCertificate.Subject -notlike $env:EXPECTED_SIGNER) { exit 1 }""";
+ var processBuilder = new ProcessBuilder(List.of(SYSTEM32.resolve("WindowsPowerShell\\v1.0\\powershell.exe").toString(), "-NoProfile", "-NonInteractive", "-Command", script));
+ processBuilder.directory(workDir.toFile());
+ processBuilder.redirectErrorStream(true);
+ processBuilder.environment().remove("PSModulePath"); // inherited PowerShell 7 module paths prevent Windows PowerShell from loading its own modules
+ processBuilder.environment().put("INSTALLER_PATH", assetPath.toString());
+ processBuilder.environment().put("EXPECTED_SIGNER", EXPECTED_SIGNER_SUBJECT_PATTERN);
+ Process p = processBuilder.start();
+ try {
+ p.getOutputStream().close();
+ var output = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8).strip();
+ if (p.waitFor() != 0) {
+ LOG.error("Checking signature of {} failed, exit code: {}, output: {}", assetPath, p.exitValue(), output);
+ throw new UpdateFailedException("Invalid Signature.");
+ }
+ LOG.debug("Verified installer {}: {}", assetPath, output);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new InterruptedIOException("Signature verification interrupted");
+ }
+ return UpdateStep.of(Localization.get().getString("org.cryptomator.windows.update.installer.restarting"), () -> this.restart(workDir, assetPath));
+ }
+
+ private UpdateStep restart(Path workDir, Path assetPath) throws IOException {
+ String selfPath = ProcessHandle.current().info().command().orElse("");
+ if (!selfPath.toLowerCase(Locale.ROOT).endsWith(".exe")) {
+ throw new UpdateFailedException("Cannot determine Cryptomator executable, current path: " + selfPath);
+ }
+ Path executable = Path.of(selfPath);
+ String installerType = assetPath.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".msi") ? "msi" : "exe";
+ LOG.info("Restarting to apply update in {} now...", workDir);
+ // System tools are called by full path, as PATH may contain look-alikes (e.g. GNU find). `timeout` fails if stdin is redirected, hence `ping` is used to sleep.
+ // INSTALLDIR is only supported by the MSI, the bundle always installs to its default location.
+ String script = """
+ @echo off
+ set "SYS32=%SystemRoot%\\System32"
+ :waitForExit
+ "%SYS32%\\tasklist.exe" /NH /FI "PID eq %CRYPTOMATOR_PID%" 2>nul | "%SYS32%\\find.exe" " %CRYPTOMATOR_PID% " >nul
+ if not errorlevel 1 (
+ "%SYS32%\\PING.EXE" -n 2 127.0.0.1 >nul
+ goto waitForExit
+ )
+ echo Running %INSTALLER_TYPE% installer %INSTALLER_PATH%
+ if /i "%INSTALLER_TYPE%"=="msi" (
+ start "" /wait "%SYS32%\\msiexec.exe" /i "%INSTALLER_PATH%" /passive /norestart INSTALLDIR="%CRYPTOMATOR_INSTALL_DIR%" /l*v "%~dp0installer.log"
+ ) else (
+ start "" /wait "%INSTALLER_PATH%" /passive /norestart /log "%~dp0installer.log"
+ )
+ echo Installer exited with code %ERRORLEVEL%
+ start "" "%CRYPTOMATOR_EXE%"
+ """.replace("\n", "\r\n");
+ Path scriptPath = workDir.resolve("install.cmd");
+ Files.writeString(scriptPath, script, StandardCharsets.US_ASCII, StandardOpenOption.WRITE, StandardOpenOption.CREATE_NEW);
+ var processBuilder = new ProcessBuilder(List.of(SYSTEM32.resolve("cmd.exe").toString(), "/c", scriptPath.toString()));
+ processBuilder.directory(workDir.toFile());
+ processBuilder.redirectErrorStream(true);
+ processBuilder.redirectOutput(workDir.resolve("install.log").toFile());
+ processBuilder.environment().put("CRYPTOMATOR_PID", String.valueOf(ProcessHandle.current().pid()));
+ processBuilder.environment().put("CRYPTOMATOR_EXE", executable.toString());
+ processBuilder.environment().put("CRYPTOMATOR_INSTALL_DIR", executable.getParent().toString());
+ processBuilder.environment().put("INSTALLER_TYPE", installerType);
+ processBuilder.environment().put("INSTALLER_PATH", assetPath.toString());
+ processBuilder.start();
+
+ return UpdateStep.EXIT;
+ }
+
+ private static String readInstallType() {
+ try {
+ return RegistryKey.HKEY_LOCAL_MACHINE.getStringValue(INSTALL_TYPE_REG_KEY, INSTALL_TYPE_REG_VALUE, false);
+ } catch (RegistryValueException e) {
+ if (e.getSystemErrorCode() == ERROR_FILE_NOT_FOUND()) {
+ LOG.debug("Install type not found in registry.");
+ } else {
+ LOG.warn("Failed to read install type from registry.", e);
+ }
+ return null;
+ }
+ }
+
+}
diff --git a/src/main/resources/META-INF/services/org.cryptomator.integrations.update.UpdateMechanism b/src/main/resources/META-INF/services/org.cryptomator.integrations.update.UpdateMechanism
new file mode 100644
index 0000000..45a2a5a
--- /dev/null
+++ b/src/main/resources/META-INF/services/org.cryptomator.integrations.update.UpdateMechanism
@@ -0,0 +1 @@
+org.cryptomator.windows.update.InstallerUpdateMechanism
diff --git a/src/main/resources/WinIntegrationsBundle.properties b/src/main/resources/WinIntegrationsBundle.properties
index 47f8ba5..a7b6682 100644
--- a/src/main/resources/WinIntegrationsBundle.properties
+++ b/src/main/resources/WinIntegrationsBundle.properties
@@ -1,2 +1,5 @@
org.cryptomator.windows.keychain.displayName=Windows Data Protection
-org.cryptomator.windows.keychain.displayWindowsHelloName=Windows Hello
\ No newline at end of file
+org.cryptomator.windows.keychain.displayWindowsHelloName=Windows Hello
+org.cryptomator.windows.update.installer.displayName=Download installer
+org.cryptomator.windows.update.installer.verifying=Verifying...
+org.cryptomator.windows.update.installer.restarting=Restarting...
\ No newline at end of file
diff --git a/src/test/java/org/cryptomator/windows/common/WindowsRegistryIT.java b/src/test/java/org/cryptomator/windows/common/WindowsRegistryIT.java
index 75f86a3..a1280dd 100644
--- a/src/test/java/org/cryptomator/windows/common/WindowsRegistryIT.java
+++ b/src/test/java/org/cryptomator/windows/common/WindowsRegistryIT.java
@@ -47,6 +47,22 @@ public void testDeleteIgnoreNotExisting() throws WindowsException {
}
}
+ @Test
+ @DisplayName("Read value of HKLM subkey without write access succeeds")
+ @Order(1)
+ public void testGetStringValueOfSubkey() throws RegistryValueException {
+ var buildNumber = RegistryKey.HKEY_LOCAL_MACHINE.getStringValue("SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion", "CurrentBuildNumber", false);
+ Assertions.assertTrue(buildNumber.matches("\\d+"));
+ }
+
+ @Test
+ @DisplayName("Read value of not existing subkey fails")
+ @Order(1)
+ public void testGetStringValueOfNotExistingSubkey() {
+ var winException = Assertions.assertThrows(RegistryValueException.class, () -> RegistryKey.HKEY_LOCAL_MACHINE.getStringValue("i\\do\\not\\exist", "foo", false));
+ Assertions.assertEquals(ERROR_FILE_NOT_FOUND(), winException.getSystemErrorCode());
+ }
+
@Test
@DisplayName("Create and no commit leads to rollback")
@Order(1)
diff --git a/src/test/java/org/cryptomator/windows/update/InstallerUpdateMechanismTest.java b/src/test/java/org/cryptomator/windows/update/InstallerUpdateMechanismTest.java
new file mode 100644
index 0000000..5c997aa
--- /dev/null
+++ b/src/test/java/org/cryptomator/windows/update/InstallerUpdateMechanismTest.java
@@ -0,0 +1,73 @@
+package org.cryptomator.windows.update;
+
+import org.cryptomator.integrations.update.DownloadUpdateMechanism.Asset;
+import org.cryptomator.integrations.update.DownloadUpdateMechanism.LatestVersion;
+import org.cryptomator.integrations.update.DownloadUpdateMechanism.LatestVersionResponse;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Assumptions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.NullAndEmptySource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.util.List;
+
+public class InstallerUpdateMechanismTest {
+
+ private static final Asset DMG = new Asset("Cryptomator-1.19.3-x64.dmg", "sha256:00", 1, "https://example.com/Cryptomator-1.19.3-x64.dmg");
+ private static final Asset EXE = new Asset("Cryptomator-1.19.3-x64.exe", "sha256:00", 1, "https://example.com/Cryptomator-1.19.3-x64.exe");
+ private static final Asset MSI = new Asset("Cryptomator-1.19.3-x64.msi", "sha256:00", 1, "https://example.com/Cryptomator-1.19.3-x64.msi");
+ private static final LatestVersionResponse RESPONSE = new LatestVersionResponse(new LatestVersion("1.19.4", "1.19.3", "1.19.2"), List.of(DMG, EXE, MSI));
+
+ @BeforeAll
+ public static void setup() {
+ Assumptions.assumeTrue(System.getProperty("os.arch").equals("amd64"), "Test assets only exist for x64");
+ }
+
+ @ParameterizedTest
+ @CsvSource({"EXE, Cryptomator-1.19.3-x64.exe", "MSI, Cryptomator-1.19.3-x64.msi", "msi, Cryptomator-1.19.3-x64.msi"})
+ public void testAssetMatchesInstallType(String installType, String expectedAsset) {
+ var mechanism = new InstallerUpdateMechanism(() -> installType);
+
+ var updateInfo = mechanism.checkForUpdate("1.19.2", RESPONSE);
+
+ Assertions.assertNotNull(updateInfo);
+ Assertions.assertEquals("1.19.3", updateInfo.version());
+ Assertions.assertEquals(expectedAsset, updateInfo.asset().name());
+ Assertions.assertSame(mechanism, updateInfo.updateMechanism());
+ }
+
+ @ParameterizedTest
+ @NullAndEmptySource
+ @ValueSource(strings = {"winget", "chocolatey", "managed"})
+ public void testNoUpdateIfNotSelfManaged(String installType) {
+ var mechanism = new InstallerUpdateMechanism(() -> installType);
+
+ var updateInfo = mechanism.checkForUpdate("1.19.2", RESPONSE);
+
+ Assertions.assertNull(updateInfo);
+ }
+
+ @ParameterizedTest
+ @CsvSource({"1.19.3", "1.20.0"})
+ public void testNoUpdateIfUpToDate(String currentVersion) {
+ var mechanism = new InstallerUpdateMechanism(() -> "MSI");
+
+ var updateInfo = mechanism.checkForUpdate(currentVersion, RESPONSE);
+
+ Assertions.assertNull(updateInfo);
+ }
+
+ @Test
+ public void testNoUpdateIfAssetMissing() {
+ var mechanism = new InstallerUpdateMechanism(() -> "MSI");
+ var response = new LatestVersionResponse(RESPONSE.latestVersion(), List.of(DMG, EXE));
+
+ var updateInfo = mechanism.checkForUpdate("1.19.2", response);
+
+ Assertions.assertNull(updateInfo);
+ }
+
+}