diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..a89a691 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,23 @@ +changelog: + categories: + - title: "Features" + labels: + - "feature" + - "enhancement" + - title: "Bug Fixes" + labels: + - "fix" + - "bug" + - title: "Performance Improvements" + labels: + - "performance" + - "perf" + - title: "Documentation" + labels: + - "documentation" + - "docs" + - title: "Maintenance & Infrastructure" + labels: + - "chore" + - "ci" + - "dependencies" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9d8b1ec --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,51 @@ +name: CI Backend Test Suite + +on: + pull_request: + branches: + - main + types: + - opened + - synchronize + - reopened + - ready_for_review + +jobs: + test: + name: Run Backend Test Suite + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout Repository + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Setup Node.js 20 + uses: actions/setup-node@v6 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: 'frontend/package-lock.json' + + - name: Install Frontend Dependencies + working-directory: ./frontend + run: npm ci + + - name: Setup Java 21 (Temurin) + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '21' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Run Backend Tests + working-directory: ./backend + run: | + chmod +x gradlew + ./gradlew test --no-daemon diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..830b7b2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,265 @@ +name: Release CI/CD Pipeline + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + tag: + description: 'Tag name to release (e.g. v0.1.0-alpha)' + required: true + type: string + +permissions: + contents: read + +concurrency: + group: release-${{ inputs.tag || github.ref_name }} + cancel-in-progress: false + +jobs: + # =========================================== + # STAGE 0: Tests & Version Sanity Check + # =========================================== + verify: + name: Run Tests & Verify Version + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@v6 + with: + ref: ${{ inputs.tag || github.ref_name }} + persist-credentials: false + + - name: Setup Node.js 20 + uses: actions/setup-node@v6 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: 'frontend/package-lock.json' + + - name: Install Frontend Dependencies + working-directory: ./frontend + run: npm ci + + - name: Setup Java 21 (Temurin) + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '21' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Run Backend Tests + working-directory: ./backend + run: | + chmod +x gradlew + ./gradlew test --no-daemon + + - name: Verify Tag Matches Gradle Version + working-directory: ./backend + env: + RELEASE_TAG: ${{ inputs.tag || github.ref_name }} + run: | + TAG_VERSION="${RELEASE_TAG#v}" + GRADLE_VERSION=$(./gradlew properties -q | grep "^version:" | awk '{print $2}') + if [ "$TAG_VERSION" != "$GRADLE_VERSION" ]; then + echo "Tag version ($TAG_VERSION) does not match Gradle version ($GRADLE_VERSION)" + exit 1 + fi + echo "Tag version matches Gradle version: $TAG_VERSION" + + # =========================================== + # STAGE 1: Linux Packaging + # =========================================== + build-linux: + name: Package Linux Installers (.deb, .rpm) + needs: verify + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@v6 + with: + ref: ${{ inputs.tag || github.ref_name }} + persist-credentials: false + + - name: Setup Node.js 20 + uses: actions/setup-node@v6 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: 'frontend/package-lock.json' + + - name: Install Frontend Dependencies + working-directory: ./frontend + run: npm ci + + - name: Setup Java 21 (Temurin) + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '21' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Install Linux Package Utilities (rpm, dpkg-dev) + run: | + sudo apt-get update + sudo apt-get install -y rpm dpkg-dev + + - name: Build Linux Package Installers (.deb, .rpm) + working-directory: ./backend + run: | + chmod +x gradlew + ./gradlew packageDeb packageRpm --no-daemon + + - name: Upload Linux Installer Artifacts + uses: actions/upload-artifact@v4 + with: + name: linux-installers + path: | + backend/build/jpackage/deb/*.deb + backend/build/jpackage/rpm/*.rpm + if-no-files-found: error + + # =========================================== + # STAGE 2: Windows Packaging + # =========================================== + build-windows: + name: Package Windows Installer (.msi) + needs: verify + runs-on: windows-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@v6 + with: + ref: ${{ inputs.tag || github.ref_name }} + persist-credentials: false + + - name: Setup Node.js 20 + uses: actions/setup-node@v6 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: 'frontend/package-lock.json' + + - name: Install Frontend Dependencies + working-directory: ./frontend + run: npm ci + + - name: Setup Java 21 (Temurin) + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '21' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Install Wix Toolset 3.x (Chocolatey) + run: choco install wix --version 3.11.2 -y + + - name: Build Windows Package Installer (.msi) + working-directory: ./backend + run: .\gradlew.bat packageMsi --no-daemon + + - name: Upload Windows Installer Artifact + uses: actions/upload-artifact@v4 + with: + name: windows-installer + path: backend/build/jpackage/msi/*.msi + if-no-files-found: error + + # =========================================== + # STAGE 3: macOS Packaging + # =========================================== + build-macos: + name: Package macOS Installer (.dmg) + needs: verify + runs-on: macos-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@v6 + with: + ref: ${{ inputs.tag || github.ref_name }} + persist-credentials: false + + - name: Setup Node.js 20 + uses: actions/setup-node@v6 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: 'frontend/package-lock.json' + + - name: Install Frontend Dependencies + working-directory: ./frontend + run: npm ci + + - name: Setup Java 21 (Temurin) + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '21' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Build macOS Package Installer (.dmg) + working-directory: ./backend + run: | + chmod +x gradlew + ./gradlew packageDmg --no-daemon + + - name: Upload macOS Installer Artifact + uses: actions/upload-artifact@v4 + with: + name: macos-installer + path: backend/build/jpackage/dmg/*.dmg + if-no-files-found: error + + # =========================================== + # STAGE 4: Publish on GitHub Releases + # =========================================== + publish-release: + name: Publish GitHub Release Assets + needs: + - build-linux + - build-windows + - build-macos + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Download All Build Artifacts + uses: actions/download-artifact@v4 + with: + path: release-assets + merge-multiple: true + + - name: Display Collected Release Assets + run: | + echo "=== Executables Collected for Release ===" + ls -la release-assets/ + + - name: Publish GitHub Release (softprops/action-gh-release) + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ inputs.tag || github.ref_name }} + files: | + release-assets/*.deb + release-assets/*.rpm + release-assets/*.msi + release-assets/*.dmg + generate_release_notes: true + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index 535b99a..5fe771b 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -11,11 +11,23 @@ val mapStructVersion = "1.6.3" val swaggerOpenAPIVersion = "3.0.3" val bouncyCastleVersion = "1.84" +val yamlVersion = file("src/main/resources/application.yaml") + .readLines() + .firstOrNull { it.trim().startsWith("version:") } + ?.substringAfter("version:") + ?.trim() + ?.removeSurrounding("\"") + ?.removeSurrounding("'") + ?: "0.1.0-alpha" + group = "com.devaulty" -version = "0.1.0-alpha" +version = yamlVersion + +// Clean version for Linux/Windows (e.g. "0.1.0-alpha" -> "0.1.0") +val packageVersion = yamlVersion.replace(Regex("(?i)-.*$"), "") -val packageVersion = "0.1.0" -val macPackageVersion = "1.0.0" +// Numeric 3-part version for macOS (e.g. "0.1.0") +val macPackageVersion = if (packageVersion.split(".").size >= 3) packageVersion else "$packageVersion.0" java { toolchain { @@ -36,6 +48,7 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-liquibase") implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-webmvc") + implementation("org.springframework.boot:spring-boot-starter-webclient") implementation("org.springframework.security:spring-security-crypto") implementation("org.mapstruct:mapstruct:${mapStructVersion}") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${swaggerOpenAPIVersion}") @@ -81,7 +94,7 @@ val buildFrontend by tasks.registering(Exec::class) { workingDir = file("../frontend") - val isWindows = org.gradle.internal.os.OperatingSystem.current().isWindows() + val isWindows = org.gradle.internal.os.OperatingSystem.current().isWindows if (isWindows) { commandLine("cmd", "/c", "npm run build") } else { diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/exception/GlobalExceptionHandler.java index ef26f8f..58e5013 100644 --- a/backend/src/main/java/com/devaulty/backend/adapter/in/web/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/devaulty/backend/adapter/in/web/exception/GlobalExceptionHandler.java @@ -41,6 +41,11 @@ public ResponseEntity handleCryptoException(CryptoException ex return buildResponse(HttpStatus.INTERNAL_SERVER_ERROR, exception.getMessage()); } + @ExceptionHandler(UpdateNotAvailableException.class) + public ResponseEntity handleUpdateNotAvailableException(UpdateNotAvailableException exception) { + return buildResponse(HttpStatus.BAD_REQUEST, exception.getMessage()); + } + @ExceptionHandler(InvalidMasterPasswordException.class) public ResponseEntity handleInvalidMasterPasswordException(InvalidMasterPasswordException exception) { return buildResponse(HttpStatus.UNAUTHORIZED, exception.getMessage()); diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/release/ReleaseApi.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/release/ReleaseApi.java new file mode 100644 index 0000000..125c801 --- /dev/null +++ b/backend/src/main/java/com/devaulty/backend/adapter/in/web/release/ReleaseApi.java @@ -0,0 +1,96 @@ +package com.devaulty.backend.adapter.in.web.release; + +import com.devaulty.backend.adapter.in.web.exception.ApiErrorResponse; +import com.devaulty.backend.adapter.in.web.release.dto.AppUpdateInfoResponse; +import com.devaulty.backend.adapter.in.web.release.dto.CurrentVersionResponse; +import com.devaulty.backend.adapter.in.web.release.dto.UpdateDownloadProgressResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import reactor.core.publisher.Flux; + +@Tag(name = "Releases", description = "Endpoints for checking desktop application updates, streaming asset downloads, and invoking native OS installation") +@RequestMapping("/api/v1/releases") +public interface ReleaseApi { + + @Operation( + summary = "Check for application updates", + description = "Fetches the latest official relea" + + "se published on GitHub Releases and compares its tag version with the local running application version (`app.version`). Dynamically detects the current host OS (Linux .deb vs .rpm, Windows .msi, macOS .dmg) and resolves the matching asset download URL and file size." + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Update check completed successfully. Returns update availability status, current vs latest version info, changelog notes, and host-specific download metadata.", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = AppUpdateInfoResponse.class)) + ), + @ApiResponse( + responseCode = "403", + description = "Forbidden. Request missing or containing an invalid `X-Devaulty-Internal-Token` header.", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class)) + ), + @ApiResponse( + responseCode = "500", + description = "Internal Server Error. Unexpected error occurred while querying GitHub API or reading local system properties.", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class)) + ) + }) + @GetMapping("/check") + ResponseEntity checkUpdates(); + + @Operation( + summary = "Stream update download and trigger native OS installation", + description = "Establishes a Server-Sent Events (SSE) HTTP stream (`text/event-stream`) to download the latest installer binary into the local platform temp directory (`~/.config/devaulty/temp` on Linux, `%LOCALAPPDATA%\\devaulty\\temp` on Windows, `~/Library/Caches/devaulty/temp` on macOS). Emits real-time progress events (`DOWNLOADING` status with percentage and byte counts). Once download reaches 100%, transitions to `INSTALLING` status, launches the native OS installer/restart script detached from the current process, and gracefully shuts down the running Devaulty application instance." + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Server-Sent Events (SSE) stream established successfully. Emits real-time `UpdateDownloadProgressResponse` events.", + content = @Content(mediaType = MediaType.TEXT_EVENT_STREAM_VALUE, schema = @Schema(implementation = UpdateDownloadProgressResponse.class)) + ), + @ApiResponse( + responseCode = "400", + description = "Bad Request. Thrown when no update is available (`updateAvailable == false`) or no compatible installer asset exists for the current host OS.", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class)) + ), + @ApiResponse( + responseCode = "403", + description = "Forbidden. Request missing or containing an invalid `X-Devaulty-Internal-Token` header.", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class)) + ), + @ApiResponse( + responseCode = "500", + description = "Internal Server Error. Streaming network failure, file I/O error during installer save, or native process execution failure.", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class)) + ) + }) + @PostMapping(value = "/download-and-install", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + ResponseEntity> downloadUpdate(); + + @Operation( + summary = "Get current running application version", + description = "Returns the local application version (`app.version`) instantly from DevaultyProperties without querying external services." + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Returns current application version string.", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = CurrentVersionResponse.class)) + ), + @ApiResponse( + responseCode = "403", + description = "Forbidden. Request missing or containing an invalid `X-Devaulty-Internal-Token` header.", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class)) + ) + }) + @GetMapping("/current-app-version") + ResponseEntity getAppInfo(); +} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/release/ReleaseController.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/release/ReleaseController.java new file mode 100644 index 0000000..2f7bb28 --- /dev/null +++ b/backend/src/main/java/com/devaulty/backend/adapter/in/web/release/ReleaseController.java @@ -0,0 +1,55 @@ +package com.devaulty.backend.adapter.in.web.release; + +import com.devaulty.backend.adapter.in.web.release.dto.CurrentVersionResponse; +import com.devaulty.backend.adapter.in.web.release.dto.AppUpdateInfoResponse; +import com.devaulty.backend.adapter.in.web.release.dto.UpdateDownloadProgressResponse; +import com.devaulty.backend.application.port.in.release.CheckForUpdatesUseCase; +import com.devaulty.backend.application.port.in.release.DownloadUpdateUseCase; +import com.devaulty.backend.application.port.in.release.GetCurrentVersionUseCase; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Flux; + +@RestController +@RequestMapping("/api/v1/releases") +public class ReleaseController implements ReleaseApi { + + private final CheckForUpdatesUseCase checkForUpdatesUseCase; + private final DownloadUpdateUseCase downloadUpdateUseCase; + private final GetCurrentVersionUseCase getCurrentVersionUseCase; + private final ReleaseWebMapper releaseWebMapper; + + public ReleaseController(CheckForUpdatesUseCase checkForUpdatesUseCase, DownloadUpdateUseCase downloadUpdateUseCase, GetCurrentVersionUseCase getCurrentVersionUseCase, ReleaseWebMapper releaseWebMapper) { + this.checkForUpdatesUseCase = checkForUpdatesUseCase; + this.downloadUpdateUseCase = downloadUpdateUseCase; + this.getCurrentVersionUseCase = getCurrentVersionUseCase; + this.releaseWebMapper = releaseWebMapper; + } + + @Override + @GetMapping("/check") + public ResponseEntity checkUpdates() { + return ResponseEntity.ok(releaseWebMapper.toAppUpdateInfoResponse(checkForUpdatesUseCase.execute())); + } + + @Override + @PostMapping(value = "/download-and-install", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public ResponseEntity> downloadUpdate() { + Flux stream = downloadUpdateUseCase.execute() + .map(releaseWebMapper::toProgressResponse); + + return ResponseEntity.ok() + .contentType(MediaType.TEXT_EVENT_STREAM) + .body(stream); + } + + @Override + @GetMapping("/current-app-version") + public ResponseEntity getAppInfo() { + return ResponseEntity.ok(new CurrentVersionResponse(getCurrentVersionUseCase.execute())); + } +} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/release/ReleaseWebMapper.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/release/ReleaseWebMapper.java new file mode 100644 index 0000000..82a0b35 --- /dev/null +++ b/backend/src/main/java/com/devaulty/backend/adapter/in/web/release/ReleaseWebMapper.java @@ -0,0 +1,24 @@ +package com.devaulty.backend.adapter.in.web.release; + +import com.devaulty.backend.adapter.in.web.release.dto.AppUpdateInfoResponse; +import com.devaulty.backend.adapter.in.web.release.dto.UpdateDownloadProgressResponse; +import com.devaulty.backend.application.port.in.release.AppUpdateInfo; +import com.devaulty.backend.application.port.in.release.UpdateProgressInfo; +import org.mapstruct.Mapper; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; + +@Mapper(componentModel = "spring") +public interface ReleaseWebMapper { + + AppUpdateInfoResponse toAppUpdateInfoResponse(AppUpdateInfo appUpdateInfo); + + UpdateDownloadProgressResponse toProgressResponse(UpdateProgressInfo info); + + default LocalDateTime toLocalDateTime(Instant instant) { + if(instant == null) return null; + return LocalDateTime.ofInstant(instant, ZoneId.systemDefault()); + } +} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/release/dto/AppUpdateInfoResponse.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/release/dto/AppUpdateInfoResponse.java new file mode 100644 index 0000000..311b2c9 --- /dev/null +++ b/backend/src/main/java/com/devaulty/backend/adapter/in/web/release/dto/AppUpdateInfoResponse.java @@ -0,0 +1,15 @@ +package com.devaulty.backend.adapter.in.web.release.dto; + +import java.time.LocalDateTime; + +public record AppUpdateInfoResponse( + boolean updateAvailable, + String currentVersion, + String latestVersion, + String releaseTitle, + String releaseNotes, + String downloadUrl, + Long downloadSizeInBytes, + LocalDateTime publishedAt +) { +} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/release/dto/CurrentVersionResponse.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/release/dto/CurrentVersionResponse.java new file mode 100644 index 0000000..1b1f4c5 --- /dev/null +++ b/backend/src/main/java/com/devaulty/backend/adapter/in/web/release/dto/CurrentVersionResponse.java @@ -0,0 +1,6 @@ +package com.devaulty.backend.adapter.in.web.release.dto; + +public record CurrentVersionResponse( + String actualVersion +) { +} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/in/web/release/dto/UpdateDownloadProgressResponse.java b/backend/src/main/java/com/devaulty/backend/adapter/in/web/release/dto/UpdateDownloadProgressResponse.java new file mode 100644 index 0000000..13fb913 --- /dev/null +++ b/backend/src/main/java/com/devaulty/backend/adapter/in/web/release/dto/UpdateDownloadProgressResponse.java @@ -0,0 +1,12 @@ +package com.devaulty.backend.adapter.in.web.release.dto; + +import com.devaulty.backend.application.port.in.release.enums.UpdateStatus; + +public record UpdateDownloadProgressResponse( + UpdateStatus status, + int percentage, + long downloadedBytes, + long totalBytes, + String errorMessage +) { +} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/external/github/GitHubClient.java b/backend/src/main/java/com/devaulty/backend/adapter/out/external/github/GitHubClient.java new file mode 100644 index 0000000..b135df3 --- /dev/null +++ b/backend/src/main/java/com/devaulty/backend/adapter/out/external/github/GitHubClient.java @@ -0,0 +1,43 @@ +package com.devaulty.backend.adapter.out.external.github; + +import com.devaulty.backend.adapter.out.external.github.dto.GitHubReleaseResponse; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.net.URI; + +@Component +public class GitHubClient { + + private final WebClient githubWebClient; + + private static final String LATEST_RELEASES_URL = "/repos/MathCunha16/Devaulty/releases/latest"; + + public GitHubClient(WebClient githubWebClient) { + this.githubWebClient = githubWebClient; + } + + public GitHubReleaseResponse getLatestRelease() { + return githubWebClient.get() + .uri(LATEST_RELEASES_URL) + .retrieve() + .bodyToMono(GitHubReleaseResponse.class) + .onErrorResume( + WebClientResponseException.class, + ex -> ex.getStatusCode() == HttpStatus.NOT_FOUND ? Mono.empty() : Mono.error(ex) + ) + .block(); + } + + public Flux downloadAsset(String downloadUrl) { + return githubWebClient.get() + .uri(URI.create(downloadUrl)) // Clean URL + .retrieve() + .bodyToFlux(DataBuffer.class); + } +} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/external/github/GitHubReleaseAdapter.java b/backend/src/main/java/com/devaulty/backend/adapter/out/external/github/GitHubReleaseAdapter.java new file mode 100644 index 0000000..12751eb --- /dev/null +++ b/backend/src/main/java/com/devaulty/backend/adapter/out/external/github/GitHubReleaseAdapter.java @@ -0,0 +1,29 @@ +package com.devaulty.backend.adapter.out.external.github; + +import com.devaulty.backend.application.port.out.external.release.ReleasePort; +import com.devaulty.backend.application.port.out.external.release.dto.LatestReleaseInfo; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Flux; + +@Component +public class GitHubReleaseAdapter implements ReleasePort { + + private final GitHubClient gitHubClient; + private final GitHubReleaseMapper mapper; + + public GitHubReleaseAdapter(GitHubClient gitHubClient, GitHubReleaseMapper mapper) { + this.gitHubClient = gitHubClient; + this.mapper = mapper; + } + + @Override + public LatestReleaseInfo getLatestRelease() { + return mapper.toDomain(gitHubClient.getLatestRelease()); + } + + @Override + public Flux downloadAsset(String downloadUrl) { + return gitHubClient.downloadAsset(downloadUrl); + } +} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/external/github/GitHubReleaseMapper.java b/backend/src/main/java/com/devaulty/backend/adapter/out/external/github/GitHubReleaseMapper.java new file mode 100644 index 0000000..157aec3 --- /dev/null +++ b/backend/src/main/java/com/devaulty/backend/adapter/out/external/github/GitHubReleaseMapper.java @@ -0,0 +1,23 @@ +package com.devaulty.backend.adapter.out.external.github; + +import com.devaulty.backend.adapter.out.external.github.dto.GitHubAssetResponse; +import com.devaulty.backend.adapter.out.external.github.dto.GitHubReleaseResponse; +import com.devaulty.backend.application.port.out.external.release.dto.LatestReleaseInfo; +import com.devaulty.backend.application.port.out.external.release.dto.ReleaseAssetInfo; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring") +public interface GitHubReleaseMapper { + + @Mapping(target = "isPreRelease", source = "preRelease") + @Mapping(target = "name", source = "name", defaultValue = "") + @Mapping(target = "body", source = "body", defaultValue = "Version notes not informed") + LatestReleaseInfo toDomain(GitHubReleaseResponse dto); + + @Mapping(target = "fileName", source = "name") + @Mapping(target = "downloadUrl", source = "browserDownloadUrl") + @Mapping(target = "sizeInBytes", source = "size") + ReleaseAssetInfo toAssetDomain(GitHubAssetResponse dto); + +} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/external/github/common/GitHubConfig.java b/backend/src/main/java/com/devaulty/backend/adapter/out/external/github/common/GitHubConfig.java new file mode 100644 index 0000000..ace50ae --- /dev/null +++ b/backend/src/main/java/com/devaulty/backend/adapter/out/external/github/common/GitHubConfig.java @@ -0,0 +1,39 @@ +package com.devaulty.backend.adapter.out.external.github.common; + +import io.netty.channel.ChannelOption; +import io.netty.handler.timeout.ReadTimeoutHandler; +import io.netty.handler.timeout.WriteTimeoutHandler; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.netty.http.client.HttpClient; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +@Configuration +public class GitHubConfig { + + private static final int CONNECT_TIMEOUT_MS = 10000; + private static final int READ_WRITE_TIMEOUT_SECONDS = 15; + + @Bean + public WebClient githubWebClient() { + HttpClient httpClient = HttpClient.create() + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, CONNECT_TIMEOUT_MS) + .responseTimeout(Duration.ofSeconds(READ_WRITE_TIMEOUT_SECONDS)) + .doOnConnected(conn -> conn + .addHandlerLast(new ReadTimeoutHandler(READ_WRITE_TIMEOUT_SECONDS, TimeUnit.SECONDS)) + .addHandlerLast(new WriteTimeoutHandler(READ_WRITE_TIMEOUT_SECONDS, TimeUnit.SECONDS))) + .followRedirect(true); + + return WebClient.builder() + .clientConnector(new ReactorClientHttpConnector(httpClient)) + .baseUrl("https://api.github.com") + .defaultHeader("User-Agent", "Devaulty-Desktop-App") + .defaultHeader("Accept", "application/vnd.github+json") + .defaultHeader("X-GitHub-Api-Version", "2026-03-10") + .build(); + } +} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/external/github/dto/GitHubAssetResponse.java b/backend/src/main/java/com/devaulty/backend/adapter/out/external/github/dto/GitHubAssetResponse.java new file mode 100644 index 0000000..fe6d054 --- /dev/null +++ b/backend/src/main/java/com/devaulty/backend/adapter/out/external/github/dto/GitHubAssetResponse.java @@ -0,0 +1,14 @@ +package com.devaulty.backend.adapter.out.external.github.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record GitHubAssetResponse( + String name, + @JsonProperty("browser_download_url") + String browserDownloadUrl, + long size, + @JsonProperty("content_type") + String contentType, + String digest +) { +} diff --git a/backend/src/main/java/com/devaulty/backend/adapter/out/external/github/dto/GitHubReleaseResponse.java b/backend/src/main/java/com/devaulty/backend/adapter/out/external/github/dto/GitHubReleaseResponse.java new file mode 100644 index 0000000..b43f4e1 --- /dev/null +++ b/backend/src/main/java/com/devaulty/backend/adapter/out/external/github/dto/GitHubReleaseResponse.java @@ -0,0 +1,21 @@ +package com.devaulty.backend.adapter.out.external.github.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.time.Instant; +import java.util.List; + +public record GitHubReleaseResponse( + @JsonProperty("tag_name") + String tagName, + String name, + String body, + @JsonProperty("html_url") + String htmlUrl, + @JsonProperty("published_at") + Instant publishedAt, + @JsonProperty("prerelease") + boolean preRelease, + List assets +) { +} diff --git a/backend/src/main/java/com/devaulty/backend/application/exception/UpdateNotAvailableException.java b/backend/src/main/java/com/devaulty/backend/application/exception/UpdateNotAvailableException.java new file mode 100644 index 0000000..f16466c --- /dev/null +++ b/backend/src/main/java/com/devaulty/backend/application/exception/UpdateNotAvailableException.java @@ -0,0 +1,7 @@ +package com.devaulty.backend.application.exception; + +public class UpdateNotAvailableException extends DevaultyException{ + public UpdateNotAvailableException(String message) { + super(message); + } +} diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/release/CheckForUpdatesImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/release/CheckForUpdatesImpl.java new file mode 100644 index 0000000..e0706d7 --- /dev/null +++ b/backend/src/main/java/com/devaulty/backend/application/impl/release/CheckForUpdatesImpl.java @@ -0,0 +1,225 @@ +package com.devaulty.backend.application.impl.release; + +import com.devaulty.backend.application.port.in.release.AppUpdateInfo; +import com.devaulty.backend.application.port.in.release.CheckForUpdatesUseCase; +import com.devaulty.backend.application.port.out.external.release.ReleasePort; +import com.devaulty.backend.application.port.out.external.release.dto.LatestReleaseInfo; +import com.devaulty.backend.application.port.out.external.release.dto.ReleaseAssetInfo; +import com.devaulty.backend.infrastructure.properties.DevaultyProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.List; +import java.util.Optional; + +public class CheckForUpdatesImpl implements CheckForUpdatesUseCase { + + private final ReleasePort releasePort; + private final DevaultyProperties devaultyProperties; + + private static final String OS_RELEASE_FILE = "/etc/os-release"; + private static final Logger logger = LoggerFactory.getLogger(CheckForUpdatesImpl.class); + + public CheckForUpdatesImpl(ReleasePort releasePort, DevaultyProperties devaultyProperties) { + this.releasePort = releasePort; + this.devaultyProperties = devaultyProperties; + } + + @Override + public AppUpdateInfo execute() { + + LatestReleaseInfo latestRelease = releasePort.getLatestRelease(); + + if (latestRelease == null) { + return new AppUpdateInfo( + false, + devaultyProperties != null ? devaultyProperties.getVersion() : null, + null, + null, + null, + null, + 0L, + null + ); + } + + String cleanLatestVersion = latestRelease.tagName().replaceAll("(?i)^v", "").trim(); + String currentVersion = devaultyProperties != null ? devaultyProperties.getVersion() : null; + + boolean updateAvailable = isNewerVersion(currentVersion, cleanLatestVersion); + + ReleaseAssetInfo targetAsset = findAssetForCurrentOs(latestRelease.assets()); + String downloadUrl = targetAsset != null ? targetAsset.downloadUrl() : null; + long downloadSize = targetAsset != null ? targetAsset.sizeInBytes() : 0L; + + return new AppUpdateInfo( + updateAvailable, + currentVersion, + latestRelease.tagName(), + latestRelease.name(), + latestRelease.body(), + downloadUrl, + downloadSize, + latestRelease.publishedAt() + ); + } + + private boolean isNewerVersion(String currentVersion, String latestVersion) { + if (currentVersion == null || latestVersion == null) { + return false; + } + + int hyphenCurrent = currentVersion.indexOf('-'); + int hyphenLatest = latestVersion.indexOf('-'); + + String currentCore = hyphenCurrent >= 0 ? currentVersion.substring(0, hyphenCurrent) : currentVersion; + String latestCore = hyphenLatest >= 0 ? latestVersion.substring(0, hyphenLatest) : latestVersion; + + String currentPre = hyphenCurrent >= 0 ? currentVersion.substring(hyphenCurrent + 1) : null; + String latestPre = hyphenLatest >= 0 ? latestVersion.substring(hyphenLatest + 1) : null; + + // 1. Compare numeric core (major.minor.patch) + int coreComparison = compareCore(currentCore, latestCore); + if (coreComparison != 0) { + return coreComparison < 0; + } + + // 2. Same core: stable (no pre-release) > any pre-release (SemVer spec) + if (currentPre == null && latestPre != null) { + return false; // current is stable, latest is pre-release → not newer + } + if (currentPre != null && latestPre == null) { + return true; // current is pre-release, latest is stable → newer + } + if (currentPre == null) { + return false; // both stable, same version + } + + // 3. Both have pre-release identifiers: compare dot-separated identifiers + return comparePreRelease(currentPre, latestPre) < 0; + } + + private int compareCore(String a, String b) { + String[] aParts = a.split("\\."); + String[] bParts = b.split("\\."); + int length = Math.max(aParts.length, bParts.length); + for (int i = 0; i < length; i++) { + int va = i < aParts.length ? parseVersionPart(aParts[i]) : 0; + int vb = i < bParts.length ? parseVersionPart(bParts[i]) : 0; + if (va != vb) return Integer.compare(va, vb); + } + return 0; + } + + /** + * Compares two pre-release strings per SemVer 2.0 spec: + * - Numeric identifiers are compared numerically. + * - Alphanumeric identifiers are compared lexically (ASCII). + * - A numeric identifier always has lower precedence than alphanumeric. + * Returns negative if a < b, positive if a > b, 0 if equal. + */ + private int comparePreRelease(String a, String b) { + String[] aIds = a.split("\\."); + String[] bIds = b.split("\\."); + int length = Math.max(aIds.length, bIds.length); + for (int i = 0; i < length; i++) { + if (i >= aIds.length) return -1; // a has fewer identifiers → a is smaller + if (i >= bIds.length) return 1; + + String ai = aIds[i]; + String bi = bIds[i]; + + boolean aNum = ai.chars().allMatch(Character::isDigit); + boolean bNum = bi.chars().allMatch(Character::isDigit); + + int cmp; + if (aNum && bNum) { + cmp = Integer.compare(Integer.parseInt(ai), Integer.parseInt(bi)); + } else if (aNum) { + cmp = -1; // numeric < alphanumeric + } else if (bNum) { + cmp = 1; + } else { + cmp = ai.compareTo(bi); + } + if (cmp != 0) return cmp; + } + return 0; + } + + + private int parseVersionPart(String part) { + try { + return Integer.parseInt(part); + } catch (NumberFormatException e) { + return 0; + } + } + + private ReleaseAssetInfo findAssetForCurrentOs(List assets) { + if (assets == null) { + return null; + } + + String os = System.getProperty("os.name").toLowerCase(); + + String extension; + if (os.contains("win")) { + extension = ".msi"; + } else if (os.contains("mac")) { + extension = ".dmg"; + } else { + extension = detectLinuxExtension(); + if (extension == null) { + return null; + } + } + + return assets.stream() + .filter(asset -> asset.fileName().endsWith(extension)) + .findFirst() + .orElse(null); + } + + private String detectLinuxExtension() { + + Optional osRelease = readOsRelease(); + if (osRelease.isEmpty()) { + return null; + } + + String content = osRelease.get().toLowerCase(); + + boolean isRpmBased = content.contains("rhel") + || content.contains("fedora") + || content.contains("suse"); + + boolean isDebBased = content.contains("debian") + || content.contains("ubuntu"); + + if (isRpmBased) { + return ".rpm"; + } + if (isDebBased) { + return ".deb"; + } + + logger.warn("Could not detect Linux distribution from /etc/os-release"); + return null; + } + + private Optional readOsRelease() { + File file = new File(OS_RELEASE_FILE); + if (!file.exists()) { + return Optional.empty(); + } + try { + return Optional.of(Files.readString(file.toPath())); + } catch (IOException e) { + return Optional.empty(); + } + } +} 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 new file mode 100644 index 0000000..fdb6234 --- /dev/null +++ b/backend/src/main/java/com/devaulty/backend/application/impl/release/DownloadUpdateImpl.java @@ -0,0 +1,181 @@ +package com.devaulty.backend.application.impl.release; + +import com.devaulty.backend.application.exception.UpdateNotAvailableException; +import com.devaulty.backend.application.port.in.release.*; +import com.devaulty.backend.application.port.in.release.enums.UpdateStatus; +import com.devaulty.backend.application.port.out.external.release.ReleasePort; +import org.springframework.core.io.buffer.DataBufferUtils; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +import java.io.File; +import java.io.IOException; +import java.nio.channels.AsynchronousFileChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicLong; + +public class DownloadUpdateImpl implements DownloadUpdateUseCase { + + private static final Duration PROGRESS_SAMPLE_INTERVAL = Duration.ofMillis(200); + private static final Duration DOWNLOAD_TIMEOUT = Duration.ofMinutes(15); + + private final ReleasePort releasePort; + private final CheckForUpdatesUseCase checkForUpdatesUseCase; + private final InstallUpdateUseCase installUpdateUseCase; + + public DownloadUpdateImpl(ReleasePort releasePort, CheckForUpdatesUseCase checkForUpdatesUseCase, InstallUpdateUseCase installUpdateUseCase) { + this.releasePort = releasePort; + this.checkForUpdatesUseCase = checkForUpdatesUseCase; + this.installUpdateUseCase = installUpdateUseCase; + } + + @Override + public Flux execute() { + + AppUpdateInfo updateInfo = checkForUpdatesUseCase.execute(); + + if (!updateInfo.updateAvailable() || updateInfo.downloadUrl() == null) { + throw new UpdateNotAvailableException( + "There is no update available for this application" + ); + } + + String downloadUrl = updateInfo.downloadUrl(); + long totalBytes = updateInfo.downloadSizeInBytes(); + + File tempDir = ReleaseTempFolder.resolve(); + String rawFileName = downloadUrl.substring(downloadUrl.lastIndexOf('/') + 1); + String fileName = sanitizeFileName(rawFileName); + + Path targetPath = new File(tempDir, fileName).toPath(); + verifyWithinTempDir(targetPath, tempDir); + + return startDownloadProcess(downloadUrl, totalBytes, targetPath); + } + + private String sanitizeFileName(String rawFileName) { + // Strip any directory separators or null bytes to prevent path traversal + String sanitized = rawFileName + .replace("/", "") + .replace("\\", "") + .replace("\0", "") + .trim(); + + if (sanitized.isEmpty()) { + throw new IllegalArgumentException("Derived installer file name is empty after sanitization."); + } + + return sanitized; + } + + private void verifyWithinTempDir(Path targetPath, File tempDir) { + try { + Path canonicalTarget = targetPath.toFile().getCanonicalFile().toPath(); + Path canonicalTempDir = tempDir.getCanonicalFile().toPath(); + + if (!canonicalTarget.startsWith(canonicalTempDir)) { + throw new IllegalStateException("Installer target path resolved outside of the expected temp directory."); + } + } catch (IOException e) { + throw new IllegalStateException("Failed to resolve installer target path.", e); + } + } + + private Flux startDownloadProcess(String downloadUrl, long totalBytes, Path targetPath) { + AtomicLong downloadedBytes = new AtomicLong(0); + boolean knownSize = totalBytes > 0; + + UpdateProgressInfo initialProgress = new UpdateProgressInfo( + UpdateStatus.DOWNLOADING, 0, 0L, totalBytes, null + ); + + return Flux.using( + () -> AsynchronousFileChannel.open( + targetPath, + StandardOpenOption.CREATE, + StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING + ), + + fileChannel -> { + 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)); + }) + .distinctUntilChanged(p -> knownSize ? p.percentage() : p.downloadedBytes()) + .sample(PROGRESS_SAMPLE_INTERVAL) + .concatWith(Mono.defer(() -> Mono.just(new UpdateProgressInfo( + UpdateStatus.DOWNLOADING, + knownSize ? 100 : 0, + downloadedBytes.get(), + totalBytes, + null + )))); + + + Mono installAndComplete = Mono + .fromRunnable(() -> installUpdateUseCase.execute(targetPath)) + .subscribeOn(Schedulers.boundedElastic()) + .thenReturn(new UpdateProgressInfo( + UpdateStatus.COMPLETED, 100, totalBytes, totalBytes, null + )) + .onErrorResume(installError -> Mono.just(new UpdateProgressInfo( + UpdateStatus.FAILED, + 100, + totalBytes, + totalBytes, + "Download Succeed, but the application failed to Install : " + installError.getMessage() + ))); + + UpdateProgressInfo installingProgress = new UpdateProgressInfo( + UpdateStatus.INSTALLING, 100, totalBytes, totalBytes, null + ); + + return Mono.just(initialProgress) + .concatWith(downloadProgress) + .concatWith(Mono.just(installingProgress)) + .concatWith(installAndComplete); + }, + + this::closeChannel + ).onErrorResume(ex -> deleteQuietly(targetPath) + .then(Mono.just(new UpdateProgressInfo( + UpdateStatus.FAILED, 0, downloadedBytes.get(), totalBytes, ex.getMessage() + ))) + ); + } + + private Mono deleteQuietly(Path path) { + return Mono.fromRunnable(() -> { + try { + Files.deleteIfExists(path); + } catch (Exception ignored) { + // Best-effort cleanup + } + }); + } + + private void closeChannel(AsynchronousFileChannel channel) { + if (channel != null && channel.isOpen()) { + try { + channel.close(); + } catch (Exception ignored) { + // Defensive channel closure + } + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/devaulty/backend/application/impl/release/GetCurrentVersionImpl.java b/backend/src/main/java/com/devaulty/backend/application/impl/release/GetCurrentVersionImpl.java new file mode 100644 index 0000000..afaacb1 --- /dev/null +++ b/backend/src/main/java/com/devaulty/backend/application/impl/release/GetCurrentVersionImpl.java @@ -0,0 +1,18 @@ +package com.devaulty.backend.application.impl.release; + +import com.devaulty.backend.application.port.in.release.GetCurrentVersionUseCase; +import com.devaulty.backend.infrastructure.properties.DevaultyProperties; + +public class GetCurrentVersionImpl implements GetCurrentVersionUseCase { + + private final DevaultyProperties devaultyProperties; + + public GetCurrentVersionImpl(DevaultyProperties devaultyProperties) { + this.devaultyProperties = devaultyProperties; + } + + @Override + public String execute() { + return devaultyProperties.getVersion(); + } +} 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 new file mode 100644 index 0000000..f42810e --- /dev/null +++ b/backend/src/main/java/com/devaulty/backend/application/impl/release/InstallUpdateImpl.java @@ -0,0 +1,164 @@ +package com.devaulty.backend.application.impl.release; + +import com.devaulty.backend.application.exception.UpdateNotAvailableException; +import com.devaulty.backend.application.port.in.release.InstallUpdateUseCase; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.SpringApplication; +import org.springframework.context.ConfigurableApplicationContext; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * Launches the platform-specific installer for a previously downloaded update + * and schedules the current application instance to shut down so the + * installer can replace the running files. + * + *

