Compare commits
7 commits
21deba8c39
...
195cbe8f7c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
195cbe8f7c | ||
|
|
d6a53dcb47 | ||
|
|
b131adfad0 | ||
|
|
49ce1b17ac | ||
|
|
7b555b7c9b | ||
|
|
42a1b47348 | ||
|
|
8b39d0345b |
16 changed files with 688 additions and 87 deletions
44
INSTALL.md
44
INSTALL.md
|
|
@ -1,22 +1,18 @@
|
|||
# Installing WoW Backup
|
||||
|
||||
This guide provides step-by-step instructions for installing and running **WoW Backup** on macOS, Windows, and Linux.
|
||||
|
||||
---
|
||||
|
||||
## Download
|
||||
|
||||
Download the appropriate installer for your platform from the **[GitHub Releases](https://github.com/rukira/wow-backup/releases)** page:
|
||||
Download the appropriate installer for your platform from the **[latest release](https://git.asarius.site/rukira/wow-backup/releases)**:
|
||||
|
||||
| Platform | Package Format | Installer File Pattern |
|
||||
|---|---|---|
|
||||
|--------------------------|---|---|
|
||||
| **macOS** | DMG Disk Image | `WoW Backup-<version>.dmg` |
|
||||
| **Windows** | Windows Installer (MSI) | `WoW Backup-<version>.msi` |
|
||||
| **Linux** | Debian Package (DEB) | `wow-backup_<version>_amd64.deb` |
|
||||
| **Linux** (Experimental) | Debian Package (DEB) | `wow-backup_<version>_amd64.deb` |
|
||||
|
||||
---
|
||||
|
||||
## macOS Installation
|
||||
## macOS
|
||||
|
||||
1. Download the `.dmg` file from the latest release.
|
||||
2. Double-click the `.dmg` file to mount the disk image.
|
||||
|
|
@ -27,20 +23,14 @@ Download the appropriate installer for your platform from the **[GitHub Releases
|
|||
Because WoW Backup is distributed without Apple Developer ID code-signing notarization, macOS Gatekeeper may show a warning on first launch (*"WoW Backup cannot be opened because the developer cannot be verified"* or *"Apple could not verify that WoW Backup is free of malware"*).
|
||||
|
||||
To launch the app for the first time:
|
||||
|
||||
- **Option A (Finder)**:
|
||||
1. Open Finder and navigate to `/Applications`.
|
||||
2. Right-click (or Control-click) on **WoW Backup.app** and choose **Open**.
|
||||
3. In the security popup, click **Open**.
|
||||
- **Option B (Terminal)**:
|
||||
Run the following command in Terminal to clear the quarantine attribute:
|
||||
```shell
|
||||
xattr -d com.apple.quarantine "/Applications/WoW Backup.app"
|
||||
```
|
||||
1. Open **System Settings**
|
||||
2. Navigate to **Security & Privacy**.
|
||||
3. Click **Open Anyway** under **Allow apps downloaded from**.
|
||||
4. Restart WoW Backup.
|
||||
|
||||
---
|
||||
|
||||
## Windows Installation
|
||||
## Windows
|
||||
|
||||
1. Download the `.msi` installer from the latest release.
|
||||
2. Double-click the `.msi` file to start the installation wizard.
|
||||
|
|
@ -54,7 +44,7 @@ If Windows Defender SmartScreen flags the installer (*"Windows protected your PC
|
|||
|
||||
---
|
||||
|
||||
## Linux Installation
|
||||
## Linux (Experimental)
|
||||
|
||||
1. Download the `.deb` package from the latest release.
|
||||
2. Open your terminal in the download folder and install the package:
|
||||
|
|
@ -68,17 +58,3 @@ If Windows Defender SmartScreen flags the installer (*"Windows protected your PC
|
|||
```
|
||||
3. Launch **WoW Backup** from your desktop application launcher or run `wowbackup` in the terminal.
|
||||
|
||||
---
|
||||
|
||||
## First-Time Setup & Overview
|
||||
|
||||
Once started, WoW Backup runs discreetly in the system tray / menu bar:
|
||||
|
||||
1. **Accessing Dashboard**: Click the tray icon in the macOS menu bar or Windows/Linux system tray to open the application window.
|
||||
2. **Settings Configuration**:
|
||||
- Navigate to **Settings** (gear icon).
|
||||
- Set your **World of Warcraft Installation Folder** (e.g. `_retail_` or `_classic_`).
|
||||
- Select your **Backup Destination Folder**.
|
||||
- Set your preferred **Daily Backup Time**, **Retention Policy** (number of backups to keep), and **Compression Mode** (`ZIP` or folder replication).
|
||||
- Optionally enable **Launch at Startup** so backups occur automatically without manual intervention.
|
||||
3. **Lifecycle**: Closing the main window hides it to the tray while keeping the scheduler running. To quit the application completely, select **Quit** from the tray menu.
|
||||
|
|
|
|||
24
LICENSE
Normal file
24
LICENSE
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
This is free and unencumbered software released into the public domain.
|
||||
|
||||
Anyone is free to copy, modify, publish, use, compile, sell, or
|
||||
distribute this software, either in source code form or as a compiled
|
||||
binary, for any purpose, commercial or non-commercial, and by any
|
||||
means.
|
||||
|
||||
In jurisdictions that recognize copyright laws, the author or authors
|
||||
of this software dedicate any and all copyright interest in the
|
||||
software to the public domain. We make this dedication for the benefit
|
||||
of the public at large and to the detriment of our heirs and
|
||||
successors. We intend this dedication to be an overt act of
|
||||
relinquishment in perpetuity of all present and future rights to this
|
||||
software under copyright law.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
||||
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
For more information, please refer to <https://unlicense.org>
|
||||
66
README.md
66
README.md
|
|
@ -1,22 +1,32 @@
|
|||
# WoW Backup
|
||||
|
||||
A lightweight, native-feeling desktop application built with Kotlin and Compose Multiplatform to automatically backup and restore World of Warcraft configuration data (WTF and Interface directories) on macOS and Windows.
|
||||
A lightweight, native-feeling desktop application to automatically backup World of Warcraft configuration data (WTF and Interface directories).
|
||||
|
||||
## Overview
|
||||
World of Warcraft stores all UI configurations, macros, keybindings, and addon data in the `WTF/` and `Interface/` folders. Losing this data due to game corruption, accidental deletion, or addon errors can mean losing years of customized setups. **WoW Backup** runs discreetly in the system tray, and automatically performs scheduled, timestamped backups (optionally compressed as ZIP archives).
|
||||
|
||||
World of Warcraft stores all UI configurations, macros, keybindings, and addon saved variables in the `WTF/` and `Interface/` folders. Losing this data due to game corruption, accidental deletion, or addon errors can mean losing years of customized setups. **WoW Backup** runs discreetly in the system tray, automatically creates scheduled, timestamped backups (optionally compressed as ZIP archives), and will provide seamless one-click restoration.
|
||||
## Installation & Download
|
||||
|
||||
Pre-built binaries for **macOS**, **Windows**, and **Linux** are provided on each release.
|
||||
|
||||
👉 **See [here](INSTALL.md)** for download links and step-by-step installation instructions.
|
||||
|
||||
## Key Features
|
||||
|
||||
- 🛡️ **System Tray Native Life Cycle**: Lives in the menu bar / system tray with custom template icons matching light/dark OS appearances. Closing the window hides to tray; quitting is explicit.
|
||||
- ⚙️ **Smart Auto-Configuration**: Automatically detects standard WoW install locations (supporting retail and classic directories) and validates folder permissions.
|
||||
- ⏰ **Automated & Scheduled Backups**: Background scheduler powered by Cardiologist ensures dependable daily backups at a user-defined time.
|
||||
- 📦 **Compression & History Management**: Supports both folder replication and ZIP compression, with automatic pruning of backups beyond the configured retention limit (1–30 backups).
|
||||
- 🎮 **Game State Detection**: Checks for active WoW processes before running backups to prevent file-locking conflicts, with optional "force backup" override and safety confirmation.
|
||||
- 🔔 **Native OS Notifications**: Notifies the user when backups start, succeed, fail, or are skipped due to game execution.
|
||||
- ⏰ **Automated & Scheduled Backups**: Background scheduler ensures regular backups at a user-defined time.
|
||||
- 📦 **Compression & History Management**: Supports both folder replication and ZIP compression, with automatic pruning of backups beyond the configured limit.
|
||||
- 🎮 **Game State Detection**: Checks for running WoW processes before running backups to prevent file-locking conflicts.
|
||||
- 🔔 **Native OS Notifications**: Notifies the user when backups start, succeed, fail, or are skipped.
|
||||
- 🚀 **Launch at Startup**: Integrated with macOS LaunchAgents (`~/Library/LaunchAgents/`) and Windows Registry (`HKCU\...\Run`).
|
||||
- 🎨 **Modern Theming & Customization**: Dynamic Material 3 theming supporting System/Light/Dark modes and configurable accent colors (via MaterialKolor).
|
||||
- 🔄 **Restore System (In Progress)**: Interactive restore interface to safely roll back WTF and Interface configurations.
|
||||
- 🎨 **Theming & Customization**: Dynamic theming supporting Light/Dark mode, with configurable accent colors.
|
||||
|
||||
---
|
||||
|
||||
## Application Data Locations
|
||||
The app uses a configuration file to store user preferences. This file and runtime logs are stored in the following locations:
|
||||
- **macOS**: `~/Library/Application Support/WoWBackup/` (Logs: `.../logs/wowbackup.log`)
|
||||
- **Windows**: `%APPDATA%\WoWBackup\` (Logs: `...\logs\wowbackup.log`)
|
||||
- **Linux**: `~/.config/WoWBackup/` (Logs: `.../logs/wowbackup.log`)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -24,22 +34,14 @@ World of Warcraft stores all UI configurations, macros, keybindings, and addon s
|
|||
|
||||
- **Language & Runtime**: Kotlin Multiplatform targeting Desktop (JVM, Java 17+)
|
||||
- **UI Framework**: JetBrains Compose Multiplatform with Material 3 Design
|
||||
- **Architecture**: MVI / MVVM with Kotlin Coroutines and StateFlow
|
||||
- **Scheduling**: `io.github.kevincianfarini.cardiologist` for drift-free daily execution
|
||||
- **Logging**: `logback-classic` + `kotlin-logging-jvm` with daily rotation and size archiving in app data directories
|
||||
- **Serialization**: `kotlinx-serialization-json` for typed JSON configuration persistence
|
||||
- **Architecture**: MVVM with Kotlin Coroutines
|
||||
- **Scheduling**: `io.github.kevincianfarini.cardiologist`
|
||||
- **Logging**: `logback-classic` + `kotlin-logging-jvm` with daily rotation and archiving
|
||||
- **Serialization**: Settings stored in a JSON file using `kotlinx-serialization-json`
|
||||
- **Theming**: `com.materialkolor:material-kolor` for dynamic palette generation
|
||||
|
||||
---
|
||||
|
||||
## Installation & Download
|
||||
|
||||
Pre-built binaries for macOS, Windows, and Linux are automatically packaged and published on each release.
|
||||
|
||||
👉 **See [INSTALL.md](INSTALL.md)** for download links, step-by-step installation instructions, Gatekeeper/SmartScreen bypass guidance, and initial setup notes.
|
||||
|
||||
---
|
||||
|
||||
## Build & Run
|
||||
|
||||
### Prerequisites
|
||||
|
|
@ -71,22 +73,6 @@ Pre-built binaries for macOS, Windows, and Linux are automatically packaged and
|
|||
|
||||
---
|
||||
|
||||
## Feature Implementation Status
|
||||
## License
|
||||
|
||||
| Feature | Plan Document | Status | Description |
|
||||
|---|---|---|---|
|
||||
| **0. Foundation** | `docs/plans/feature-0-foundation.md` | ✅ **Completed** | Architecture, config persistence, logging, platform abstractions |
|
||||
| **1. System Tray** | `docs/plans/feature-1-system-tray.md` | ✅ **Completed** | Tray icon, menu, tray-first lifecycle, window alignment |
|
||||
| **2. Configuration** | `docs/plans/feature-2-configuration.md` | ✅ **Completed** | Full settings UI, path validation, native file dialogs, startup manager |
|
||||
| **3. Backup Engine** | `docs/plans/feature-3-backup.md` | ✅ **Completed** | Parallel copy, ZIP compression, scheduler, history pruning, notifications |
|
||||
| **4. Status Screen** | `docs/plans/feature-4-status.md` | ✅ **Completed** | Live status dashboard, progress indicator, manual backup trigger, shortcuts |
|
||||
| **5. Restore Screen** | `docs/plans/feature-5-restore.md` | 🚧 **Pending** | Backup list browser, ZIP extraction, confirmation flow, safety checks |
|
||||
| **6. Release Pipeline** | `docs/plans/feature-6-release-pipeline.md` | ✅ **Completed** | GitHub Actions multi-platform release pipeline & packaging automation |
|
||||
|
||||
---
|
||||
|
||||
## Application Data Locations
|
||||
|
||||
- **macOS**: `~/Library/Application Support/WoWBackup/` (Logs: `.../logs/wowbackup.log`)
|
||||
- **Windows**: `%APPDATA%\WoWBackup\` (Logs: `...\logs\wowbackup.log`)
|
||||
- **Linux**: `~/.config/WoWBackup/` (Logs: `.../logs/wowbackup.log`)
|
||||
This project is released into the public domain. See the [LICENSE](LICENSE) file or [unlicense.org](https://unlicense.org/) for details.
|
||||
|
|
|
|||
|
|
@ -31,16 +31,21 @@ kotlin {
|
|||
implementation(libs.logback.classic)
|
||||
implementation(libs.material.kolor)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
implementation(libs.kotlinx.coroutinesTest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val appVersion = (project.findProperty("appVersion") as? String)?.takeIf { it.isNotBlank() } ?: "1.0.0"
|
||||
val appVersion = providers.gradleProperty("appVersion").orNull?.takeIf { it.isNotBlank() } ?: "0.0.1"
|
||||
|
||||
val generatedSrcDir = layout.buildDirectory.dir("generated/src/jvmMain/kotlin")
|
||||
|
||||
val generateBuildConfig by tasks.registering {
|
||||
val outputDir = generatedSrcDir
|
||||
val version = appVersion
|
||||
inputs.property("version", version)
|
||||
outputs.dir(outputDir)
|
||||
doLast {
|
||||
val dir = outputDir.get().asFile.resolve("com/rukira/wowbackup")
|
||||
|
|
@ -72,6 +77,8 @@ compose.desktop {
|
|||
packageName = "WoW Backup"
|
||||
packageVersion = appVersion
|
||||
|
||||
modules("java.naming", "java.sql", "java.instrument", "jdk.unsupported")
|
||||
|
||||
macOS {
|
||||
bundleID = "com.rukira.wowbackup"
|
||||
iconFile.set(project.file("src/jvmMain/resources/icon.icns"))
|
||||
|
|
|
|||
|
|
@ -12,7 +12,12 @@ import java.io.File
|
|||
|
||||
object LoggingSetup {
|
||||
|
||||
private var initialized = false
|
||||
|
||||
fun init() {
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
|
||||
val logDir = AppDirectories.logsDir
|
||||
val logFile = File(logDir, "wowbackup.log")
|
||||
|
||||
|
|
@ -26,7 +31,7 @@ object LoggingSetup {
|
|||
|
||||
val rollingPolicy = SizeAndTimeBasedRollingPolicy<ILoggingEvent>().apply {
|
||||
this.context = context
|
||||
fileNamePattern = "${logDir}/wowbackup.%d{yyyy-MM-dd}.%i.log.gz"
|
||||
fileNamePattern = "${logDir.absolutePath}/wowbackup.%d{yyyy-MM-dd}.%i.log.gz"
|
||||
setMaxFileSize(FileSize.valueOf("10MB"))
|
||||
maxHistory = 30
|
||||
setTotalSizeCap(FileSize.valueOf("100MB"))
|
||||
|
|
@ -46,5 +51,10 @@ object LoggingSetup {
|
|||
|
||||
val rootLogger = context.getLogger(ch.qos.logback.classic.Logger.ROOT_LOGGER_NAME)
|
||||
rootLogger.addAppender(fileAppender)
|
||||
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
val uncaughtLogger = LoggerFactory.getLogger("UncaughtExceptionHandler")
|
||||
uncaughtLogger.error("Uncaught exception on thread [${thread.name}]", throwable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.rukira.wowbackup.platform
|
|||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import java.awt.Desktop
|
||||
import java.io.File
|
||||
import java.net.URI
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
|
|
@ -15,4 +16,16 @@ object DesktopActions {
|
|||
logger.error(e) { "Failed to open folder: $path" }
|
||||
}
|
||||
}
|
||||
|
||||
fun openUrl(url: String) {
|
||||
try {
|
||||
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
|
||||
Desktop.getDesktop().browse(URI(url))
|
||||
} else {
|
||||
logger.warn { "Desktop browsing is not supported on this platform" }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error(e) { "Failed to open URL: $url" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.rukira.wowbackup.ui.status
|
|||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
|
|
@ -214,12 +215,33 @@ fun StatusScreen(
|
|||
}
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
"WoW Backup v${BuildConfig.VERSION}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
)
|
||||
val update = uiState.updateInfo
|
||||
if (update != null) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
"• Update available (v${update.latestVersion})",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Button(
|
||||
onClick = { DesktopActions.openUrl(update.releaseUrl) },
|
||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp),
|
||||
modifier = Modifier.height(26.dp),
|
||||
) {
|
||||
Text("Download", style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,13 +7,18 @@ import com.rukira.wowbackup.backup.BackupProgress
|
|||
import com.rukira.wowbackup.backup.BackupResult
|
||||
import com.rukira.wowbackup.backup.BackupScheduler
|
||||
import com.rukira.wowbackup.config.ConfigManager
|
||||
import com.rukira.wowbackup.update.UpdateChecker
|
||||
import com.rukira.wowbackup.update.UpdateInfo
|
||||
import com.rukira.wowbackup.update.UpdateService
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
|
|
@ -32,10 +37,25 @@ data class StatusUiState(
|
|||
val backupProgress: BackupProgress? = null,
|
||||
val totalBackups: Int = 0,
|
||||
val backupPath: String? = null,
|
||||
val updateInfo: UpdateInfo? = null,
|
||||
)
|
||||
|
||||
@OptIn(kotlin.time.ExperimentalTime::class)
|
||||
class StatusViewModel : ViewModel() {
|
||||
class StatusViewModel(
|
||||
private val updateService: UpdateService = UpdateChecker(),
|
||||
) : ViewModel() {
|
||||
|
||||
private val updateInfo = MutableStateFlow<UpdateInfo?>(null)
|
||||
|
||||
init {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
updateInfo.value = updateService.checkForUpdate()
|
||||
} catch (e: Exception) {
|
||||
// Silently handle any uncaught exception
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only recomputes when backupPath or lastBackupResult changes — not on every progress tick
|
||||
private val totalBackups = combine(
|
||||
|
|
@ -53,7 +73,8 @@ class StatusViewModel : ViewModel() {
|
|||
ConfigManager.config,
|
||||
BackupScheduler.state,
|
||||
totalBackups,
|
||||
) { config, scheduler, backupCount ->
|
||||
updateInfo,
|
||||
) { config, scheduler, backupCount, update ->
|
||||
StatusUiState(
|
||||
isConfigured = config.isConfigured,
|
||||
lastBackupTime = scheduler.lastBackupTime?.let { formatRelativeTime(it) },
|
||||
|
|
@ -66,6 +87,7 @@ class StatusViewModel : ViewModel() {
|
|||
backupProgress = scheduler.currentProgress,
|
||||
totalBackups = backupCount,
|
||||
backupPath = config.backupPath,
|
||||
updateInfo = update,
|
||||
)
|
||||
}.flowOn(Dispatchers.IO).stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), StatusUiState())
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
package com.rukira.wowbackup.update
|
||||
|
||||
data class SemVer(
|
||||
val major: Int,
|
||||
val minor: Int = 0,
|
||||
val patch: Int = 0,
|
||||
val preRelease: String? = null,
|
||||
) : Comparable<SemVer> {
|
||||
|
||||
override fun compareTo(other: SemVer): Int {
|
||||
if (this.major != other.major) return this.major.compareTo(other.major)
|
||||
if (this.minor != other.minor) return this.minor.compareTo(other.minor)
|
||||
if (this.patch != other.patch) return this.patch.compareTo(other.patch)
|
||||
|
||||
// A version without a pre-release is greater than one with a pre-release
|
||||
return when {
|
||||
this.preRelease == null && other.preRelease == null -> 0
|
||||
this.preRelease == null && other.preRelease != null -> 1
|
||||
this.preRelease != null && other.preRelease == null -> -1
|
||||
else -> this.preRelease!!.compareTo(other.preRelease!!)
|
||||
}
|
||||
}
|
||||
|
||||
fun isNewerThan(other: SemVer): Boolean = this > other
|
||||
|
||||
override fun toString(): String {
|
||||
val base = "$major.$minor.$patch"
|
||||
return if (preRelease != null) "$base-$preRelease" else base
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun parseOrNull(version: String?): SemVer? {
|
||||
if (version.isNullOrBlank()) return null
|
||||
|
||||
val cleaned = version.trim().removePrefix("v").removePrefix("V").trim()
|
||||
if (cleaned.isEmpty()) return null
|
||||
|
||||
val parts = cleaned.split("-", limit = 2)
|
||||
val core = parts[0]
|
||||
val preRelease = parts.getOrNull(1)?.takeIf { it.isNotBlank() }
|
||||
|
||||
val numParts = core.split(".")
|
||||
if (numParts.isEmpty() || numParts.size > 3) return null
|
||||
|
||||
val major = numParts[0].toIntOrNull() ?: return null
|
||||
val minor = if (numParts.size > 1) numParts[1].toIntOrNull() ?: return null else 0
|
||||
val patch = if (numParts.size > 2) numParts[2].toIntOrNull() ?: return null else 0
|
||||
|
||||
return SemVer(major = major, minor = minor, patch = patch, preRelease = preRelease)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
package com.rukira.wowbackup.update
|
||||
|
||||
import com.rukira.wowbackup.BuildConfig
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.net.URI
|
||||
import java.net.http.HttpClient
|
||||
import java.net.http.HttpRequest
|
||||
import java.net.http.HttpResponse
|
||||
import java.time.Duration
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
@Serializable
|
||||
internal data class ReleaseDto(
|
||||
@SerialName("tag_name") val tagName: String = "",
|
||||
val name: String? = null,
|
||||
@SerialName("html_url") val htmlUrl: String? = null,
|
||||
)
|
||||
|
||||
data class UpdateInfo(
|
||||
val latestVersion: String,
|
||||
val releaseUrl: String,
|
||||
)
|
||||
|
||||
interface UpdateService {
|
||||
suspend fun checkForUpdate(currentVersion: String = BuildConfig.VERSION): UpdateInfo?
|
||||
}
|
||||
|
||||
class UpdateChecker(
|
||||
private val apiUrl: String = DEFAULT_API_URL,
|
||||
private val fallbackUrl: String = DEFAULT_FALLBACK_URL,
|
||||
private val httpClient: HttpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(5))
|
||||
.build(),
|
||||
) : UpdateService {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
|
||||
override suspend fun checkForUpdate(currentVersion: String): UpdateInfo? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(apiUrl))
|
||||
.timeout(Duration.ofSeconds(5))
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", "WoWBackup/${BuildConfig.VERSION}")
|
||||
.GET()
|
||||
.build()
|
||||
|
||||
val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString())
|
||||
if (response.statusCode() != 200) {
|
||||
logger.warn { "Update check received non-200 status code: ${response.statusCode()}" }
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
val body = response.body()
|
||||
if (body.isNullOrBlank()) {
|
||||
logger.warn { "Update check received empty response body" }
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
val release = json.decodeFromString<ReleaseDto>(body)
|
||||
val remoteSemVer = SemVer.parseOrNull(release.tagName) ?: run {
|
||||
logger.warn { "Failed to parse remote release tag: '${release.tagName}'" }
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
val currentSemVer = SemVer.parseOrNull(currentVersion) ?: run {
|
||||
logger.warn { "Failed to parse current version: '$currentVersion'" }
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
if (remoteSemVer.isNewerThan(currentSemVer)) {
|
||||
val releaseUrl = release.htmlUrl?.takeIf { it.isNotBlank() } ?: fallbackUrl
|
||||
val cleanTag = release.tagName.trim().removePrefix("v").removePrefix("V")
|
||||
logger.info { "New update available: v$cleanTag (current: v$currentVersion). URL: $releaseUrl" }
|
||||
UpdateInfo(
|
||||
latestVersion = cleanTag,
|
||||
releaseUrl = releaseUrl,
|
||||
)
|
||||
} else {
|
||||
logger.debug { "App is up to date: current=$currentVersion, remote=${release.tagName}" }
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn(e) { "Failed to check for updates: ${e.message}" }
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_API_URL = "https://git.asarius.site/api/v1/repos/rukira/wow-backup/releases/latest"
|
||||
const val DEFAULT_FALLBACK_URL = "https://git.asarius.site/rukira/wow-backup/releases"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package com.rukira.wowbackup
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class BuildConfigTest {
|
||||
@Test
|
||||
fun testBuildConfigVersionIsNotEmpty() {
|
||||
assertTrue(BuildConfig.VERSION.isNotBlank(), "BuildConfig.VERSION should not be blank")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
package com.rukira.wowbackup.logging
|
||||
|
||||
import com.rukira.wowbackup.platform.AppDirectories
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import java.io.File
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class LoggingSetupTest {
|
||||
|
||||
private val testLogger = KotlinLogging.logger {}
|
||||
|
||||
@Test
|
||||
fun testLoggingSetupInitializesAndWritesToFile() {
|
||||
LoggingSetup.init()
|
||||
|
||||
val logsDir = AppDirectories.logsDir
|
||||
assertTrue(logsDir.exists(), "Logs directory should exist")
|
||||
assertTrue(logsDir.isDirectory, "Logs directory should be a directory")
|
||||
|
||||
val testMessage = "Test log entry for unit verification - ${System.currentTimeMillis()}"
|
||||
testLogger.info { testMessage }
|
||||
|
||||
val logFile = File(logsDir, "wowbackup.log")
|
||||
assertTrue(logFile.exists(), "wowbackup.log file should exist")
|
||||
|
||||
val content = logFile.readText()
|
||||
assertTrue(content.contains(testMessage), "wowbackup.log should contain the logged message")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAppDirectoriesLogsDir() {
|
||||
val appDataDir = AppDirectories.appDataDir
|
||||
val logsDir = AppDirectories.logsDir
|
||||
|
||||
assertTrue(logsDir.absolutePath.startsWith(appDataDir.absolutePath), "Logs dir should be inside app data dir")
|
||||
assertTrue(logsDir.name == "logs", "Logs directory should be named 'logs'")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
package com.rukira.wowbackup.ui.status
|
||||
|
||||
import com.rukira.wowbackup.update.UpdateInfo
|
||||
import com.rukira.wowbackup.update.UpdateService
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class StatusViewModelTest {
|
||||
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
|
||||
@BeforeTest
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testViewModelReceivesUpdateInfo() = runTest(testDispatcher) {
|
||||
val mockService = object : UpdateService {
|
||||
override suspend fun checkForUpdate(currentVersion: String): UpdateInfo {
|
||||
return UpdateInfo(
|
||||
latestVersion = "1.0.2",
|
||||
releaseUrl = "https://git.asarius.site/rukira/wow-backup/releases/tag/v1.0.2",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val viewModel = StatusViewModel(updateService = mockService)
|
||||
advanceUntilIdle()
|
||||
|
||||
val state = viewModel.state.first { it.updateInfo != null }
|
||||
assertNotNull(state.updateInfo)
|
||||
assertEquals("1.0.2", state.updateInfo!!.latestVersion)
|
||||
assertEquals("https://git.asarius.site/rukira/wow-backup/releases/tag/v1.0.2", state.updateInfo!!.releaseUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testViewModelHandlesNoUpdate() = runTest(testDispatcher) {
|
||||
val mockService = object : UpdateService {
|
||||
override suspend fun checkForUpdate(currentVersion: String): UpdateInfo? = null
|
||||
}
|
||||
|
||||
val viewModel = StatusViewModel(updateService = mockService)
|
||||
advanceUntilIdle()
|
||||
|
||||
val state = viewModel.state.value
|
||||
assertNull(state.updateInfo)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
package com.rukira.wowbackup.update
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SemVerTest {
|
||||
|
||||
@Test
|
||||
fun testParseStandardVersions() {
|
||||
val v1 = SemVer.parseOrNull("1.0.0")
|
||||
assertNotNull(v1)
|
||||
assertEquals(1, v1.major)
|
||||
assertEquals(0, v1.minor)
|
||||
assertEquals(0, v1.patch)
|
||||
assertNull(v1.preRelease)
|
||||
|
||||
val v2 = SemVer.parseOrNull("v1.2.3")
|
||||
assertNotNull(v2)
|
||||
assertEquals(1, v2.major)
|
||||
assertEquals(2, v2.minor)
|
||||
assertEquals(3, v2.patch)
|
||||
assertNull(v2.preRelease)
|
||||
|
||||
val v3 = SemVer.parseOrNull("V2.0")
|
||||
assertNotNull(v3)
|
||||
assertEquals(2, v3.major)
|
||||
assertEquals(0, v3.minor)
|
||||
assertEquals(0, v3.patch)
|
||||
|
||||
val v4 = SemVer.parseOrNull("0.0.1")
|
||||
assertNotNull(v4)
|
||||
assertEquals(0, v4.major)
|
||||
assertEquals(0, v4.minor)
|
||||
assertEquals(1, v4.patch)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testParsePreReleaseVersions() {
|
||||
val v = SemVer.parseOrNull("1.0.0-beta.1")
|
||||
assertNotNull(v)
|
||||
assertEquals(1, v.major)
|
||||
assertEquals(0, v.minor)
|
||||
assertEquals(0, v.patch)
|
||||
assertEquals("beta.1", v.preRelease)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testParseInvalidVersions() {
|
||||
assertNull(SemVer.parseOrNull(null))
|
||||
assertNull(SemVer.parseOrNull(""))
|
||||
assertNull(SemVer.parseOrNull(" "))
|
||||
assertNull(SemVer.parseOrNull("invalid"))
|
||||
assertNull(SemVer.parseOrNull("1.a.3"))
|
||||
assertNull(SemVer.parseOrNull("1.2.3.4"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testVersionComparison() {
|
||||
val v001 = SemVer.parseOrNull("0.0.1")!!
|
||||
val v100 = SemVer.parseOrNull("1.0.0")!!
|
||||
val v101 = SemVer.parseOrNull("1.0.1")!!
|
||||
val v102 = SemVer.parseOrNull("v1.0.2")!!
|
||||
val v110 = SemVer.parseOrNull("1.1.0")!!
|
||||
val v200 = SemVer.parseOrNull("2.0.0")!!
|
||||
|
||||
assertTrue(v100.isNewerThan(v001))
|
||||
assertTrue(v101.isNewerThan(v100))
|
||||
assertTrue(v102.isNewerThan(v101))
|
||||
assertTrue(v110.isNewerThan(v102))
|
||||
assertTrue(v200.isNewerThan(v110))
|
||||
|
||||
assertFalse(v101.isNewerThan(v102))
|
||||
assertFalse(v102.isNewerThan(SemVer.parseOrNull("1.0.2")!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPreReleaseComparison() {
|
||||
val release = SemVer.parseOrNull("1.0.0")!!
|
||||
val preRelease = SemVer.parseOrNull("1.0.0-alpha")!!
|
||||
val preReleaseBeta = SemVer.parseOrNull("1.0.0-beta")!!
|
||||
|
||||
assertTrue(release.isNewerThan(preRelease))
|
||||
assertFalse(preRelease.isNewerThan(release))
|
||||
assertTrue(preReleaseBeta.isNewerThan(preRelease))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testToString() {
|
||||
assertEquals("1.0.0", SemVer.parseOrNull("1.0.0")?.toString())
|
||||
assertEquals("1.2.0", SemVer.parseOrNull("v1.2")?.toString())
|
||||
assertEquals("1.0.0-rc1", SemVer.parseOrNull("1.0.0-rc1")?.toString())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
package com.rukira.wowbackup.update
|
||||
|
||||
import com.sun.net.httpserver.HttpServer
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.net.InetSocketAddress
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class UpdateCheckerTest {
|
||||
|
||||
private lateinit var server: HttpServer
|
||||
private var serverPort: Int = 0
|
||||
|
||||
@BeforeTest
|
||||
fun setUp() {
|
||||
server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
|
||||
serverPort = server.address.port
|
||||
server.start()
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun tearDown() {
|
||||
server.stop(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUpdateAvailableWhenRemoteIsNewer() = runBlocking {
|
||||
val jsonResponse = """
|
||||
{
|
||||
"id": 5,
|
||||
"tag_name": "v1.0.2",
|
||||
"name": "WoW Backup v1.0.2",
|
||||
"html_url": "https://git.asarius.site/rukira/wow-backup/releases/tag/v1.0.2"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
server.createContext("/releases/latest") { exchange ->
|
||||
exchange.responseHeaders.add("Content-Type", "application/json")
|
||||
val bytes = jsonResponse.toByteArray()
|
||||
exchange.sendResponseHeaders(200, bytes.size.toLong())
|
||||
exchange.responseBody.write(bytes)
|
||||
exchange.close()
|
||||
}
|
||||
|
||||
val checker = UpdateChecker(
|
||||
apiUrl = "http://127.0.0.1:$serverPort/releases/latest",
|
||||
fallbackUrl = "https://git.asarius.site/rukira/wow-backup/releases",
|
||||
)
|
||||
|
||||
val result = checker.checkForUpdate(currentVersion = "1.0.1")
|
||||
assertNotNull(result)
|
||||
assertEquals("1.0.2", result.latestVersion)
|
||||
assertEquals("https://git.asarius.site/rukira/wow-backup/releases/tag/v1.0.2", result.releaseUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNoUpdateWhenUpToDate() = runBlocking {
|
||||
val jsonResponse = """
|
||||
{
|
||||
"id": 5,
|
||||
"tag_name": "v1.0.2",
|
||||
"name": "WoW Backup v1.0.2",
|
||||
"html_url": "https://git.asarius.site/rukira/wow-backup/releases/tag/v1.0.2"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
server.createContext("/releases/latest") { exchange ->
|
||||
exchange.responseHeaders.add("Content-Type", "application/json")
|
||||
val bytes = jsonResponse.toByteArray()
|
||||
exchange.sendResponseHeaders(200, bytes.size.toLong())
|
||||
exchange.responseBody.write(bytes)
|
||||
exchange.close()
|
||||
}
|
||||
|
||||
val checker = UpdateChecker(
|
||||
apiUrl = "http://127.0.0.1:$serverPort/releases/latest",
|
||||
)
|
||||
|
||||
val result = checker.checkForUpdate(currentVersion = "1.0.2")
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNoUpdateWhenRunningNewerVersion() = runBlocking {
|
||||
val jsonResponse = """
|
||||
{
|
||||
"id": 5,
|
||||
"tag_name": "v1.0.2",
|
||||
"name": "WoW Backup v1.0.2",
|
||||
"html_url": "https://git.asarius.site/rukira/wow-backup/releases/tag/v1.0.2"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
server.createContext("/releases/latest") { exchange ->
|
||||
exchange.responseHeaders.add("Content-Type", "application/json")
|
||||
val bytes = jsonResponse.toByteArray()
|
||||
exchange.sendResponseHeaders(200, bytes.size.toLong())
|
||||
exchange.responseBody.write(bytes)
|
||||
exchange.close()
|
||||
}
|
||||
|
||||
val checker = UpdateChecker(
|
||||
apiUrl = "http://127.0.0.1:$serverPort/releases/latest",
|
||||
)
|
||||
|
||||
val result = checker.checkForUpdate(currentVersion = "2.0.0")
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHttpErrorGracefullyReturnsNull() = runBlocking {
|
||||
server.createContext("/releases/latest") { exchange ->
|
||||
exchange.sendResponseHeaders(404, 0)
|
||||
exchange.close()
|
||||
}
|
||||
|
||||
val checker = UpdateChecker(
|
||||
apiUrl = "http://127.0.0.1:$serverPort/releases/latest",
|
||||
)
|
||||
|
||||
val result = checker.checkForUpdate(currentVersion = "1.0.0")
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMalformedJsonGracefullyReturnsNull() = runBlocking {
|
||||
server.createContext("/releases/latest") { exchange ->
|
||||
val bytes = "Not valid JSON".toByteArray()
|
||||
exchange.sendResponseHeaders(200, bytes.size.toLong())
|
||||
exchange.responseBody.write(bytes)
|
||||
exchange.close()
|
||||
}
|
||||
|
||||
val checker = UpdateChecker(
|
||||
apiUrl = "http://127.0.0.1:$serverPort/releases/latest",
|
||||
)
|
||||
|
||||
val result = checker.checkForUpdate(currentVersion = "1.0.0")
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFallbackUrlUsedWhenHtmlUrlMissing() = runBlocking {
|
||||
val jsonResponse = """
|
||||
{
|
||||
"id": 5,
|
||||
"tag_name": "v1.0.2",
|
||||
"name": "WoW Backup v1.0.2"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
server.createContext("/releases/latest") { exchange ->
|
||||
val bytes = jsonResponse.toByteArray()
|
||||
exchange.sendResponseHeaders(200, bytes.size.toLong())
|
||||
exchange.responseBody.write(bytes)
|
||||
exchange.close()
|
||||
}
|
||||
|
||||
val checker = UpdateChecker(
|
||||
apiUrl = "http://127.0.0.1:$serverPort/releases/latest",
|
||||
fallbackUrl = "https://git.asarius.site/rukira/wow-backup/releases",
|
||||
)
|
||||
|
||||
val result = checker.checkForUpdate(currentVersion = "1.0.0")
|
||||
assertNotNull(result)
|
||||
assertEquals("https://git.asarius.site/rukira/wow-backup/releases", result.releaseUrl)
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ materialKolor = "2.0.0"
|
|||
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
|
||||
androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
|
||||
kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
|
||||
kotlinx-coroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
|
||||
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
|
||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
||||
cardiologist = { module = "io.github.kevincianfarini.cardiologist:cardiologist", version.ref = "cardiologist" }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue