Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/main/java/module-info.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,5 +26,6 @@
provides UiAppearanceProvider with WinUiAppearanceProvider;
provides RevealPathService with ExplorerRevealPathService;
provides QuickAccessService with ExplorerQuickAccessService;
provides UpdateMechanism with InstallerUpdateMechanism;

}
26 changes: 21 additions & 5 deletions src/main/java/org/cryptomator/windows/common/RegistryKey.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* <p>
* 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);
}
}
Expand All @@ -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);

Expand All @@ -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);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> installType;

public InstallerUpdateMechanism() {
this(InstallerUpdateMechanism::readInstallType);
}

// visible for testing
InstallerUpdateMechanism(Supplier<String> 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;
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
org.cryptomator.windows.update.InstallerUpdateMechanism
5 changes: 4 additions & 1 deletion src/main/resources/WinIntegrationsBundle.properties
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
org.cryptomator.windows.keychain.displayName=Windows Data Protection
org.cryptomator.windows.keychain.displayWindowsHelloName=Windows Hello
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...
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}

}
Loading