Known limitation: on macOS the update flow only mounts the {@code .dmg} + * image; it does not copy the app into {@code /Applications} or relaunch it + * automatically. A full macOS auto-update typically requires a dedicated + * mechanism (e.g. Sparkle) and is left as a follow-up.

+ */ +public class InstallUpdateImpl implements InstallUpdateUseCase { + + private static final Logger logger = LoggerFactory.getLogger(InstallUpdateImpl.class); + + private static final long SHUTDOWN_DELAY_SECONDS = 5; + private static final String WINDOWS_INSTALL_DIR_ENV = "DEVAULTY_INSTALL_DIR"; + private static final String WINDOWS_DEFAULT_INSTALL_DIR = "C:\\Program Files\\devaulty"; + + private final ConfigurableApplicationContext applicationContext; + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + + public InstallUpdateImpl(ConfigurableApplicationContext applicationContext) { + this.applicationContext = applicationContext; + } + + @Override + public void execute(Path installerFile) { + if (installerFile == null || !Files.exists(installerFile)) { + throw new UpdateNotAvailableException("No downloaded installer file found for: " + installerFile); + } + + File verifiedInstaller = verifyWithinTempDir(installerFile.toFile(), ReleaseTempFolder.resolve()); + + logger.info("Starting installer for file: {}", verifiedInstaller.getAbsolutePath()); + launchInstallerProcess(verifiedInstaller); + scheduleApplicationShutdown(); + } + + private void launchInstallerProcess(File installerFile) { + String os = System.getProperty("os.name").toLowerCase(); + String filePath = installerFile.getAbsolutePath(); + long currentPid = ProcessHandle.current().pid(); + + try { + if (os.contains("win")) { + launchWindowsInstaller(filePath, currentPid); + } else if (os.contains("mac")) { + launchMacInstaller(filePath, currentPid); + } else { + launchLinuxInstaller(filePath, currentPid); + } + } catch (IOException e) { + logger.error("Failed to launch native installer process", e); + throw new IllegalStateException("Failed to launch application installer process.", e); + } + } + + private void launchLinuxInstaller(String filePath, long currentPid) throws IOException { + String installProgram = filePath.endsWith(".rpm") ? "rpm" : "dpkg"; + String installFlag = filePath.endsWith(".rpm") ? "-Uvh" : "-i"; + + 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\" && devaulty"; + + List command = List.of( + "bash", "-c", script, "bash", + String.valueOf(currentPid), filePath, installProgram, installFlag + ); + + startDetached(command); + } + + private void launchMacInstaller(String filePath, long currentPid) throws IOException { + // Read the note about limitations at the top of the class + String script = + "PID=\"$1\"; FILE=\"$2\"; " + + "while kill -0 \"$PID\" 2>/dev/null; do sleep 0.2; done; " + + "open \"$FILE\""; + + List command = List.of( + "bash", "-c", script, "bash", + String.valueOf(currentPid), filePath + ); + + startDetached(command); + } + + private void launchWindowsInstaller(String filePath, long currentPid) throws IOException { + String installDir = System.getenv(WINDOWS_INSTALL_DIR_ENV); + if (installDir == null || installDir.isBlank()) { + installDir = WINDOWS_DEFAULT_INSTALL_DIR; + logger.warn("{} not set, falling back to default install path: {}", + WINDOWS_INSTALL_DIR_ENV, installDir); + } + String executablePath = installDir + "\\devaulty.exe"; + + String script = + "param($ProcId, $File, $ExePath) " + + "Wait-Process -Id $ProcId -ErrorAction SilentlyContinue; " + + "Start-Process msiexec.exe -ArgumentList '/i', $File, '/qb' -Wait; " + + "Start-Process $ExePath"; + + List command = List.of( + "powershell", "-NoProfile", "-WindowStyle", "Hidden", + "-Command", script, + String.valueOf(currentPid), filePath, executablePath + ); + + startDetached(command); + } + + protected void startDetached(List command) throws IOException { + ProcessBuilder builder = new ProcessBuilder(command); + builder.redirectOutput(ProcessBuilder.Redirect.DISCARD); + builder.redirectError(ProcessBuilder.Redirect.DISCARD); + builder.start(); + } + + private File verifyWithinTempDir(File installerFile, File tempDir) { + try { + Path canonicalInstaller = installerFile.getCanonicalFile().toPath(); + Path canonicalTempDir = tempDir.getCanonicalFile().toPath(); + + if (!canonicalInstaller.startsWith(canonicalTempDir)) { + throw new IllegalStateException("Installer file resolved outside of the expected temp directory."); + } + return canonicalInstaller.toFile(); + } catch (IOException e) { + throw new IllegalStateException("Failed to resolve installer file path.", e); + } + } + + // Temp folder resolution delegated to ReleaseTempFolder to ensure both + // DownloadUpdateImpl and InstallUpdateImpl always use the same directory. + + private void scheduleApplicationShutdown() { + scheduler.schedule(() -> { + logger.info("Shutting down current application instance for update replacement..."); + int exitCode = SpringApplication.exit(applicationContext, () -> 0); + scheduler.shutdown(); + System.exit(exitCode); + }, SHUTDOWN_DELAY_SECONDS, TimeUnit.SECONDS); + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..d7825ac --- /dev/null +++ b/backend/src/main/java/com/devaulty/backend/application/impl/release/ReleaseTempFolder.java @@ -0,0 +1,37 @@ +package com.devaulty.backend.application.impl.release; + +import java.io.File; + +/** + * Shared utility for resolving the OS-specific temporary directory used by + * the Devaulty update pipeline. Both {@link DownloadUpdateImpl} and + * {@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. + */ +public final class ReleaseTempFolder { + + private ReleaseTempFolder() {} + + public static File resolve() { + 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(); + } + + return tempDir; + } +} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/release/AppUpdateInfo.java b/backend/src/main/java/com/devaulty/backend/application/port/in/release/AppUpdateInfo.java new file mode 100644 index 0000000..46cda57 --- /dev/null +++ b/backend/src/main/java/com/devaulty/backend/application/port/in/release/AppUpdateInfo.java @@ -0,0 +1,15 @@ +package com.devaulty.backend.application.port.in.release; + +import java.time.Instant; + +public record AppUpdateInfo( + boolean updateAvailable, + String currentVersion, + String latestVersion, + String releaseTitle, + String releaseNotes, + String downloadUrl, + Long downloadSizeInBytes, + Instant publishedAt +) { +} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/release/CheckForUpdatesUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/release/CheckForUpdatesUseCase.java new file mode 100644 index 0000000..74be8b9 --- /dev/null +++ b/backend/src/main/java/com/devaulty/backend/application/port/in/release/CheckForUpdatesUseCase.java @@ -0,0 +1,5 @@ +package com.devaulty.backend.application.port.in.release; + +public interface CheckForUpdatesUseCase { + AppUpdateInfo execute(); +} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/release/DownloadUpdateUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/release/DownloadUpdateUseCase.java new file mode 100644 index 0000000..efb6422 --- /dev/null +++ b/backend/src/main/java/com/devaulty/backend/application/port/in/release/DownloadUpdateUseCase.java @@ -0,0 +1,7 @@ +package com.devaulty.backend.application.port.in.release; + +import reactor.core.publisher.Flux; + +public interface DownloadUpdateUseCase { + Flux execute(); +} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/release/GetCurrentVersionUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/release/GetCurrentVersionUseCase.java new file mode 100644 index 0000000..0801012 --- /dev/null +++ b/backend/src/main/java/com/devaulty/backend/application/port/in/release/GetCurrentVersionUseCase.java @@ -0,0 +1,5 @@ +package com.devaulty.backend.application.port.in.release; + +public interface GetCurrentVersionUseCase { + String execute(); +} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/release/InstallUpdateUseCase.java b/backend/src/main/java/com/devaulty/backend/application/port/in/release/InstallUpdateUseCase.java new file mode 100644 index 0000000..32be398 --- /dev/null +++ b/backend/src/main/java/com/devaulty/backend/application/port/in/release/InstallUpdateUseCase.java @@ -0,0 +1,7 @@ +package com.devaulty.backend.application.port.in.release; + +import java.nio.file.Path; + +public interface InstallUpdateUseCase { + void execute(Path installerFile); +} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/release/UpdateProgressInfo.java b/backend/src/main/java/com/devaulty/backend/application/port/in/release/UpdateProgressInfo.java new file mode 100644 index 0000000..7a3f357 --- /dev/null +++ b/backend/src/main/java/com/devaulty/backend/application/port/in/release/UpdateProgressInfo.java @@ -0,0 +1,12 @@ +package com.devaulty.backend.application.port.in.release; + +import com.devaulty.backend.application.port.in.release.enums.UpdateStatus; + +public record UpdateProgressInfo( + UpdateStatus status, + int percentage, // 0 to 100 + long downloadedBytes, // e.g: 45.200.000 + long totalBytes, // e.g: 154.000.000 + String errorMessage +) { +} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/in/release/enums/UpdateStatus.java b/backend/src/main/java/com/devaulty/backend/application/port/in/release/enums/UpdateStatus.java new file mode 100644 index 0000000..d42476e --- /dev/null +++ b/backend/src/main/java/com/devaulty/backend/application/port/in/release/enums/UpdateStatus.java @@ -0,0 +1,8 @@ +package com.devaulty.backend.application.port.in.release.enums; + +public enum UpdateStatus { + DOWNLOADING, + COMPLETED, + FAILED, + INSTALLING +} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/out/external/release/ReleasePort.java b/backend/src/main/java/com/devaulty/backend/application/port/out/external/release/ReleasePort.java new file mode 100644 index 0000000..b7ce49f --- /dev/null +++ b/backend/src/main/java/com/devaulty/backend/application/port/out/external/release/ReleasePort.java @@ -0,0 +1,11 @@ +package com.devaulty.backend.application.port.out.external.release; + +import com.devaulty.backend.application.port.out.external.release.dto.LatestReleaseInfo; +import org.springframework.core.io.buffer.DataBuffer; +import reactor.core.publisher.Flux; + +public interface ReleasePort { + LatestReleaseInfo getLatestRelease(); + + Flux downloadAsset(String downloadUrl); +} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/out/external/release/dto/LatestReleaseInfo.java b/backend/src/main/java/com/devaulty/backend/application/port/out/external/release/dto/LatestReleaseInfo.java new file mode 100644 index 0000000..791c6c7 --- /dev/null +++ b/backend/src/main/java/com/devaulty/backend/application/port/out/external/release/dto/LatestReleaseInfo.java @@ -0,0 +1,15 @@ +package com.devaulty.backend.application.port.out.external.release.dto; + +import java.time.Instant; +import java.util.List; + +public record LatestReleaseInfo( + String tagName, + String name, + String body, + String htmlUrl, + Instant publishedAt, + boolean isPreRelease, + List assets +) { +} diff --git a/backend/src/main/java/com/devaulty/backend/application/port/out/external/release/dto/ReleaseAssetInfo.java b/backend/src/main/java/com/devaulty/backend/application/port/out/external/release/dto/ReleaseAssetInfo.java new file mode 100644 index 0000000..f695de0 --- /dev/null +++ b/backend/src/main/java/com/devaulty/backend/application/port/out/external/release/dto/ReleaseAssetInfo.java @@ -0,0 +1,9 @@ +package com.devaulty.backend.application.port.out.external.release.dto; + +public record ReleaseAssetInfo( + String fileName, + String downloadUrl, + long sizeInBytes, + String contentType +) { +} diff --git a/backend/src/main/java/com/devaulty/backend/infrastructure/configuration/OpenApiConfig.java b/backend/src/main/java/com/devaulty/backend/infrastructure/configuration/OpenApiConfig.java index c74dbb9..7c94dbb 100644 --- a/backend/src/main/java/com/devaulty/backend/infrastructure/configuration/OpenApiConfig.java +++ b/backend/src/main/java/com/devaulty/backend/infrastructure/configuration/OpenApiConfig.java @@ -1,8 +1,11 @@ package com.devaulty.backend.infrastructure.configuration; +import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.info.License; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -11,11 +14,20 @@ public class OpenApiConfig { @Bean public OpenAPI customOpenAPI() { + String securitySchemeName = "DevaultyInternalToken"; + return new OpenAPI() .info(new Info() .title("Devaulty API") .version("v1") .description("Restful API for Devaulty") - .license(new License().name("Apache 2.0").url("http://springdoc.org"))); + .license(new License().name("Apache 2.0").url("http://springdoc.org"))) + .addSecurityItem(new SecurityRequirement().addList(securitySchemeName)) + .components(new Components() + .addSecuritySchemes(securitySchemeName, new SecurityScheme() + .name("X-Devaulty-Internal-Token") + .type(SecurityScheme.Type.APIKEY) + .in(SecurityScheme.In.HEADER) + .description("Devaulty Internal Token"))); } } diff --git a/backend/src/main/java/com/devaulty/backend/infrastructure/configuration/ReleaseConfig.java b/backend/src/main/java/com/devaulty/backend/infrastructure/configuration/ReleaseConfig.java new file mode 100644 index 0000000..1d58c93 --- /dev/null +++ b/backend/src/main/java/com/devaulty/backend/infrastructure/configuration/ReleaseConfig.java @@ -0,0 +1,60 @@ +package com.devaulty.backend.infrastructure.configuration; + +import com.devaulty.backend.application.impl.release.CheckForUpdatesImpl; +import com.devaulty.backend.application.impl.release.DownloadUpdateImpl; +import com.devaulty.backend.application.impl.release.GetCurrentVersionImpl; +import com.devaulty.backend.application.impl.release.InstallUpdateImpl; +import com.devaulty.backend.application.port.in.release.CheckForUpdatesUseCase; +import com.devaulty.backend.application.port.in.release.DownloadUpdateUseCase; +import com.devaulty.backend.application.port.in.release.GetCurrentVersionUseCase; +import com.devaulty.backend.application.port.in.release.InstallUpdateUseCase; +import com.devaulty.backend.application.port.out.external.release.ReleasePort; +import com.devaulty.backend.infrastructure.properties.DevaultyProperties; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class ReleaseConfig { + + @Bean + public CheckForUpdatesUseCase checkForUpdatesUseCase( + ReleasePort releasePort, + DevaultyProperties devaultyProperties + ){ + return new CheckForUpdatesImpl(releasePort, + devaultyProperties + ); + } + + @Bean + public DownloadUpdateUseCase downloadUpdateUseCase( + ReleasePort releasePort, + CheckForUpdatesUseCase checkForUpdatesUseCase, + InstallUpdateUseCase installUpdateUseCase + ){ + return new DownloadUpdateImpl( + releasePort, + checkForUpdatesUseCase, + installUpdateUseCase + ); + } + + @Bean + public InstallUpdateUseCase installUpdateUseCase( + ConfigurableApplicationContext configurableApplicationContext + ){ + return new InstallUpdateImpl( + configurableApplicationContext + ); + } + + @Bean + public GetCurrentVersionUseCase getActualVersionUseCase( + DevaultyProperties devaultyProperties + ){ + return new GetCurrentVersionImpl( + devaultyProperties + ); + } +} diff --git a/backend/src/main/java/com/devaulty/backend/infrastructure/properties/DevaultyProperties.java b/backend/src/main/java/com/devaulty/backend/infrastructure/properties/DevaultyProperties.java new file mode 100644 index 0000000..b32d47e --- /dev/null +++ b/backend/src/main/java/com/devaulty/backend/infrastructure/properties/DevaultyProperties.java @@ -0,0 +1,23 @@ +package com.devaulty.backend.infrastructure.properties; + +import jakarta.validation.constraints.NotBlank; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import org.springframework.validation.annotation.Validated; + +@Component +@Validated +@ConfigurationProperties(prefix = "app") +public class DevaultyProperties { + + @NotBlank(message = "app.version cannot be blank") + private String version; + + public String getVersion() { + return version; + } + + public void setVersion(String version) { + this.version = version; + } +} diff --git a/backend/src/main/java/com/devaulty/backend/infrastructure/security/InternalAppTokenFilter.java b/backend/src/main/java/com/devaulty/backend/infrastructure/security/InternalAppTokenFilter.java index 3efc361..db322a9 100644 --- a/backend/src/main/java/com/devaulty/backend/infrastructure/security/InternalAppTokenFilter.java +++ b/backend/src/main/java/com/devaulty/backend/infrastructure/security/InternalAppTokenFilter.java @@ -4,6 +4,7 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Value; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; @@ -14,6 +15,9 @@ @Order(1) public class InternalAppTokenFilter extends OncePerRequestFilter { + @Value("${devaulty.dev.token:#{null}}") + private String devToken; + @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { @@ -28,7 +32,13 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse // 2. For API requests, requires the request token String requestToken = request.getHeader(AppTokenContext.HEADER_NAME); - if (requestToken == null || !requestToken.equals(AppTokenContext.PROCESS_TOKEN)) { + // 3. Safety check + // In Dev: accepts the devToken + // In Prod: accepts the processToken + boolean isDevTokenValid = devToken != null && !devToken.isBlank() && devToken.equals(requestToken); + boolean isProcessTokenValid = AppTokenContext.PROCESS_TOKEN.equals(requestToken); + + if (requestToken == null || (!isDevTokenValid && !isProcessTokenValid)) { response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setContentType("application/json"); response.getWriter().write("{\"error\": \"Forbidden: Unauthorized local process request.\"}"); diff --git a/backend/src/main/resources/application-dev.yml b/backend/src/main/resources/application-dev.yml index 30cc150..a1bbf65 100644 --- a/backend/src/main/resources/application-dev.yml +++ b/backend/src/main/resources/application-dev.yml @@ -5,4 +5,10 @@ spring: show-sql: true properties: hibernate: - format_sql: true \ No newline at end of file + format_sql: true + devtools: + restart: + additional-exclude: static/**,jpackage/**,db/** +devaulty: + dev: + token: "dev-secret-token" \ No newline at end of file diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml index aceea89..a388d55 100644 --- a/backend/src/main/resources/application.yaml +++ b/backend/src/main/resources/application.yaml @@ -1,3 +1,6 @@ +app: + version: "0.1.0-alpha" + spring: application: name: backend diff --git a/backend/src/test/java/com/devaulty/backend/BackendApplicationTests.java b/backend/src/test/java/com/devaulty/backend/BackendApplicationTests.java index 20ab4aa..eaa1059 100644 --- a/backend/src/test/java/com/devaulty/backend/BackendApplicationTests.java +++ b/backend/src/test/java/com/devaulty/backend/BackendApplicationTests.java @@ -2,8 +2,10 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; @SpringBootTest +@ActiveProfiles("test") class BackendApplicationTests { @Test diff --git a/backend/src/test/java/com/devaulty/backend/adapter/in/web/release/ReleaseControllerIT.java b/backend/src/test/java/com/devaulty/backend/adapter/in/web/release/ReleaseControllerIT.java new file mode 100644 index 0000000..4417bb9 --- /dev/null +++ b/backend/src/test/java/com/devaulty/backend/adapter/in/web/release/ReleaseControllerIT.java @@ -0,0 +1,116 @@ +package com.devaulty.backend.adapter.in.web.release; + +import com.devaulty.backend.application.port.in.release.InstallUpdateUseCase; +import com.devaulty.backend.application.port.out.external.release.ReleasePort; +import com.devaulty.backend.application.port.out.external.release.dto.LatestReleaseInfo; +import com.devaulty.backend.application.port.out.external.release.dto.ReleaseAssetInfo; +import com.devaulty.backend.infrastructure.BaseIntegrationTest; +import com.devaulty.backend.infrastructure.security.AppTokenContext; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import reactor.core.publisher.Flux; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.List; + +import static org.hamcrest.Matchers.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +class ReleaseControllerIT extends BaseIntegrationTest { + + @MockitoBean + private ReleasePort releasePort; + + @MockitoBean + private InstallUpdateUseCase installUpdateUseCase; + + @Test + @DisplayName("GET /api/v1/releases/current-app-version should return 200 OK with actual version payload") + void getAppInfo_shouldReturn200OK_withActualVersion() throws Exception { + mockMvc.perform(get("/api/v1/releases/current-app-version") + .header(AppTokenContext.HEADER_NAME, AppTokenContext.PROCESS_TOKEN) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.actualVersion").value("0.1.0-alpha")); + } + + @Test + @DisplayName("GET /api/v1/releases/check should return 200 OK with update response payload") + void checkUpdates_shouldReturn200OK_withUpdateInfo() throws Exception { + // Arrange + String currentOs = System.getProperty("os.name").toLowerCase(); + String extension = currentOs.contains("win") ? ".msi" : currentOs.contains("mac") ? ".dmg" : ".deb"; + + ReleaseAssetInfo asset = new ReleaseAssetInfo( + "devaulty_0.2.0" + extension, + "https://github.com/MathCunha16/Devaulty/releases/download/v0.2.0/devaulty_0.2.0" + extension, + 50000000L, + "application/octet-stream" + ); + LatestReleaseInfo latestRelease = new LatestReleaseInfo( + "v0.2.0", + "Release v0.2.0", + "Changelog content", + "https://github.com/MathCunha16/Devaulty/releases/tag/v0.2.0", + Instant.now(), + false, + List.of(asset) + ); + + when(releasePort.getLatestRelease()).thenReturn(latestRelease); + + // Act & Assert + mockMvc.perform(get("/api/v1/releases/check") + .header(AppTokenContext.HEADER_NAME, AppTokenContext.PROCESS_TOKEN) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.updateAvailable").value(true)) + .andExpect(jsonPath("$.currentVersion").isNotEmpty()) + .andExpect(jsonPath("$.latestVersion").value("v0.2.0")) + .andExpect(jsonPath("$.releaseTitle").value("Release v0.2.0")) + .andExpect(jsonPath("$.releaseNotes").value("Changelog content")) + .andExpect(jsonPath("$.downloadUrl").value(containsString(extension))) + .andExpect(jsonPath("$.downloadSizeInBytes").value(50000000L)); + } + + @Test + @DisplayName("POST /api/v1/releases/download-and-install should return 200 OK with SSE stream") + void downloadUpdate_shouldReturn200OK_withSSEStream() throws Exception { + // Arrange + String currentOs = System.getProperty("os.name").toLowerCase(); + String extension = currentOs.contains("win") ? ".msi" : currentOs.contains("mac") ? ".dmg" : ".deb"; + String downloadUrl = "https://github.com/MathCunha16/Devaulty/releases/download/v0.2.0/devaulty_0.2.0" + extension; + + ReleaseAssetInfo asset = new ReleaseAssetInfo("devaulty_0.2.0" + extension, downloadUrl, 11L, "application/octet-stream"); + LatestReleaseInfo latestRelease = new LatestReleaseInfo( + "v0.2.0", + "Release v0.2.0", + "Changelog content", + "https://github.com/MathCunha16/Devaulty/releases/tag/v0.2.0", + Instant.now(), + false, + List.of(asset) + ); + + when(releasePort.getLatestRelease()).thenReturn(latestRelease); + + DefaultDataBufferFactory factory = new DefaultDataBufferFactory(); + var buffer = factory.wrap("hello world".getBytes(StandardCharsets.UTF_8)); + when(releasePort.downloadAsset(anyString())).thenReturn(Flux.just(buffer)); + + // Act & Assert + mockMvc.perform(post("/api/v1/releases/download-and-install") + .header(AppTokenContext.HEADER_NAME, AppTokenContext.PROCESS_TOKEN) + .accept(MediaType.TEXT_EVENT_STREAM)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_EVENT_STREAM)); + } +} diff --git a/backend/src/test/java/com/devaulty/backend/adapter/out/external/github/GitHubClientTest.java b/backend/src/test/java/com/devaulty/backend/adapter/out/external/github/GitHubClientTest.java new file mode 100644 index 0000000..202e132 --- /dev/null +++ b/backend/src/test/java/com/devaulty/backend/adapter/out/external/github/GitHubClientTest.java @@ -0,0 +1,139 @@ +package com.devaulty.backend.adapter.out.external.github; + +import com.devaulty.backend.adapter.out.external.github.dto.GitHubAssetResponse; +import com.devaulty.backend.adapter.out.external.github.dto.GitHubReleaseResponse; +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@SuppressWarnings({"unchecked", "rawtypes"}) +class GitHubClientTest { + + @Mock + private WebClient webClient; + + @Mock + private WebClient.RequestHeadersUriSpec requestHeadersUriSpec; + + @Mock + private WebClient.RequestHeadersSpec requestHeadersSpec; + + @Mock + private WebClient.ResponseSpec responseSpec; + + private GitHubClient gitHubClient; + + @BeforeEach + void setUp() { + gitHubClient = new GitHubClient(webClient); + } + + @Test + @DisplayName("getLatestRelease should return GitHubReleaseResponse when request succeeds") + void getLatestRelease_shouldReturnReleaseResponse_whenGitHubReturnsRelease() { + // Arrange + GitHubAssetResponse asset = new GitHubAssetResponse("devaulty_0.2.0.deb", "https://download.url", 50000000L, "application/octet-stream", "sha256:12345"); + GitHubReleaseResponse expectedResponse = new GitHubReleaseResponse( + "v0.2.0", + "Release v0.2.0", + "Changelog notes", + "https://github.com/tag/v0.2.0", + Instant.now(), + false, + List.of(asset) + ); + + when(webClient.get()).thenReturn(requestHeadersUriSpec); + when(requestHeadersUriSpec.uri(anyString())).thenReturn(requestHeadersSpec); + when(requestHeadersSpec.retrieve()).thenReturn(responseSpec); + when(responseSpec.bodyToMono(GitHubReleaseResponse.class)).thenReturn(Mono.just(expectedResponse)); + + // Act + GitHubReleaseResponse actualResponse = gitHubClient.getLatestRelease(); + + // Assert + assertNotNull(actualResponse); + assertEquals("v0.2.0", actualResponse.tagName()); + assertEquals("Release v0.2.0", actualResponse.name()); + assertEquals("Changelog notes", actualResponse.body()); + assertEquals(1, actualResponse.assets().size()); + + verify(webClient, times(1)).get(); + } + + @Test + @DisplayName("getLatestRelease should return null when 404 Not Found WebClientResponseException occurs") + void getLatestRelease_shouldReturnNull_when404NotFoundOccurs() { + // Arrange + when(webClient.get()).thenReturn(requestHeadersUriSpec); + when(requestHeadersUriSpec.uri(anyString())).thenReturn(requestHeadersSpec); + when(requestHeadersSpec.retrieve()).thenReturn(responseSpec); + when(responseSpec.bodyToMono(GitHubReleaseResponse.class)) + .thenReturn(Mono.error(WebClientResponseException.create(404, "Not Found", null, null, null))); + + // Act + GitHubReleaseResponse actualResponse = gitHubClient.getLatestRelease(); + + // Assert + assertNull(actualResponse); + verify(webClient, times(1)).get(); + } + + @Test + @DisplayName("getLatestRelease should rethrow exception when 500 Server Error WebClientResponseException occurs") + void getLatestRelease_shouldThrowException_whenServerErrorOccurs() { + // Arrange + when(webClient.get()).thenReturn(requestHeadersUriSpec); + when(requestHeadersUriSpec.uri(anyString())).thenReturn(requestHeadersSpec); + when(requestHeadersSpec.retrieve()).thenReturn(responseSpec); + when(responseSpec.bodyToMono(GitHubReleaseResponse.class)) + .thenReturn(Mono.error(WebClientResponseException.create(500, "Internal Server Error", null, null, null))); + + // Act & Assert + assertThrows(WebClientResponseException.class, () -> gitHubClient.getLatestRelease()); + verify(webClient, times(1)).get(); + } + + @Test + @DisplayName("downloadAsset should return DataBuffer Flux when download URL is valid") + void downloadAsset_shouldReturnDataBufferFlux_whenUrlIsValid() { + // Arrange + String downloadUrl = "https://github.com/MathCunha16/Devaulty/releases/download/v0.2.0/devaulty_0.2.0.deb"; + DefaultDataBufferFactory factory = new DefaultDataBufferFactory(); + DataBuffer mockBuffer = factory.wrap("binary content".getBytes(StandardCharsets.UTF_8)); + + when(webClient.get()).thenReturn(requestHeadersUriSpec); + when(requestHeadersUriSpec.uri(any(URI.class))).thenReturn(requestHeadersSpec); + when(requestHeadersSpec.retrieve()).thenReturn(responseSpec); + when(responseSpec.bodyToFlux(DataBuffer.class)).thenReturn(Flux.just(mockBuffer)); + + // Act + Flux resultFlux = gitHubClient.downloadAsset(downloadUrl); + List buffers = resultFlux.collectList().block(); + + // Assert + assertNotNull(buffers); + assertEquals(1, buffers.size()); + verify(webClient, times(1)).get(); + } +} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/release/CheckForUpdatesImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/release/CheckForUpdatesImplTest.java new file mode 100644 index 0000000..e2755d8 --- /dev/null +++ b/backend/src/test/java/com/devaulty/backend/application/impl/release/CheckForUpdatesImplTest.java @@ -0,0 +1,222 @@ +package com.devaulty.backend.application.impl.release; + +import com.devaulty.backend.application.port.in.release.AppUpdateInfo; +import com.devaulty.backend.application.port.out.external.release.ReleasePort; +import com.devaulty.backend.application.port.out.external.release.dto.LatestReleaseInfo; +import com.devaulty.backend.application.port.out.external.release.dto.ReleaseAssetInfo; +import com.devaulty.backend.infrastructure.properties.DevaultyProperties; +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.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class CheckForUpdatesImplTest { + + @Mock + private ReleasePort releasePort; + + @Mock + private DevaultyProperties devaultyProperties; + + @InjectMocks + private CheckForUpdatesImpl checkForUpdatesUseCase; + + @BeforeEach + void setUp() { + lenient().when(devaultyProperties.getVersion()).thenReturn("0.1.0-alpha"); + } + + @Test + @DisplayName("Should return updateAvailable = true when GitHub has a newer version tag") + void shouldReturnUpdateAvailable_whenNewerReleaseExistsOnGitHub() { + // Arrange + String currentOs = System.getProperty("os.name").toLowerCase(); + String extension = currentOs.contains("win") ? ".msi" : currentOs.contains("mac") ? ".dmg" : ".deb"; + + ReleaseAssetInfo matchingAsset = new ReleaseAssetInfo( + "devaulty-installer" + extension, + "https://download.url/devaulty" + extension, + 50000000L, + "application/octet-stream" + ); + LatestReleaseInfo latestRelease = new LatestReleaseInfo( + "v0.2.0", + "Release 0.2.0", + "Bug fixes and performance improvements", + "https://github.com/MathCunha16/Devaulty/releases/tag/v0.2.0", + Instant.now(), + false, + List.of(matchingAsset) + ); + + when(releasePort.getLatestRelease()).thenReturn(latestRelease); + + // Act + AppUpdateInfo result = checkForUpdatesUseCase.execute(); + + // Assert + assertNotNull(result); + assertTrue(result.updateAvailable()); + assertEquals("0.1.0-alpha", result.currentVersion()); + assertEquals("v0.2.0", result.latestVersion()); + assertEquals("Release 0.2.0", result.releaseTitle()); + assertEquals("Bug fixes and performance improvements", result.releaseNotes()); + assertEquals("https://download.url/devaulty" + extension, result.downloadUrl()); + assertEquals(50000000L, result.downloadSizeInBytes()); + + verify(releasePort, times(1)).getLatestRelease(); + } + + @Test + @DisplayName("Should return updateAvailable = false when current version is equal to GitHub release tag") + void shouldReturnNoUpdateAvailable_whenCurrentVersionIsUpToDate() { + // Arrange + LatestReleaseInfo latestRelease = new LatestReleaseInfo( + "v0.1.0-alpha", + "Release 0.1.0-alpha", + "Initial alpha release", + "https://github.com/MathCunha16/Devaulty/releases/tag/v0.1.0-alpha", + Instant.now(), + false, + List.of() + ); + + when(releasePort.getLatestRelease()).thenReturn(latestRelease); + + // Act + AppUpdateInfo result = checkForUpdatesUseCase.execute(); + + // Assert + assertNotNull(result); + assertFalse(result.updateAvailable()); + assertEquals("0.1.0-alpha", result.currentVersion()); + assertEquals("v0.1.0-alpha", result.latestVersion()); + + verify(releasePort, times(1)).getLatestRelease(); + } + + @Test + @DisplayName("Should return updateAvailable = false when local version is newer than GitHub release tag (prevents downgrade)") + void shouldReturnNoUpdateAvailable_whenLocalVersionIsNewerThanGitHubRelease() { + // Arrange + LatestReleaseInfo olderRelease = new LatestReleaseInfo( + "v0.0.9", + "Release 0.0.9", + "Older release", + "https://github.com/MathCunha16/Devaulty/releases/tag/v0.0.9", + Instant.now(), + false, + List.of() + ); + + when(releasePort.getLatestRelease()).thenReturn(olderRelease); + + // Act + AppUpdateInfo result = checkForUpdatesUseCase.execute(); + + // Assert + assertNotNull(result); + assertFalse(result.updateAvailable()); + assertEquals("0.1.0-alpha", result.currentVersion()); + assertEquals("v0.0.9", result.latestVersion()); + + verify(releasePort, times(1)).getLatestRelease(); + } + + @Test + @DisplayName("Should return null downloadUrl without throwing NullPointerException when release assets is null") + void shouldHandleNullAssets_withoutThrowingNullPointerException() { + // Arrange + LatestReleaseInfo nullAssetsRelease = new LatestReleaseInfo( + "v0.2.0", + "Release 0.2.0", + "Release notes", + "https://github.com/MathCunha16/Devaulty/releases/tag/v0.2.0", + Instant.now(), + false, + null + ); + + when(releasePort.getLatestRelease()).thenReturn(nullAssetsRelease); + + // Act + AppUpdateInfo result = checkForUpdatesUseCase.execute(); + + // Assert + assertNotNull(result); + assertTrue(result.updateAvailable()); + assertNull(result.downloadUrl()); + assertEquals(0L, result.downloadSizeInBytes()); + + verify(releasePort, times(1)).getLatestRelease(); + } + + @Test + @DisplayName("Should return updateAvailable = false and dynamic currentVersion when GitHub release is null") + void shouldReturnNoUpdateAvailable_whenNoReleaseExistsOnGitHub() { + // Arrange + when(devaultyProperties.getVersion()).thenReturn("2.5.0-beta"); + when(releasePort.getLatestRelease()).thenReturn(null); + + // Act + AppUpdateInfo result = checkForUpdatesUseCase.execute(); + + // Assert + assertNotNull(result); + assertFalse(result.updateAvailable()); + assertEquals("2.5.0-beta", result.currentVersion()); + assertNull(result.latestVersion()); + assertNull(result.downloadUrl()); + assertEquals(0L, result.downloadSizeInBytes()); + + verify(releasePort, times(1)).getLatestRelease(); + } + + @ParameterizedTest(name = "{0} -> {1} should be recognised as an update") + @CsvSource({ + "0.1.0-alpha.1, v0.1.0-alpha.2", + "0.1.0-alpha, v0.1.0-beta", + "0.1.0-alpha, v0.1.0" + }) + void shouldReturnUpdateAvailable_whenPreReleaseProgresses(String currentVersion, String latestTag) { + when(devaultyProperties.getVersion()).thenReturn(currentVersion); + LatestReleaseInfo latestRelease = new LatestReleaseInfo( + latestTag, "Release " + latestTag, "", "", + Instant.now(), false, List.of() + ); + when(releasePort.getLatestRelease()).thenReturn(latestRelease); + + AppUpdateInfo result = checkForUpdatesUseCase.execute(); + + assertTrue(result.updateAvailable()); + } + + @Test + @DisplayName("Should return updateAvailable = false when stable is not superseded by a pre-release") + void shouldReturnNoUpdateAvailable_whenStableIsNotSupersededByPreRelease() { + // Arrange: current = 0.1.0 (stable), latest = 0.1.0-beta (pre-release) + when(devaultyProperties.getVersion()).thenReturn("0.1.0"); + LatestReleaseInfo latestRelease = new LatestReleaseInfo( + "v0.1.0-beta", "Release 0.1.0-beta", "", "", + Instant.now(), false, List.of() + ); + when(releasePort.getLatestRelease()).thenReturn(latestRelease); + + AppUpdateInfo result = checkForUpdatesUseCase.execute(); + + assertFalse(result.updateAvailable()); + } +} + 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 new file mode 100644 index 0000000..0f981e5 --- /dev/null +++ b/backend/src/test/java/com/devaulty/backend/application/impl/release/DownloadUpdateImplTest.java @@ -0,0 +1,128 @@ +package com.devaulty.backend.application.impl.release; + +import com.devaulty.backend.application.exception.UpdateNotAvailableException; +import com.devaulty.backend.application.port.in.release.AppUpdateInfo; +import com.devaulty.backend.application.port.in.release.CheckForUpdatesUseCase; +import com.devaulty.backend.application.port.in.release.InstallUpdateUseCase; +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.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +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.time.Instant; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class DownloadUpdateImplTest { + + @Mock + private ReleasePort releasePort; + + @Mock + private CheckForUpdatesUseCase checkForUpdatesUseCase; + + @Mock + private InstallUpdateUseCase installUpdateUseCase; + + @InjectMocks + private DownloadUpdateImpl downloadUpdateUseCase; + + @Test + @DisplayName("Should throw UpdateNotAvailableException when updateAvailable is false") + void shouldThrowUpdateNotAvailableException_whenNoUpdateAvailable() { + // Arrange + AppUpdateInfo noUpdateInfo = new AppUpdateInfo( + false, + "0.1.0-alpha", + "v0.1.0-alpha", + "Title", + "Notes", + null, + 0L, + Instant.now() + ); + + when(checkForUpdatesUseCase.execute()).thenReturn(noUpdateInfo); + + // Act & Assert + assertThrows(UpdateNotAvailableException.class, () -> downloadUpdateUseCase.execute()); + verify(checkForUpdatesUseCase, times(1)).execute(); + verifyNoInteractions(releasePort); + } + + @Test + @DisplayName("Should throw UpdateNotAvailableException when downloadUrl is null") + void shouldThrowUpdateNotAvailableException_whenDownloadUrlIsNull() { + // Arrange + AppUpdateInfo nullUrlInfo = new AppUpdateInfo( + true, + "0.1.0-alpha", + "v0.2.0", + "Title", + "Notes", + null, + 0L, + Instant.now() + ); + + when(checkForUpdatesUseCase.execute()).thenReturn(nullUrlInfo); + + // Act & Assert + assertThrows(UpdateNotAvailableException.class, () -> downloadUpdateUseCase.execute()); + verify(checkForUpdatesUseCase, times(1)).execute(); + verifyNoInteractions(releasePort); + } + + @Test + @DisplayName("Should stream download progress and trigger installation when download succeeds") + void shouldStreamProgressAndTriggerInstallation_whenDownloadSucceeds() { + // Arrange + String downloadUrl = "https://github.com/MathCunha16/Devaulty/releases/download/v0.2.0/devaulty_0.2.0.deb"; + long totalBytes = 11L; + + AppUpdateInfo validUpdateInfo = new AppUpdateInfo( + true, + "0.1.0-alpha", + "v0.2.0", + "Title", + "Notes", + downloadUrl, + totalBytes, + Instant.now() + ); + + when(checkForUpdatesUseCase.execute()).thenReturn(validUpdateInfo); + + DefaultDataBufferFactory factory = new DefaultDataBufferFactory(); + var buffer = factory.wrap("hello world".getBytes(StandardCharsets.UTF_8)); + when(releasePort.downloadAsset(downloadUrl)).thenReturn(Flux.just(buffer)); + doNothing().when(installUpdateUseCase).execute(any()); + + // Act + Flux progressFlux = downloadUpdateUseCase.execute(); + List results = progressFlux.collectList().block(); + + // Assert + assertNotNull(results); + assertFalse(results.isEmpty()); + assertTrue(results.stream().anyMatch(p -> p.status() == UpdateStatus.DOWNLOADING)); + assertTrue(results.stream().anyMatch(p -> p.status() == UpdateStatus.INSTALLING)); + assertTrue(results.stream().anyMatch(p -> p.status() == UpdateStatus.COMPLETED && p.percentage() == 100)); + + verify(checkForUpdatesUseCase, times(1)).execute(); + verify(releasePort, times(1)).downloadAsset(downloadUrl); + verify(installUpdateUseCase, times(1)).execute(any()); + } +} diff --git a/backend/src/test/java/com/devaulty/backend/application/impl/release/GetCurrentVersionImplTest.java b/backend/src/test/java/com/devaulty/backend/application/impl/release/GetCurrentVersionImplTest.java new file mode 100644 index 0000000..e50def6 --- /dev/null +++ b/backend/src/test/java/com/devaulty/backend/application/impl/release/GetCurrentVersionImplTest.java @@ -0,0 +1,38 @@ +package com.devaulty.backend.application.impl.release; + +import com.devaulty.backend.infrastructure.properties.DevaultyProperties; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class GetCurrentVersionImplTest { + + @Mock + private DevaultyProperties devaultyProperties; + + @InjectMocks + private GetCurrentVersionImpl getCurrentVersionUseCase; + + @Test + @DisplayName("Should return current application version string from DevaultyProperties") + void shouldReturnCurrentAppVersion_whenExecuted() { + // Arrange + when(devaultyProperties.getVersion()).thenReturn("0.1.0-alpha"); + + // Act + String result = getCurrentVersionUseCase.execute(); + + // Assert + assertNotNull(result); + assertEquals("0.1.0-alpha", result); + verify(devaultyProperties, times(1)).getVersion(); + } +} 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 new file mode 100644 index 0000000..d2ce501 --- /dev/null +++ b/backend/src/test/java/com/devaulty/backend/application/impl/release/InstallUpdateImplTest.java @@ -0,0 +1,83 @@ +package com.devaulty.backend.application.impl.release; + +import com.devaulty.backend.application.exception.UpdateNotAvailableException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ConfigurableApplicationContext; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class InstallUpdateImplTest { + + @Mock + private ConfigurableApplicationContext applicationContext; + + @Spy + private InstallUpdateImpl installUpdateUseCase = new InstallUpdateImpl(applicationContext); + + @Test + @DisplayName("Should throw UpdateNotAvailableException when installer file does not exist") + void shouldThrowUpdateNotAvailableException_whenInstallerFileDoesNotExist() { + // Arrange + Path nonExistentPath = Path.of("non_existent_file.deb"); + + // Act & Assert + assertThrows(UpdateNotAvailableException.class, () -> installUpdateUseCase.execute(nonExistentPath)); + } + + @Test + @DisplayName("Should throw IllegalStateException when installer file path is outside temp directory") + void shouldThrowIllegalStateException_whenInstallerFileOutsideTempDir() throws IOException { + // Arrange + File outsideTempFile = File.createTempFile("outside_test", ".deb"); + outsideTempFile.deleteOnExit(); + + // Act & Assert + assertThrows(IllegalStateException.class, () -> installUpdateUseCase.execute(outsideTempFile.toPath())); + } + + @Test + @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 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()); + + // Act & Assert + assertDoesNotThrow(() -> installUpdateUseCase.execute(testInstaller.toPath())); + verify(installUpdateUseCase, times(1)).startDetached(anyList()); + } +} diff --git a/docs/architecture/versioning.md b/docs/architecture/versioning.md new file mode 100644 index 0000000..5f5897b --- /dev/null +++ b/docs/architecture/versioning.md @@ -0,0 +1,85 @@ +# Application Versioning Architecture + +This document defines how the application version is configured, managed, and propagated across the Devaulty codebase, Spring Boot backend, and native `jpackage` installers. + +## The Single Source of Truth + +To prevent version desynchronization across the build system, backend, and installer packagers, the application version is declared in **one single place**: + +> **`src/main/resources/application.yaml`** -> `app.version` + +```yaml +app: + version: "0.1.0-alpha" +``` + +No other file in the repository (including `build.gradle.kts` or Java source files) hardcodes the version number. + +## How Version Information Propagates + +### 1. Spring Boot Backend (`DevaultyProperties`) + +The backend reads the version dynamically at application startup using a strongly typed, validated configuration properties bean: + +- **Class:** `com.devaulty.backend.infrastructure.properties.DevaultyProperties` +- **Prefix:** `app` +- **Validation:** `@Validated` with `@NotBlank` on `appVersion`. + +```java +@Component +@Validated +@ConfigurationProperties(prefix = "app") +public class DevaultyProperties { + + @NotBlank(message = "app.version cannot be blank") + private String appVersion; + + public String getAppVersion() { + return appVersion; + } + + public void setAppVersion(String appVersion) { + this.appVersion = appVersion; + } +} +``` + +Use cases like `CheckForUpdatesImpl` inject `DevaultyProperties` to obtain `devaultyProperties.getAppVersion()`. This eliminates the need for Spring Boot `BuildProperties` or `build-info.properties` generation, preventing false-positive hot-reloads during local development. + +### 2. Gradle Build System & Installers (`build.gradle.kts`) + +`build.gradle.kts` reads `application.yaml` dynamically at configuration time: + +```kotlin +// Reads app.version dynamically from application.yaml as Single Source of Truth +val yamlVersion = file("src/main/resources/application.yaml") + .readLines() + .firstOrNull { it.trim().startsWith("version:") } + ?.substringAfter("version:") + ?.trim() + ?.removeSurrounding("\"") + ?.removeSurrounding("'") + ?: "0.1.0-alpha" + +group = "com.devaulty.backend" +version = yamlVersion + +// Clean version for Linux/Windows packaging (e.g. "0.1.0-alpha" -> "0.1.0") +val packageVersion = yamlVersion.replace(Regex("(?i)-.*$"), "") + +// Numeric 3-part version for macOS packaging (e.g. "0.1.0") +val macPackageVersion = if (packageVersion.split(".").size >= 3) packageVersion else "$packageVersion.0" +``` + +From this single string: +- **`version`**: Used for standard Gradle task metadata (`0.1.0-alpha`). +- **`packageVersion`**: Strips prerelease suffixes (`-alpha`, `-beta`) to comply with Linux (`.deb`/`.rpm`) and Windows (`.msi`) package managers (`0.1.0`). +- **`macPackageVersion`**: Formats a strict 3-part numeric SemVer required by Apple (`0.1.0`). + +## Checklist: Bumping a Version + +To release a new version of Devaulty (e.g. from `0.1.0-alpha` to `0.2.0`): + +- [ ] Update `app.version` in `src/main/resources/application.yaml`. +- [ ] Run `./gradlew compileJava` to verify property loading. +- [ ] Run `./gradlew packageInstallers` (or `./gradlew packageDeb`) to verify installer generation. diff --git a/docs/security/local-development-tokens.md b/docs/security/local-development-tokens.md new file mode 100644 index 0000000..7c415e1 --- /dev/null +++ b/docs/security/local-development-tokens.md @@ -0,0 +1,69 @@ +# Local Development & Process Security Tokens + +This document explains how the internal process security token (`X-Devaulty-Internal-Token`) operates, why it exists, and how to develop and test endpoints locally using Swagger UI and cURL. + +## The Problem Addressed + +Devaulty runs a local Spring Boot server on an ephemeral port inside the user's desktop machine. Since local HTTP ports can theoretically be probed by other local applications running under the same user session, all `/api/v1/**` endpoints require an internal process authentication header: + +```http +X-Devaulty-Internal-Token: +``` + +## Production vs Development Behavior + +The token validation logic is enforced by `InternalAppTokenFilter`. + +``` +Production Mode (prod) Development Mode (dev) +====================== ====================== +- Random UUID generated - Accepts static token from application-dev.yml + in-memory at process boot ("dev-secret-token") +- Injected via JavaFX into - Accepts random UUID as fallback + WebView before React mounts - Enables Swagger UI & cURL testing +``` + +### 1. Production Mode (`application-prod.yml`) +- `AppTokenContext.PROCESS_TOKEN` generates a cryptographically unique UUID (`UUID.randomUUID()`) in-memory when the JVM boots. +- `DevaultyDesktop.java` injects this token directly into the JavaFX `WebView` JS context (`window.DEVAULTY_INTERNAL_TOKEN`) during the `Worker.State.RUNNING` state before React components mount. +- External requests without this exact in-memory UUID receive `HTTP 403 Forbidden`. + +### 2. Local Development Mode (`application-dev.yml`) +- `src/main/resources/application-dev.yml` declares a development token: + +```yaml +devaulty: + dev: + token: "dev-secret-token" +``` + +- `InternalAppTokenFilter` reads `@Value("${devaulty.dev.token:#{null}}")`. If present, it permits requests carrying `"dev-secret-token"`. +- In `application-prod.yml`, `devaulty.dev.token` is completely absent. `devToken` evaluates to `null`, ensuring development tokens can **never** be used in production builds. + +## How to Test Endpoints Locally + +### Option A: Using Swagger UI + +1. Open Swagger UI in your browser: + `http://localhost:8080/swagger-ui.html` +2. Click the green **Authorize 🔓** button in the top right corner. +3. In the `DevaultyInternalToken` value input, enter: + `dev-secret-token` +4. Click **Authorize** and close the modal. +5. All subsequent "Try it out" API calls from Swagger UI will automatically include the `X-Devaulty-Internal-Token: dev-secret-token` header. + +### Option B: Using cURL / HTTP Clients + +Pass the `X-Devaulty-Internal-Token` header in your request: + +```bash +curl -X GET "http://localhost:8080/api/v1/release/check" \ + -H "X-Devaulty-Internal-Token: dev-secret-token" \ + -H "Accept: application/json" +``` + +## Security Best Practices Checklist + +- [ ] `application-prod.yml` must **never** contain a `devaulty.dev.token` property. +- [ ] `InternalAppTokenFilter` must validate `devToken` only when non-null and non-blank (`devToken != null && !devToken.isBlank()`). +- [ ] Production builds (`jpackage`) must always execute with `-Dspring.profiles.active=prod`. diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index a875379..8874b0e 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -19,16 +19,27 @@ export class ApiError extends Error { } } +export const getInternalToken = (): string | undefined => { + if (typeof window !== "undefined" && window.DEVAULTY_INTERNAL_TOKEN) { + return window.DEVAULTY_INTERNAL_TOKEN; + } + // Strictly in development mode (import.meta.env.DEV), fallback to dev secret token. + // In production builds, Vite tree-shakes and completely eliminates this fallback. + if (import.meta.env.DEV) { + return "dev-secret-token"; + } + return undefined; +}; + export const apiClient = axios.create({ - baseURL: import.meta.env.VITE_API_BASE_URL ?? "/api/v1", + baseURL: import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8080/api/v1", headers: { "Content-Type": "application/json", }, }); apiClient.interceptors.request.use((config) => { - // Reads DEVAULTY_INTERNAL_TOKEN from window (set by JavaFX) - const internalToken = window.DEVAULTY_INTERNAL_TOKEN; + const internalToken = getInternalToken(); if (internalToken) { config.headers["X-Devaulty-Internal-Token"] = internalToken; diff --git a/frontend/src/components/RootLayout.tsx b/frontend/src/components/RootLayout.tsx index 92a8a7e..e7aa945 100644 --- a/frontend/src/components/RootLayout.tsx +++ b/frontend/src/components/RootLayout.tsx @@ -1,6 +1,6 @@ import React, { Suspense, useState } from "react"; import { Link, Outlet } from "@tanstack/react-router"; -import { Toaster } from "sonner"; +import { Toaster, toast } from "sonner"; import { ThemeProvider } from "./ThemeProvider"; import { useTheme } from "../hooks/useTheme"; import { useProjectsQuery } from "~features/projects/hooks/useProjects"; @@ -11,6 +11,13 @@ import * as Icons from "lucide-react"; import styles from "../routes/__root.module.css"; import { HackerLogo } from "./HackerLogo"; import { getIconComponent } from "../utils/icons"; +import { + useCurrentVersionQuery, + useCheckUpdatesQuery, + useCheckUpdatesMutation, +} from "~features/releases/hooks/useReleases"; +import { UpdateModal } from "~features/releases/components/UpdateModal"; +import type { AppUpdateInfoResponse } from "~types/api"; // ────────────────────────────────────────────────────────────── // Edge toggle button — floats half in / half out of the sidebar. @@ -43,15 +50,35 @@ const SidebarEdgeButton: React.FC = () => { // ────────────────────────────────────────────────────────────── // Main navigation sidebar content // ────────────────────────────────────────────────────────────── -const NavigationSidebar: React.FC = () => { +const NavigationSidebar: React.FC<{ + onOpenUpdateModal: (info: AppUpdateInfoResponse) => void; +}> = ({ onOpenUpdateModal }) => { const { theme, toggleTheme } = useTheme(); const { data: projectsData } = useProjectsQuery(); + const { data: versionData } = useCurrentVersionQuery(); + const checkUpdatesMutation = useCheckUpdatesMutation(); + const { isOpen } = useSidebar(); const [isModalOpen, setIsModalOpen] = useState(false); const projects = projectsData?.content || []; 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 || ""})` + ); + } + } catch { + toast.error("Failed to check for application updates."); + } + }; + return ( // wrapper: transitions the visible width
{ className={`${styles.sidebar} ${isOpen ? styles.sidebarOpen : styles.sidebarClosed}`} >
- - - +
+ + + + {versionData?.actualVersion && ( + + )} +
@@ -158,6 +201,28 @@ const NavigationSidebar: React.FC = () => { const RootLayoutInner: React.FC = () => { const { theme } = useTheme(); const { isOpen } = useSidebar(); + const { data: autoUpdateData } = useCheckUpdatesQuery(true); + + const [dismissedVersion, setDismissedVersion] = useState(null); + const [manualUpdateInfo, setManualUpdateInfo] = useState(null); + + const currentUpdateInfo = + manualUpdateInfo || (autoUpdateData?.updateAvailable ? autoUpdateData : null); + + const isModalOpen = + !!currentUpdateInfo && currentUpdateInfo.latestVersion !== dismissedVersion; + + const handleOpenUpdateModal = (info: AppUpdateInfoResponse) => { + setManualUpdateInfo(info); + setDismissedVersion(null); + }; + + const handleCloseUpdateModal = () => { + if (currentUpdateInfo) { + setDismissedVersion(currentUpdateInfo.latestVersion); + } + setManualUpdateInfo(null); + }; return (
@@ -168,7 +233,7 @@ const RootLayoutInner: React.FC = () => {
); }; diff --git a/frontend/src/features/releases/api/releasesApi.ts b/frontend/src/features/releases/api/releasesApi.ts new file mode 100644 index 0000000..ac129d2 --- /dev/null +++ b/frontend/src/features/releases/api/releasesApi.ts @@ -0,0 +1,94 @@ +import { apiClient, getInternalToken } from "../../../api/client"; +import type { + CurrentVersionResponse, + AppUpdateInfoResponse, + UpdateDownloadProgressResponse, +} from "../../../types/api"; + +export const releasesApi = { + getCurrentVersion: async (): Promise => { + const response = await apiClient.get("/releases/current-app-version"); + return response.data; + }, + + checkUpdates: async (): Promise => { + const response = await apiClient.get("/releases/check"); + return response.data; + }, + + streamDownloadAndInstall: ( + onProgress: (data: UpdateDownloadProgressResponse) => void, + onError: (errorMessage: string) => void + ): (() => void) => { + const controller = new AbortController(); + const baseUrl = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8080/api/v1"; + const url = `${baseUrl}/releases/download-and-install`; + + const headers: Record = { + Accept: "text/event-stream", + }; + const internalToken = getInternalToken(); + if (internalToken) { + headers["X-Devaulty-Internal-Token"] = internalToken; + } + + fetch(url, { + method: "POST", + headers, + signal: controller.signal, + }) + .then(async (response) => { + if (!response.ok) { + let errorMsg = `HTTP Error ${response.status}`; + try { + const errData = await response.json(); + if (errData?.message) errorMsg = errData.message; + } catch { + // ignore json parse error + } + onError(errorMsg); + return; + } + + if (!response.body) { + onError("No stream body received from server"); + return; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith("data:")) { + const jsonStr = trimmed.slice(5).trim(); + if (jsonStr) { + try { + const data: UpdateDownloadProgressResponse = JSON.parse(jsonStr); + onProgress(data); + } catch { + // ignore JSON parse error for malformed lines + } + } + } + } + } + }) + .catch((err) => { + if (err.name !== "AbortError") { + onError(err.message || "Failed to download update stream"); + } + }); + + return () => controller.abort(); + }, +}; diff --git a/frontend/src/features/releases/components/UpdateModal.module.css b/frontend/src/features/releases/components/UpdateModal.module.css new file mode 100644 index 0000000..2e0afdd --- /dev/null +++ b/frontend/src/features/releases/components/UpdateModal.module.css @@ -0,0 +1,320 @@ +.overlay { + position: fixed; + inset: 0; + background-color: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(8px); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + padding: 1rem; + animation: fadeIn 0.25s cubic-bezier(0.16, 1, 0.3, 1); +} + +.modal { + background: var(--color-card, #0f172a); + border: 1px solid var(--color-border, rgba(255, 255, 255, 0.1)); + border-radius: var(--radius-lg, 12px); + width: 100%; + max-width: 520px; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.6), 0 0 30px rgba(99, 102, 241, 0.15); + overflow: hidden; + display: flex; + flex-direction: column; + position: relative; + animation: slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1); +} + +.header { + padding: 1.5rem 1.5rem 1rem 1.5rem; + display: flex; + align-items: flex-start; + justify-content: space-between; + border-bottom: 1px solid var(--color-border, rgba(255, 255, 255, 0.08)); + background: linear-gradient(180deg, rgba(99, 102, 241, 0.08) 0%, rgba(0, 0, 0, 0) 100%); +} + +.headerTitleSection { + display: flex; + align-items: center; + gap: 0.875rem; +} + +.iconBadge { + width: 44px; + height: 44px; + border-radius: var(--radius-md, 8px); + background: rgba(99, 102, 241, 0.15); + border: 1px solid rgba(99, 102, 241, 0.3); + color: #6366f1; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + box-shadow: 0 0 15px rgba(99, 102, 241, 0.2); +} + +.titleGroup { + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.title { + font-size: 1.15rem; + font-weight: 700; + color: var(--color-foreground, #ffffff); + margin: 0; + letter-spacing: -0.01em; +} + +.subtitle { + font-size: 0.78rem; + color: var(--color-muted-foreground, #94a3b8); + margin: 0; +} + +.closeBtn { + background: transparent; + border: none; + color: var(--color-muted-foreground, #94a3b8); + cursor: pointer; + padding: 0.35rem; + border-radius: var(--radius-sm, 6px); + transition: all 0.15s ease; + display: flex; + align-items: center; + justify-content: center; +} + +.closeBtn:hover { + background: var(--color-secondary, rgba(255, 255, 255, 0.1)); + color: var(--color-foreground, #ffffff); +} + +.body { + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 1.25rem; + max-height: 70vh; + overflow-y: auto; +} + +.versionBadgeRow { + display: flex; + align-items: center; + gap: 0.75rem; + background: rgba(0, 0, 0, 0.25); + border: 1px solid var(--color-border, rgba(255, 255, 255, 0.08)); + padding: 0.75rem 1rem; + border-radius: var(--radius-md, 8px); +} + +.versionPill { + font-family: var(--font-mono, monospace); + font-size: 0.75rem; + font-weight: 700; + padding: 0.25rem 0.6rem; + border-radius: 9999px; +} + +.versionCurrent { + background: rgba(148, 163, 184, 0.15); + color: var(--color-muted-foreground, #94a3b8); + border: 1px solid rgba(148, 163, 184, 0.2); +} + +.versionArrow { + color: var(--color-muted-foreground, #64748b); + font-size: 0.8rem; +} + +.versionNew { + background: rgba(16, 185, 129, 0.15); + color: #10b981; + border: 1px solid rgba(16, 185, 129, 0.3); + box-shadow: 0 0 10px rgba(16, 185, 129, 0.2); +} + +.releaseNotesBox { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.releaseNotesLabel { + font-size: 0.72rem; + font-weight: 700; + font-family: var(--font-mono, monospace); + color: var(--color-muted-foreground, #94a3b8); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.releaseNotesContent { + background: var(--color-background, rgba(0, 0, 0, 0.3)); + border: 1px solid var(--color-border, rgba(255, 255, 255, 0.08)); + border-radius: var(--radius-md, 8px); + padding: 1rem; + font-size: 0.82rem; + line-height: 1.5; + color: var(--color-foreground, #cbd5e1); + max-height: 180px; + overflow-y: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +/* Progress & Installation State */ +.progressSection { + display: flex; + flex-direction: column; + gap: 0.875rem; + padding: 1.25rem; + background: rgba(0, 0, 0, 0.3); + border: 1px solid rgba(99, 102, 241, 0.2); + border-radius: var(--radius-md, 8px); +} + +.progressHeader { + display: flex; + align-items: center; + justify-content: space-between; +} + +.progressStatus { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.82rem; + font-weight: 600; + color: var(--color-foreground, #ffffff); +} + +.progressPercent { + font-family: var(--font-mono, monospace); + font-size: 0.9rem; + font-weight: 700; + color: #6366f1; +} + +.track { + width: 100%; + height: 8px; + background: rgba(255, 255, 255, 0.08); + border-radius: 9999px; + overflow: hidden; + position: relative; +} + +.fill { + height: 100%; + background: linear-gradient(90deg, #6366f1 0%, #10b981 100%); + border-radius: 9999px; + transition: width 0.3s ease; + box-shadow: 0 0 12px rgba(16, 185, 129, 0.5); +} + +.progressSubInfo { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 0.72rem; + color: var(--color-muted-foreground, #94a3b8); + font-family: var(--font-mono, monospace); +} + +.restartAlert { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem; + background: rgba(16, 185, 129, 0.1); + border: 1px solid rgba(16, 185, 129, 0.3); + border-radius: var(--radius-md, 8px); + color: #10b981; + font-size: 0.82rem; + font-weight: 600; + animation: pulseGlow 2s infinite ease-in-out; +} + +.errorBox { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.875rem 1rem; + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.3); + border-radius: var(--radius-md, 8px); + color: #ef4444; + font-size: 0.8rem; +} + +.footer { + padding: 1rem 1.5rem; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.75rem; + border-top: 1px solid var(--color-border, rgba(255, 255, 255, 0.08)); + background: rgba(0, 0, 0, 0.2); +} + +.btnSecondary { + padding: 0.55rem 1.1rem; + border-radius: var(--radius-md, 6px); + border: 1px solid var(--color-border, rgba(255, 255, 255, 0.15)); + background: transparent; + color: var(--color-foreground, #ffffff); + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; +} + +.btnSecondary:hover { + background: var(--color-secondary, rgba(255, 255, 255, 0.08)); +} + +.btnPrimary { + padding: 0.55rem 1.25rem; + border-radius: var(--radius-md, 6px); + border: none; + background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%); + color: #ffffff; + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 0.5rem; + box-shadow: 0 4px 14px rgba(99, 102, 241, 0.4); + transition: all 0.15s ease; +} + +.btnPrimary:hover:not(:disabled) { + background: linear-gradient(135deg, #4f46e5 0%, #4338ca 100%); + transform: translateY(-1px); + box-shadow: 0 6px 18px rgba(99, 102, 241, 0.5); +} + +.btnPrimary:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slideUp { + from { opacity: 0; transform: translateY(20px) scale(0.97); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +@keyframes pulseGlow { + 0%, 100% { border-color: rgba(16, 185, 129, 0.3); box-shadow: 0 0 10px rgba(16, 185, 129, 0.1); } + 50% { border-color: rgba(16, 185, 129, 0.6); box-shadow: 0 0 20px rgba(16, 185, 129, 0.3); } +} diff --git a/frontend/src/features/releases/components/UpdateModal.tsx b/frontend/src/features/releases/components/UpdateModal.tsx new file mode 100644 index 0000000..4854b88 --- /dev/null +++ b/frontend/src/features/releases/components/UpdateModal.tsx @@ -0,0 +1,333 @@ +import React, { useState, useEffect, useRef } from "react"; +import { + DownloadCloud, + Sparkles, + ArrowRight, + X, + AlertCircle, + CheckCircle2, + Loader2, + RefreshCw, +} from "lucide-react"; +import type { + AppUpdateInfoResponse, + UpdateDownloadProgressResponse, +} from "~types/api"; +import { releasesApi } from "../api/releasesApi"; +import styles from "./UpdateModal.module.css"; + +interface UpdateModalProps { + isOpen: boolean; + onClose: () => void; + updateInfo: AppUpdateInfoResponse | null; +} + +const formatBytes = (bytes?: number): string => { + if (!bytes || bytes <= 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; +}; + +export const UpdateModal: React.FC = ({ + isOpen, + onClose, + updateInfo, +}) => { + const [isDownloading, setIsDownloading] = useState(false); + const [progress, setProgress] = useState(null); + const [streamError, setStreamError] = useState(null); + + const cleanupRef = useRef<(() => void) | null>(null); + const modalRef = useRef(null); + + useEffect(() => { + return () => { + if (cleanupRef.current) { + cleanupRef.current(); + } + }; + }, []); + + // Focus trap: save previously focused element, move focus to modal on open, + // restore on close/unmount, and keep Tab navigation inside the modal. + useEffect(() => { + if (!isOpen) return; + + const previouslyFocused = document.activeElement as HTMLElement | null; + + // Move focus to the modal container on open + modalRef.current?.focus(); + + const getFocusableElements = (): HTMLElement[] => { + if (!modalRef.current) return []; + return Array.from( + modalRef.current.querySelectorAll( + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' + ) + ).filter((el) => !el.closest("[aria-hidden='true']")); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape" && !isDownloading && progress?.status !== "INSTALLING") { + onClose(); + return; + } + + if (e.key === "Tab") { + const focusable = getFocusableElements(); + if (focusable.length === 0) { + e.preventDefault(); + return; + } + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + if (e.shiftKey) { + // Treat the modal container itself as the lower boundary so that + // Shift+Tab from the very first focusable position (or from the + // modal container that receives initial focus) wraps to the last. + if ( + document.activeElement === first || + document.activeElement === modalRef.current + ) { + e.preventDefault(); + last.focus(); + } + } else { + if (document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + previouslyFocused?.focus(); + }; + }, [isOpen, isDownloading, progress?.status, onClose]); + + if (!isOpen || !updateInfo) return null; + + const handleStartUpdate = () => { + setIsDownloading(true); + setStreamError(null); + setProgress({ + status: "DOWNLOADING", + percentage: 0, + downloadedBytes: 0, + totalBytes: updateInfo.downloadSizeInBytes || 0, + }); + + if (cleanupRef.current) { + cleanupRef.current(); + } + + cleanupRef.current = releasesApi.streamDownloadAndInstall( + (data) => { + setProgress(data); + if (data.status === "FAILED") { + setIsDownloading(false); + setStreamError(data.errorMessage || "Update download failed."); + } + }, + (errorMsg) => { + setIsDownloading(false); + setStreamError(errorMsg); + } + ); + }; + + const isInstallingOrCompleted = + progress?.status === "INSTALLING" || progress?.status === "COMPLETED"; + + return ( +
{ + if (!isDownloading && !isInstallingOrCompleted) { + onClose(); + } + }} + > +
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="update-modal-title" + > + {/* Header */} +
+
+
+ +
+
+

+ SOFTWARE UPDATE AVAILABLE +

+

+ A new version of Devaulty is ready to download and install. +

+
+
+ {!isDownloading && !isInstallingOrCompleted && ( + + )} +
+ + {/* Body */} +
+ {/* Version badge row */} +
+ + Current: v{updateInfo.currentVersion} + + + + New: v{updateInfo.latestVersion} + +
+ + {/* Release Notes */} + {updateInfo.releaseNotes && !isDownloading && !isInstallingOrCompleted && ( +
+ + {updateInfo.releaseTitle ? updateInfo.releaseTitle : "RELEASE NOTES"} + +
+ {updateInfo.releaseNotes} +
+
+ )} + + {/* Stream Error Box */} + {streamError && ( +
+ + {streamError} +
+ )} + + {/* Download Progress Bar Section */} + {isDownloading && ( +
+
+
+ {isInstallingOrCompleted ? ( + <> + + INSTALLING UPDATE... + + ) : ( + <> + + DOWNLOADING INSTALLER... + + )} +
+ + {progress?.percentage || 0}% + +
+ +
+
+
+ +
+ + {formatBytes(progress?.downloadedBytes)} /{" "} + {formatBytes(progress?.totalBytes || updateInfo.downloadSizeInBytes)} + + + {isInstallingOrCompleted + ? "Executing Native Installer..." + : `${progress?.percentage || 0}% Completed`} + +
+
+ )} + + {/* Restart Notification Notice */} + {isInstallingOrCompleted && ( +
+ +
+ Update successfully downloaded! +
+ Devaulty is now applying the update and will restart automatically in a few seconds. +
+
+
+ )} +
+ + {/* Footer Actions */} +
+ {!isDownloading && !isInstallingOrCompleted ? ( + <> + + + + ) : isInstallingOrCompleted ? ( +
+ + Preparing restart... +
+ ) : ( + + )} +
+
+
+ ); +}; diff --git a/frontend/src/features/releases/hooks/useReleases.ts b/frontend/src/features/releases/hooks/useReleases.ts new file mode 100644 index 0000000..1bd80b5 --- /dev/null +++ b/frontend/src/features/releases/hooks/useReleases.ts @@ -0,0 +1,32 @@ +import { useQuery, useMutation } from "@tanstack/react-query"; +import { releasesApi } from "../api/releasesApi"; + +export const releaseKeys = { + version: ["releases", "current-version"] as const, + check: ["releases", "check"] as const, +}; + +export const useCurrentVersionQuery = () => { + return useQuery({ + queryKey: releaseKeys.version, + queryFn: releasesApi.getCurrentVersion, + staleTime: 1000 * 60 * 60, // 1 hour + retry: 1, + }); +}; + +export const useCheckUpdatesQuery = (enabled = true) => { + return useQuery({ + queryKey: releaseKeys.check, + queryFn: releasesApi.checkUpdates, + enabled, + staleTime: 1000 * 60 * 15, // 15 minutes + retry: false, + }); +}; + +export const useCheckUpdatesMutation = () => { + return useMutation({ + mutationFn: releasesApi.checkUpdates, + }); +}; diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 7eb6a34..61d780b 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -357,3 +357,29 @@ export interface PagedModelCredentialSummaryResponse { page: PageMetadata; } +// Release / Auto-Updater Models +export interface CurrentVersionResponse { + actualVersion: string; +} + +export interface AppUpdateInfoResponse { + updateAvailable: boolean; + currentVersion: string; + latestVersion: string; + releaseTitle?: string; + releaseNotes?: string; + downloadUrl?: string; + downloadSizeInBytes?: number; + publishedAt?: string; +} + +export type UpdateDownloadStatus = "DOWNLOADING" | "COMPLETED" | "FAILED" | "INSTALLING"; + +export interface UpdateDownloadProgressResponse { + status: UpdateDownloadStatus; + percentage: number; + downloadedBytes: number; + totalBytes: number; + errorMessage?: string; +} + diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index d6348cf..8a2e0c1 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -26,4 +26,12 @@ export default defineConfig({ '~features': path.resolve(__dirname, './src/features'), }, }, + server: { + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true, + }, + }, + }, })