Sebastian Stenzel 1 月之前
父節點
當前提交
b0ed133e05

+ 5 - 0
src/main/java/module-info.java

@@ -20,6 +20,8 @@ import org.cryptomator.networking.SSLContextWithWindowsCertStore;
 import org.cryptomator.integrations.tray.TrayMenuController;
 import org.cryptomator.logging.LogbackConfiguratorFactory;
 import org.cryptomator.ui.traymenu.AwtTrayMenuController;
+import org.cryptomator.updater.MacOsDmgUpdateMechanism;
+import org.cryptomator.updater.UpdateMechanism;
 
 open module org.cryptomator.desktop {
 	requires static org.jetbrains.annotations;
@@ -61,6 +63,9 @@ open module org.cryptomator.desktop {
 	uses SSLContextProvider;
 	uses org.cryptomator.event.NotificationHandler;
 
+	// opens org.cryptomator.updater to org.cryptomator.integrations.api;
+	provides UpdateMechanism with MacOsDmgUpdateMechanism; // TODO: move to integrations-mac
+
 	provides TrayMenuController with AwtTrayMenuController;
 	provides Configurator with LogbackConfiguratorFactory;
 	provides SSLContextProvider with SSLContextWithWindowsCertStore, SSLContextWithMacKeychain, SSLContextWithPKCS12TrustStore;

+ 51 - 0
src/main/java/org/cryptomator/updater/DownloadUpdateMechanism.java

@@ -0,0 +1,51 @@
+package org.cryptomator.updater;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.util.List;
+
+public abstract class DownloadUpdateMechanism implements UpdateMechanism {
+
+	private static final ObjectMapper MAPPER = new ObjectMapper();
+
+	@Override
+	public boolean isUpdateAvailable() {
+		try (var client = HttpClient.newHttpClient()) {
+			// TODO: check different source
+			HttpRequest request = HttpRequest.newBuilder().uri(URI.create("https://api.github.com/repos/cryptomator/cryptomator/releases/latest")).header("Accept", "application/vnd.github+json").build();
+
+			HttpResponse<InputStream> response = client.send(request, HttpResponse.BodyHandlers.ofInputStream());
+
+			if (response.statusCode() != 200) {
+				throw new RuntimeException("Failed to fetch release: " + response.statusCode());
+			}
+
+			var release = MAPPER.readValue(response.body(), GitHubRelease.class);
+
+			return release.assets.stream().anyMatch(a -> a.name.endsWith("arm64.dmg"));
+		} catch (IOException | InterruptedException e) {
+			return false;
+		}
+	}
+
+	@JsonIgnoreProperties(ignoreUnknown = true)
+	public record GitHubRelease(
+			@JsonProperty("tag_name") String tagName,
+			List<Asset> assets
+	) {}
+
+	@JsonIgnoreProperties(ignoreUnknown = true)
+	public record Asset(
+			String name,
+			@JsonProperty("browser_download_url") String downloadUrl
+	) {}
+
+}

+ 165 - 0
src/main/java/org/cryptomator/updater/DownloadUpdateProcess.java

@@ -0,0 +1,165 @@
+package org.cryptomator.updater;
+
+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 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;
+	protected volatile boolean downloadSuccessful;
+
+	/**
+	 * Creates a new DownloadUpdateProcess instance.
+	 * @param workdir The directory where the update will be downloaded to. Ideally, this should be a temporary directory that is cleaned up after the update process is complete.
+	 * @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, URI uri, byte[] checksum, long estDownloadSize) {
+		this.workDir = workdir;
+		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);
+	}
+
+	@Override
+	public void cancel() {
+		downloadThread.interrupt();
+	}
+
+	private void download() {
+		try {
+			download("update.dmg");
+			downloadSuccessful = true;
+		} 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 filename the name of the file to save the update as in the working directory
+	 * @throws IOException indicating I/O errors during the download or file writing process or due to checksum mismatch
+	 */
+	protected void download(String filename) throws IOException {
+		var request = HttpRequest.newBuilder().uri(uri).GET().build();
+		var downloadFile = workDir.resolve(filename);
+		try (HttpClient client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.ALWAYS).build()) {
+			// 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(downloadFile, 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: " + filename);
+			}
+
+			// post-download processing
+			postDownload(downloadFile);
+		} catch (InterruptedException e) {
+			throw new InterruptedIOException("Download interrupted");
+		}
+	}
+
+	protected void postDownload(Path downloadedFile) 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;
+		}
+
+	}
+
+}

