Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -94,27 +94,37 @@ private Flux<UpdateProgressInfo> startDownloadProcess(String downloadUrl, long t
);

return Flux.using(
() -> AsynchronousFileChannel.open(
() -> Files.newOutputStream(
targetPath,
StandardOpenOption.CREATE,
StandardOpenOption.WRITE,
StandardOpenOption.TRUNCATE_EXISTING
),

fileChannel -> {
outputStream -> {
Flux<UpdateProgressInfo> 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));
try {
int chunkSize = dataBuffer.readableByteCount();

// Write the buffer contents to the output stream
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 Mono.just(new UpdateProgressInfo(
UpdateStatus.DOWNLOADING, percentage, currentTotal, totalBytes, null
));
} catch (IOException e) {
return Mono.error(e);
} finally {
// Always release the DataBuffer to prevent Netty ByteBuf leaks
DataBufferUtils.release(dataBuffer);
}
Comment thread
MathCunha16 marked this conversation as resolved.
Outdated
})
.distinctUntilChanged(p -> knownSize ? p.percentage() : p.downloadedBytes())
.sample(PROGRESS_SAMPLE_INTERVAL)
Expand All @@ -126,7 +136,6 @@ private Flux<UpdateProgressInfo> startDownloadProcess(String downloadUrl, long t
null
))));


Mono<UpdateProgressInfo> installAndComplete = Mono
.fromRunnable(() -> installUpdateUseCase.execute(targetPath))
.subscribeOn(Schedulers.boundedElastic())
Expand All @@ -151,7 +160,7 @@ private Flux<UpdateProgressInfo> 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()
Expand All @@ -169,12 +178,13 @@ private Mono<Void> 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
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> command = List.of(
Expand All @@ -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<String> command = List.of(
"bash", "-c", script, "bash",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,22 @@
* {@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();
}
return tempDir;
Comment thread
MathCunha16 marked this conversation as resolved.
}

String userHome = System.getProperty("user.home");
String os = System.getProperty("os.name").toLowerCase();

Expand Down
2 changes: 1 addition & 1 deletion backend/src/main/resources/application.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
app:
version: "0.1.4-alpha"
version: "0.1.5-alpha"

spring:
application:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,20 @@
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;
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
import reactor.core.publisher.Flux;

import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Instant;
import java.util.List;

Expand All @@ -27,6 +31,9 @@
@ExtendWith(MockitoExtension.class)
class DownloadUpdateImplTest {

@TempDir
Path tempFolder;

@Mock
private ReleasePort releasePort;

Expand All @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
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;
Expand All @@ -21,12 +24,25 @@
@ExtendWith(MockitoExtension.class)
class InstallUpdateImplTest {

@TempDir
Path tempFolder;

@Mock
private ConfigurableApplicationContext applicationContext;

@Spy
private InstallUpdateImpl installUpdateUseCase = new InstallUpdateImpl(applicationContext);
Comment thread
MathCunha16 marked this conversation as resolved.
Outdated

@BeforeEach
void setUp() {
System.setProperty("devaulty.temp.dir", tempFolder.toString());
}

@AfterEach
void tearDown() {
System.clearProperty("devaulty.temp.dir");
}

@Test
@DisplayName("Should throw UpdateNotAvailableException when installer file does not exist")
void shouldThrowUpdateNotAvailableException_whenInstallerFileDoesNotExist() {
Expand All @@ -52,26 +68,9 @@ 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
doNothing().when(installUpdateUseCase).startDetached(anyList());
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/components/RootLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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"
>
<span>v{versionData.actualVersion}</span>
<span>{formatVersionTag(versionData.actualVersion)}</span>

{checkUpdatesMutation.isPending && (
<Icons.Loader2 size={10} className="animate-spin text-primary" />
)}
Expand Down
64 changes: 61 additions & 3 deletions frontend/src/features/releases/components/UpdateModal.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading