diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/release/DownloadUpdateImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/release/DownloadUpdateImpl.java index fdb6234..31b3ecd 100644 --- a/backend/src/main/java/com/devaulty/backend/application/impl/release/DownloadUpdateImpl.java +++ b/backend/src/main/java/com/devaulty/backend/application/impl/release/DownloadUpdateImpl.java @@ -11,7 +11,7 @@ import java.io.File; import java.io.IOException; -import java.nio.channels.AsynchronousFileChannel; +import java.io.OutputStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; @@ -94,28 +94,33 @@ private Flux startDownloadProcess(String downloadUrl, long t ); return Flux.using( - () -> AsynchronousFileChannel.open( + () -> Files.newOutputStream( targetPath, StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING ), - fileChannel -> { + outputStream -> { Flux downloadProgress = releasePort.downloadAsset(downloadUrl) .timeout(DOWNLOAD_TIMEOUT) - .concatMap(dataBuffer -> { - int chunkSize = dataBuffer.readableByteCount(); - long currentTotal = downloadedBytes.addAndGet(chunkSize); - int percentage = knownSize ? (int) ((currentTotal * 100) / totalBytes) : 0; - - UpdateProgressInfo progressInfo = new UpdateProgressInfo( - UpdateStatus.DOWNLOADING, percentage, currentTotal, totalBytes, null - ); - - return DataBufferUtils.write(Flux.just(dataBuffer), fileChannel) - .then(Mono.just(progressInfo)); - }) + .concatMap(dataBuffer -> Mono.fromCallable(() -> { + int chunkSize = dataBuffer.readableByteCount(); + + byte[] bytes = new byte[chunkSize]; + dataBuffer.read(bytes); + outputStream.write(bytes); + + long currentTotal = downloadedBytes.addAndGet(chunkSize); + int percentage = knownSize ? (int) ((currentTotal * 100) / totalBytes) : 0; + + return new UpdateProgressInfo( + UpdateStatus.DOWNLOADING, percentage, currentTotal, totalBytes, null + ); + }) + .subscribeOn(Schedulers.boundedElastic()) + .doFinally(signal -> DataBufferUtils.release(dataBuffer)) + ) .distinctUntilChanged(p -> knownSize ? p.percentage() : p.downloadedBytes()) .sample(PROGRESS_SAMPLE_INTERVAL) .concatWith(Mono.defer(() -> Mono.just(new UpdateProgressInfo( @@ -126,7 +131,6 @@ private Flux startDownloadProcess(String downloadUrl, long t null )))); - Mono installAndComplete = Mono .fromRunnable(() -> installUpdateUseCase.execute(targetPath)) .subscribeOn(Schedulers.boundedElastic()) @@ -151,7 +155,7 @@ private Flux startDownloadProcess(String downloadUrl, long t .concatWith(installAndComplete); }, - this::closeChannel + this::closeOutputStream ).onErrorResume(ex -> deleteQuietly(targetPath) .then(Mono.just(new UpdateProgressInfo( UpdateStatus.FAILED, 0, downloadedBytes.get(), totalBytes, ex.getMessage() @@ -169,12 +173,13 @@ private Mono deleteQuietly(Path path) { }); } - private void closeChannel(AsynchronousFileChannel channel) { - if (channel != null && channel.isOpen()) { + private void closeOutputStream(OutputStream outputStream) { + if (outputStream != null) { try { - channel.close(); + outputStream.flush(); + outputStream.close(); } catch (Exception ignored) { - // Defensive channel closure + // Defensive stream closure } } } diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/release/InstallUpdateImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/release/InstallUpdateImpl.java index 15c3e79..b2f0215 100644 --- a/backend/src/main/java/com/devaulty/backend/application/impl/release/InstallUpdateImpl.java +++ b/backend/src/main/java/com/devaulty/backend/application/impl/release/InstallUpdateImpl.java @@ -80,10 +80,13 @@ private void launchLinuxInstaller(String filePath, long currentPid) throws IOExc // Wait for the current process to exit, install the package with pkexec, // then relaunch the app fully detached (nohup + setsid) so it survives // after the installer script exits. + // Uses ";" instead of "&&" so the app always relaunches, even if the + // installation fails — the previous version is still installed and + // should remain usable. String script = "PID=\"$1\"; FILE=\"$2\"; PROGRAM=\"$3\"; FLAG=\"$4\"; " + "while kill -0 \"$PID\" 2>/dev/null; do sleep 0.2; done; " + - "pkexec \"$PROGRAM\" \"$FLAG\" \"$FILE\" && " + + "pkexec \"$PROGRAM\" \"$FLAG\" \"$FILE\"; " + "nohup setsid devaulty >/dev/null 2>&1 &"; List command = List.of( @@ -95,11 +98,16 @@ private void launchLinuxInstaller(String filePath, long currentPid) throws IOExc } private void launchMacInstaller(String filePath, long currentPid) throws IOException { - // Read the note about limitations at the top of the class + // Read the note about limitations at the top of the class. + // After mounting the DMG the app is always relaunched so the user is + // not left without a running instance. The relaunch starts the + // currently-installed version; the user still needs to manually copy + // the new .app from the mounted image into /Applications. String script = "PID=\"$1\"; FILE=\"$2\"; " + "while kill -0 \"$PID\" 2>/dev/null; do sleep 0.2; done; " + - "open \"$FILE\""; + "open \"$FILE\"; " + + "sleep 2; open -a Devaulty"; List command = List.of( "bash", "-c", script, "bash", @@ -157,7 +165,7 @@ private File verifyWithinTempDir(File installerFile, File tempDir) { // Temp folder resolution delegated to ReleaseTempFolder to ensure both // DownloadUpdateImpl and InstallUpdateImpl always use the same directory. - private void scheduleApplicationShutdown() { + protected void scheduleApplicationShutdown() { scheduler.schedule(() -> { logger.info("Shutting down current application instance for update replacement..."); int exitCode = SpringApplication.exit(applicationContext, () -> 0); diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/release/ReleaseTempFolder.java b/backend/src/main/java/com/devaulty/backend/application/impl/release/ReleaseTempFolder.java index d7825ac..83f0f9b 100644 --- a/backend/src/main/java/com/devaulty/backend/application/impl/release/ReleaseTempFolder.java +++ b/backend/src/main/java/com/devaulty/backend/application/impl/release/ReleaseTempFolder.java @@ -8,12 +8,26 @@ * {@link InstallUpdateImpl} must resolve the same directory so that the * installer file written by the download step can be located by the install * step without path mismatches. + * Can be overridden via {@code -Ddevaulty.temp.dir} (e.g. in tests for isolation). */ public final class ReleaseTempFolder { private ReleaseTempFolder() {} public static File resolve() { + String customDir = System.getProperty("devaulty.temp.dir"); + if (customDir != null && !customDir.isBlank()) { + File tempDir = new File(customDir); + if (!tempDir.exists()) { + tempDir.mkdirs(); + } + if (!tempDir.isDirectory()) { + throw new IllegalStateException( + "Configured devaulty.temp.dir is not a valid directory: " + customDir); + } + return tempDir; + } + String userHome = System.getProperty("user.home"); String os = System.getProperty("os.name").toLowerCase(); diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml index 9019d2c..fc2e24d 100644 --- a/backend/src/main/resources/application.yaml +++ b/backend/src/main/resources/application.yaml @@ -1,5 +1,5 @@ app: - version: "0.1.4-alpha" + version: "0.1.5-alpha" spring: application: diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/release/DownloadUpdateImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/release/DownloadUpdateImplTest.java index 0f981e5..992f5f7 100644 --- a/backend/src/test/java/com/devaulty/backend/application/impl/release/DownloadUpdateImplTest.java +++ b/backend/src/test/java/com/devaulty/backend/application/impl/release/DownloadUpdateImplTest.java @@ -7,9 +7,12 @@ import com.devaulty.backend.application.port.in.release.UpdateProgressInfo; import com.devaulty.backend.application.port.in.release.enums.UpdateStatus; import com.devaulty.backend.application.port.out.external.release.ReleasePort; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -17,6 +20,7 @@ import reactor.core.publisher.Flux; import java.nio.charset.StandardCharsets; +import java.nio.file.Path; import java.time.Instant; import java.util.List; @@ -27,6 +31,9 @@ @ExtendWith(MockitoExtension.class) class DownloadUpdateImplTest { + @TempDir + Path tempFolder; + @Mock private ReleasePort releasePort; @@ -39,6 +46,16 @@ class DownloadUpdateImplTest { @InjectMocks private DownloadUpdateImpl downloadUpdateUseCase; + @BeforeEach + void setUp() { + System.setProperty("devaulty.temp.dir", tempFolder.toString()); + } + + @AfterEach + void tearDown() { + System.clearProperty("devaulty.temp.dir"); + } + @Test @DisplayName("Should throw UpdateNotAvailableException when updateAvailable is false") void shouldThrowUpdateNotAvailableException_whenNoUpdateAvailable() { diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/release/InstallUpdateImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/release/InstallUpdateImplTest.java index d2ce501..d49e165 100644 --- a/backend/src/test/java/com/devaulty/backend/application/impl/release/InstallUpdateImplTest.java +++ b/backend/src/test/java/com/devaulty/backend/application/impl/release/InstallUpdateImplTest.java @@ -1,11 +1,13 @@ package com.devaulty.backend.application.impl.release; import com.devaulty.backend.application.exception.UpdateNotAvailableException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; import org.mockito.Mock; -import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ConfigurableApplicationContext; @@ -21,11 +23,24 @@ @ExtendWith(MockitoExtension.class) class InstallUpdateImplTest { + @TempDir + Path tempFolder; + @Mock private ConfigurableApplicationContext applicationContext; - @Spy - private InstallUpdateImpl installUpdateUseCase = new InstallUpdateImpl(applicationContext); + private InstallUpdateImpl installUpdateUseCase; + + @BeforeEach + void setUp() { + System.setProperty("devaulty.temp.dir", tempFolder.toString()); + installUpdateUseCase = spy(new InstallUpdateImpl(applicationContext)); + } + + @AfterEach + void tearDown() { + System.clearProperty("devaulty.temp.dir"); + } @Test @DisplayName("Should throw UpdateNotAvailableException when installer file does not exist") @@ -52,32 +67,18 @@ void shouldThrowIllegalStateException_whenInstallerFileOutsideTempDir() throws I @DisplayName("Should launch installer process without executing real system commands during unit tests") void shouldLaunchInstallerProcess_withoutExecutingRealSystemCommands() throws IOException { // Arrange - String userHome = System.getProperty("user.home"); - String os = System.getProperty("os.name").toLowerCase(); - File tempDir; - if (os.contains("win")) { - String localAppData = System.getenv("LOCALAPPDATA"); - File base = localAppData != null ? new File(localAppData) : new File(userHome, "AppData/Local"); - tempDir = new File(base, "devaulty/temp"); - } else if (os.contains("mac")) { - tempDir = new File(userHome, "Library/Caches/devaulty/temp"); - } else { - tempDir = new File(userHome, ".config/devaulty/temp"); - } - - if (!tempDir.exists()) { - tempDir.mkdirs(); - } - + File tempDir = tempFolder.toFile(); File testInstaller = new File(tempDir, "test_installer.deb"); testInstaller.createNewFile(); - testInstaller.deleteOnExit(); - // Prevent real pkexec / msiexec execution during unit test by mocking startDetached + // Prevent real pkexec / msiexec execution and System.exit during unit test doNothing().when(installUpdateUseCase).startDetached(anyList()); + doNothing().when(installUpdateUseCase).scheduleApplicationShutdown(); // Act & Assert assertDoesNotThrow(() -> installUpdateUseCase.execute(testInstaller.toPath())); verify(installUpdateUseCase, times(1)).startDetached(anyList()); + verify(installUpdateUseCase, times(1)).scheduleApplicationShutdown(); } } + diff --git a/frontend/src/components/RootLayout.tsx b/frontend/src/components/RootLayout.tsx index e7aa945..2103621 100644 --- a/frontend/src/components/RootLayout.tsx +++ b/frontend/src/components/RootLayout.tsx @@ -17,8 +17,10 @@ import { useCheckUpdatesMutation, } from "~features/releases/hooks/useReleases"; import { UpdateModal } from "~features/releases/components/UpdateModal"; +import { formatVersionTag } from "../utils/versionUtils"; import type { AppUpdateInfoResponse } from "~types/api"; + // ────────────────────────────────────────────────────────────── // Edge toggle button — floats half in / half out of the sidebar. // Uses a CSS custom property so its `left` transitions in sync @@ -65,13 +67,14 @@ const NavigationSidebar: React.FC<{ const activeProjects = projects.filter((p) => !p.archived); const handleManualCheck = async () => { + try { const res = await checkUpdatesMutation.mutateAsync(); if (res.updateAvailable) { onOpenUpdateModal(res); } else { toast.success( - `Devaulty is up to date (v${res.currentVersion || versionData?.actualVersion || ""})` + `Devaulty is up to date (${formatVersionTag(res.currentVersion || versionData?.actualVersion)})` ); } } catch { @@ -102,7 +105,8 @@ const NavigationSidebar: React.FC<{ className="text-[10px] font-mono text-muted-foreground/70 hover:text-foreground transition-colors border border-border/40 px-2 py-0.5 rounded-full bg-secondary/20 cursor-pointer flex items-center gap-1" title="Click to check for updates" > - v{versionData.actualVersion} + {formatVersionTag(versionData.actualVersion)} + {checkUpdatesMutation.isPending && ( )} diff --git a/frontend/src/features/releases/components/UpdateModal.module.css b/frontend/src/features/releases/components/UpdateModal.module.css index 2e0afdd..ec51148 100644 --- a/frontend/src/features/releases/components/UpdateModal.module.css +++ b/frontend/src/features/releases/components/UpdateModal.module.css @@ -158,14 +158,72 @@ border-radius: var(--radius-md, 8px); padding: 1rem; font-size: 0.82rem; - line-height: 1.5; + line-height: 1.6; color: var(--color-foreground, #cbd5e1); - max-height: 180px; + max-height: 220px; overflow-y: auto; - white-space: pre-wrap; overflow-wrap: anywhere; } +.releaseNotesContent p { + margin: 0 0 0.5rem 0; +} + +.releaseNotesContent p:last-child { + margin-bottom: 0; +} + +.releaseNotesContent a { + color: var(--color-primary, #6366f1); + text-decoration: underline; + word-break: break-all; + transition: color 0.15s ease; +} + +.releaseNotesContent a:hover { + color: #818cf8; +} + +.releaseNotesContent strong, +.releaseNotesContent b { + color: #f3f4f6; + font-weight: 600; +} + +.releaseNotesContent ul, +.releaseNotesContent ol { + margin: 0.25rem 0 0.5rem 1.25rem; + padding: 0; +} + +.releaseNotesContent li { + margin-bottom: 0.25rem; +} + +.releaseNotesContent code { + font-family: var(--font-mono, monospace); + background: rgba(255, 255, 255, 0.1); + padding: 0.15rem 0.35rem; + border-radius: 4px; + font-size: 0.78rem; + color: #a5b4fc; +} + +.releaseNotesContent pre { + background: #090d16; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + padding: 0.75rem; + overflow-x: auto; + margin: 0.5rem 0; +} + +.releaseNotesContent pre code { + background: transparent; + padding: 0; +} + + /* Progress & Installation State */ .progressSection { display: flex; diff --git a/frontend/src/features/releases/components/UpdateModal.tsx b/frontend/src/features/releases/components/UpdateModal.tsx index 4854b88..22ba5d0 100644 --- a/frontend/src/features/releases/components/UpdateModal.tsx +++ b/frontend/src/features/releases/components/UpdateModal.tsx @@ -13,7 +13,10 @@ import type { AppUpdateInfoResponse, UpdateDownloadProgressResponse, } from "~types/api"; +import { marked } from "marked"; +import DOMPurify from "dompurify"; import { releasesApi } from "../api/releasesApi"; +import { formatVersionTag } from "../../../utils/versionUtils"; import styles from "./UpdateModal.module.css"; interface UpdateModalProps { @@ -30,6 +33,19 @@ const formatBytes = (bytes?: number): string => { return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; }; +const renderReleaseNotes = (notes?: string): string => { + if (!notes) return ""; + const cleaned = notes.replace(//g, "").trim(); + if (!cleaned) return ""; + try { + const rawHtml = marked.parse(cleaned, { breaks: true, gfm: true }) as string; + return DOMPurify.sanitize(rawHtml); + } catch { + return DOMPurify.sanitize(cleaned); + } +}; + + export const UpdateModal: React.FC = ({ isOpen, onClose, @@ -194,11 +210,11 @@ export const UpdateModal: React.FC = ({ {/* Version badge row */}
- Current: v{updateInfo.currentVersion} + Current: {formatVersionTag(updateInfo.currentVersion)} - New: v{updateInfo.latestVersion} + New: {formatVersionTag(updateInfo.latestVersion)}
@@ -208,12 +224,14 @@ export const UpdateModal: React.FC = ({ {updateInfo.releaseTitle ? updateInfo.releaseTitle : "RELEASE NOTES"} -
- {updateInfo.releaseNotes} -
+
)} + {/* Stream Error Box */} {streamError && (
diff --git a/frontend/src/utils/versionUtils.ts b/frontend/src/utils/versionUtils.ts new file mode 100644 index 0000000..2a78760 --- /dev/null +++ b/frontend/src/utils/versionUtils.ts @@ -0,0 +1,18 @@ +/** + * Normalizes a version string so it consistently starts with a single lowercase 'v'. + * Trims surrounding whitespace and strips any leading 'v' or 'V' (including multiples like "vv0.1" or " V0.1 "). + * + * Examples: + * - "0.1.4-alpha" -> "v0.1.4-alpha" + * - "v0.1.4-alpha" -> "v0.1.4-alpha" + * - "vv0.1.4-alpha" -> "v0.1.4-alpha" + * - " V0.1.4 " -> "v0.1.4" + * - "" / " " -> "" + */ +export const formatVersionTag = (ver?: string): string => { + if (!ver) return ""; + const trimmed = ver.trim(); + if (!trimmed) return ""; + const cleanVersion = trimmed.replace(/^v+/i, ""); + return `v${cleanVersion}`; +};