+ 87 - 0
src/main/java/org/cryptomator/updater/MacOsDmgUpdateMechanism.java

@@ -0,0 +1,87 @@
+package org.cryptomator.updater;
+
+import org.cryptomator.integrations.common.DisplayName;
+import org.cryptomator.integrations.common.OperatingSystem;
+import org.cryptomator.integrations.common.Priority;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.io.InterruptedIOException;
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HexFormat;
+import java.util.List;
+import java.util.UUID;
+
+
+@Priority(1000)
+@OperatingSystem(OperatingSystem.Value.MAC)
+@DisplayName("download .dmg file") // TODO: localize
+public class MacOsDmgUpdateMechanism extends DownloadUpdateMechanism {
+
+	private static final Logger LOG = LoggerFactory.getLogger(MacOsDmgUpdateMechanism.class);
+
+	@Override
+	public UpdateProcess prepareUpdate() throws IOException {
+		Path workDir = Files.createTempDirectory("cryptomator-update");
+		return new UpdateProcessImpl(workDir);
+	}
+
+	private static class UpdateProcessImpl extends DownloadUpdateProcess {
+
+		// FIXME: use URI and CHECKSUM from update API
+		private static final URI UPDATE_URI = URI.create("https://github.com/cryptomator/cryptomator/releases/download/1.17.0/Cryptomator-1.17.0-arm64.dmg");
+		private static final byte[] CHECKSUM = HexFormat.of().withLowerCase().parseHex("03f45e203204e93b39925cbb04e19c9316da4f77debaba4fb5071f0ec8e727e8");
+
+		public UpdateProcessImpl(Path workDir) {
+			super(workDir, UPDATE_URI, CHECKSUM,60_000_000L); // initially assume 60 MB for the update size
+		}
+
+		@Override
+		protected void postDownload(Path downloadedFile) throws IOException {
+			// Extract Cryptomator.app from the .dmg file
+			String script = """
+					hdiutil attach 'update.dmg' -mountpoint "/Volumes/Cryptomator_${MOUNT_ID}" -nobrowse -quiet &&
+					cp -R "/Volumes/Cryptomator_${MOUNT_ID}/Cryptomator.app" 'Cryptomator.app' &&
+					hdiutil detach "/Volumes/Cryptomator_${MOUNT_ID}" -quiet
+					""";
+			var command = List.of("bash", "-c", script);
+			var processBuilder = new ProcessBuilder(command);
+			processBuilder.directory(workDir.toFile());
+			processBuilder.environment().put("MOUNT_ID", UUID.randomUUID().toString());
+			Process p = processBuilder.start();
+			try {
+				if (p.waitFor() != 0) {
+					LOG.error("Failed to extract DMG, exit code: {}, output: {}", p.exitValue(), new String(p.getErrorStream().readAllBytes()));
+					throw new IOException("Failed to extract DMG, exit code: " + p.exitValue());
+				}
+			} catch (InterruptedException e) {
+				throw new InterruptedIOException("Failed to extract DMG, interrupted");
+			}
+		}
+
+		@Override
+		public Process applyUpdate() throws IllegalStateException, IOException {
+			if (downloadException != null) {
+				throw new IllegalStateException("Downloading update failed", downloadException);
+			} else if (!downloadSuccessful) {
+				throw new IllegalStateException("Update not yet downloaded");
+			}
+			// TODO: use /Applications/Cryptomator.app or ~/Applications/Cryptomator.app depending on the path of the current process (ProcessHandle.current().info().command()?)
+			String script = """
+					while kill -0 ${CRYPTOMATOR_PID} 2> /dev/null; do sleep 0.5; done;
+					cp -R 'Cryptomator.app' '/Applications/Cryptomator.app';
+					open -a '/Applications/Cryptomator.app'
+					""";
+			var command = List.of("bash", "-c", "nohup bash -c \"" + script + "\" >/Users/sebastian/Downloads/nohup.out 2>&1 &");
+			var processBuilder = new ProcessBuilder(command);
+			processBuilder.directory(workDir.toFile());
+			processBuilder.environment().put("CRYPTOMATOR_PID", String.valueOf(ProcessHandle.current().pid()));
+			return processBuilder.start();
+		}
+	}
+
+
+}

+ 31 - 0
src/main/java/org/cryptomator/updater/UpdateMechanism.java

@@ -0,0 +1,31 @@
+package org.cryptomator.updater;
+
+import org.cryptomator.integrations.common.IntegrationsLoader;
+import org.cryptomator.integrations.common.NamedServiceProvider;
+import org.jetbrains.annotations.Blocking;
+
+import javafx.concurrent.Task;
+import java.io.IOException;
+import java.util.stream.Stream;
+
+public interface UpdateMechanism extends NamedServiceProvider {
+
+	static Stream<UpdateMechanism> get() {
+		return IntegrationsLoader.loadAll(UpdateMechanism.class);
+	}
+
+	/**
+	 * Checks whether an update is available.
+	 * @return <code>true</code> if an update is available, <code>false</code> otherwise.
+	 */
+	@Blocking
+	boolean isUpdateAvailable();
+
+	/**
+	 * Performs as much as possible to prepare the update. This may include downloading the update, checking signatures, etc.
+	 * @return a {@link Task} that can be used to monitor the progress of the update preparation. The task will complete when the preparation is done.
+	 * @throws IOException I/O error during preparation, such as network issues or file access problems.
+	 */
+	UpdateProcess prepareUpdate() throws IOException; // TODO: exception types?
+
+}

+ 51 - 0
src/main/java/org/cryptomator/updater/UpdateProcess.java

@@ -0,0 +1,51 @@
+package org.cryptomator.updater;
+
+import java.io.IOException;
+import java.util.concurrent.TimeUnit;
+
+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.
+	 * <p>
+	 * 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.
+	 * <p>
+	 * 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.
+	 * <p>
+	 * 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 Process} 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 starting the update process fails
+	 */
+	Process applyUpdate() throws IllegalStateException, IOException;
+
+
+}

+ 25 - 0
src/test/java/org/cryptomator/updater/MacOsDmgUpdateMechanismTest.java

@@ -0,0 +1,25 @@
+package org.cryptomator.updater;
+
+import java.io.IOException;
+import java.util.concurrent.TimeUnit;
+
+class MacOsDmgUpdateMechanismTest {
+
+	public static void main(String args[]) throws InterruptedException, IOException {
+		UpdateMechanism updateMechanism = new MacOsDmgUpdateMechanism();
+		if (updateMechanism.isUpdateAvailable()) {
+			System.out.println("Update is available.");
+		}
+		var updateProcess = updateMechanism.prepareUpdate();
+		do {
+			double percentage = updateProcess.preparationProgress() * 100.0;
+			System.out.printf("\rPreparing update: %.2f%%", percentage);
+		} while (!updateProcess.await(100, TimeUnit.MILLISECONDS));
+		System.out.println("\nUpdate ready...");
+		Process p = updateProcess.applyUpdate();
+		p.isAlive();
+		System.out.println("Update running, exiting...");
+		// exit.
+	}
+
+}