added in app update (#319)

This commit is contained in:
AmirHossein Abdolmotallebi 2024-12-26 08:43:19 +03:30 committed by GitHub
parent 8a24d5e06d
commit 4d5a38938c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
47 changed files with 1356 additions and 335 deletions

View File

@ -45,7 +45,7 @@ dependencies {
implementation(libs.androidx.datastore)
implementation(libs.aboutLibraries.core)
implementation(libs.markdownRenderer.core)
implementation(libs.composeFileKit) {
exclude(group = "net.java.dev.jna")
}
@ -180,13 +180,17 @@ buildConfig {
getApplicationPackageName()
}
)
buildConfigField(
"APP_DISPLAY_NAME",
provider { getPrettifiedAppName() }
)
buildConfigField(
"APP_VERSION",
provider { getAppVersionString() }
)
buildConfigField(
"APP_NAME",
provider { getPrettifiedAppName() }
provider { getAppName() }
)
buildConfigField(
"PROJECT_WEBSITE",
@ -200,6 +204,18 @@ buildConfig {
"https://github.com/amir1376/ab-download-manager"
}
)
buildConfigField(
"PROJECT_GITHUB_OWNER",
provider {
"amir1376"
}
)
buildConfigField(
"PROJECT_GITHUB_REPO",
provider {
"ab-download-manager"
}
)
buildConfigField(
"PROJECT_TRANSLATIONS",
provider {

View File

@ -1,2 +1,3 @@
app.config.path="${user.home}/.abdm/config"
app.system.path="${user.home}/.abdm/system"
app.debug="false"

View File

@ -156,7 +156,13 @@ FunctionEnd
Delete "$DESKTOP\${APP_DISPLAY_NAME}.lnk"
!macroend
Function .onInstSuccess
; Check if the installer is running in silent mode
${If} ${Silent}
; In silent mode, always run the app
Call RunMainBinary
${Endif}
FunctionEnd
Section "${APP_DISPLAY_NAME}"
SectionInstType RO

View File

@ -3,13 +3,14 @@
*/
package com.abdownloadmanager.desktop
import com.abdownloadmanager.UpdateManager
import com.abdownloadmanager.desktop.di.Di
import com.abdownloadmanager.desktop.ui.Ui
import com.abdownloadmanager.desktop.utils.*
import com.abdownloadmanager.desktop.utils.singleInstance.*
import com.abdownloadmanager.integration.Integration
import com.abdownloadmanager.utils.DownloadSystem
import com.sun.jna.platform.win32.Advapi32Util
import com.abdownloadmanager.utils.appinfo.PreviousVersion
import ir.amirab.util.platform.Platform
import kotlinx.coroutines.runBlocking
import okio.Path.Companion.toOkioPath
@ -22,6 +23,8 @@ class App : AutoCloseable,
KoinComponent {
private val downloadSystem: DownloadSystem by inject()
private val integration: Integration by inject()
private val previousVersion: PreviousVersion by inject()
private val updateManager: UpdateManager by inject()
//TODO Setup Native Messaging Feature
//private val browserNativeMessaging: NativeMessaging by inject()
@ -34,8 +37,10 @@ class App : AutoCloseable,
runBlocking {
//make sure to not get any dependency until boot the DI Container
Di.boot()
// it's better to organize these list of boot functions in a separate class
integration.boot()
downloadSystem.boot()
previousVersion.boot()
//TODO Setup Native Messaging Feature
//waiting for compose kmp to add multi launcher to nativeDistributions,the PR is already exists but not merger
//or maybe I should use a custom solution
@ -79,7 +84,7 @@ fun main(args: Array<String>) {
appArguments = appArguments,
)
} catch (e: Throwable) {
System.err.println("Fail to start the ${AppInfo.name} app because:")
System.err.println("Fail to start the ${AppInfo.displayName} app because:")
e.printStackTrace()
exitProcess(-1)
}

View File

@ -12,6 +12,7 @@ import com.abdownloadmanager.desktop.pages.home.HomeComponent
import com.abdownloadmanager.desktop.pages.queue.QueuesComponent
import com.abdownloadmanager.desktop.pages.settings.SettingsComponent
import com.abdownloadmanager.desktop.pages.singleDownloadPage.SingleDownloadComponent
import com.abdownloadmanager.desktop.pages.updater.UpdateComponent
import com.abdownloadmanager.desktop.repository.AppRepository
import com.abdownloadmanager.desktop.storage.AppSettingsStorage
import com.abdownloadmanager.desktop.ui.widget.MessageDialogModel
@ -42,6 +43,7 @@ import com.abdownloadmanager.resources.*
import com.abdownloadmanager.utils.DownloadSystem
import com.abdownloadmanager.utils.category.CategoryManager
import com.abdownloadmanager.utils.category.CategorySelectionMode
import com.arkivanov.decompose.childContext
import ir.amirab.downloader.exception.TooManyErrorException
import ir.amirab.downloader.monitor.isDownloadActiveFlow
import ir.amirab.util.compose.StringSource
@ -847,8 +849,10 @@ class AppComponent(
).all { it }
}
// TODO enable updater
// val updater = UpdateComponent(childContext("updater"))
val updater = UpdateComponent(
childContext("updater"),
this,
)
val showAboutPage = MutableStateFlow(false)
val showOpenSourceLibraries = MutableStateFlow(false)
val showTranslators = MutableStateFlow(false)

View File

@ -5,10 +5,13 @@ import com.abdownloadmanager.desktop.utils.BrowserType
interface BaseConstants{
val appName:String
val appDisplayName: String
val packageName:String
val projectWebsite:String
val projectSourceCode:String
val projectTranslations: String
val projectGithubOwner: String
val projectGithubRepo: String
val browserIntegrations:List<BrowserIntegrationModel>
val telegramGroupUrl:String
val telegramChannelUrl:String
@ -16,10 +19,13 @@ interface BaseConstants{
object SharedConstants:BaseConstants{
override val appName: String = BuildConfig.APP_NAME
override val appDisplayName: String = BuildConfig.APP_DISPLAY_NAME
override val packageName: String = BuildConfig.PACKAGE_NAME
override val projectWebsite: String= BuildConfig.PROJECT_WEBSITE
override val projectTranslations: String = BuildConfig.PROJECT_TRANSLATIONS
override val projectSourceCode: String= BuildConfig.PROJECT_SOURCE_CODE
override val projectGithubOwner: String = BuildConfig.PROJECT_GITHUB_OWNER
override val projectGithubRepo: String = BuildConfig.PROJECT_GITHUB_REPO
override val browserIntegrations: List<BrowserIntegrationModel> = listOf(
BrowserIntegrationModel(
BrowserType.Chrome,BuildConfig.INTEGRATION_CHROME_LINK

View File

@ -149,12 +149,12 @@ val showDownloadList = simpleAction(
appComponent.openHome()
}
/*val checkForUpdateAction = simpleAction(
title = "Check For Update",
val checkForUpdateAction = simpleAction(
title = Res.string.update_check_for_update.asStringSource(),
icon = MyIcons.refresh,
) {
appComponent.updater.requestCheckForUpdate()
}*/
}
val openAboutAction = simpleAction(
title = Res.string.about.asStringSource(),
icon = MyIcons.info,

View File

@ -1,10 +1,15 @@
package com.abdownloadmanager.desktop.di
import GithubApi
import com.abdownloadmanager.UpdateDownloadLocationProvider
import com.abdownloadmanager.UpdateManager
import com.abdownloadmanager.desktop.AppArguments
import com.abdownloadmanager.integration.IntegrationHandler
import com.abdownloadmanager.desktop.AppComponent
import com.abdownloadmanager.desktop.SharedConstants
import com.abdownloadmanager.desktop.integration.IntegrationHandlerImp
import com.abdownloadmanager.desktop.pages.settings.ThemeManager
import com.abdownloadmanager.desktop.pages.updater.UpdateDownloaderViaDownloadSystem
import ir.amirab.downloader.queue.QueueManager
import com.abdownloadmanager.desktop.repository.AppRepository
import com.abdownloadmanager.desktop.storage.*
@ -23,6 +28,8 @@ import ir.amirab.downloader.monitor.DownloadMonitor
import ir.amirab.downloader.utils.IDiskStat
import ir.amirab.util.startup.Startup
import com.abdownloadmanager.integration.Integration
import com.abdownloadmanager.updateapplier.DesktopUpdateApplier
import com.abdownloadmanager.updateapplier.UpdateApplier
import ir.amirab.downloader.DownloadManager
import ir.amirab.util.config.datastore.createMapConfigDatastore
import kotlinx.coroutines.*
@ -33,12 +40,14 @@ import org.koin.core.component.KoinComponent
import org.koin.core.context.startKoin
import org.koin.dsl.bind
import org.koin.dsl.module
import com.abdownloadmanager.updatechecker.DummyUpdateChecker
import com.abdownloadmanager.updatechecker.GithubUpdateChecker
import com.abdownloadmanager.updatechecker.UpdateChecker
import com.abdownloadmanager.utils.DownloadFoldersRegistry
import com.abdownloadmanager.utils.DownloadSystem
import com.abdownloadmanager.utils.FileIconProvider
import com.abdownloadmanager.utils.FileIconProviderUsingCategoryIcons
import ir.amirab.util.AppVersionTracker
import com.abdownloadmanager.utils.appinfo.PreviousVersion
import com.abdownloadmanager.utils.autoremove.RemovedDownloadsFromDiskTracker
import com.abdownloadmanager.utils.category.*
import com.abdownloadmanager.utils.compose.IMyIcons
@ -188,14 +197,47 @@ val integrationModule = module {
}
}
val updaterModule = module {
single {
UpdateDownloadLocationProvider {
AppInfo.updateDir.resolve("downloads")
}
}
single<UpdateApplier> {
DesktopUpdateApplier(
installationFolder = AppInfo.installationFolder,
updateFolder = AppInfo.updateDir.path,
logDir = AppInfo.logDir.path,
appName = AppInfo.name,
updateDownloader = UpdateDownloaderViaDownloadSystem(
get(),
get(),
),
)
}
single<UpdateChecker> {
DummyUpdateChecker(AppVersion.get())
GithubUpdateChecker(
AppVersion.get(),
githubApi = GithubApi(
owner = SharedConstants.projectGithubOwner,
repo = SharedConstants.projectGithubRepo,
client = OkHttpClient
.Builder()
.build()
)
)
}
single {
UpdateManager(
updateChecker = get(),
updateApplier = get(),
appVersionTracker = get(),
)
}
}
val startUpModule = module {
single {
Startup.getStartUpManagerForDesktop(
name = AppInfo.name,
name = AppInfo.displayName,
path = AppInfo.exeFile,
args = listOf(AppArguments.Args.BACKGROUND),
)
@ -270,6 +312,21 @@ val appModule = module {
get(), get(), get(),
)
}
single {
PreviousVersion(
systemPath = AppInfo.systemDir,
currentVersion = AppInfo.version,
)
}
single {
AppVersionTracker(
previousVersion = {
// it MUST be booted first
get<PreviousVersion>().get()
},
currentVersion = AppInfo.version,
)
}
}

View File

@ -27,11 +27,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.abdownloadmanager.desktop.App
import com.abdownloadmanager.utils.compose.widget.MyIcon
import com.abdownloadmanager.desktop.ui.util.ifThen
import com.abdownloadmanager.resources.Res
import com.abdownloadmanager.resources.*
import ir.amirab.util.compose.resources.myStringResource
@Composable
@ -83,7 +81,7 @@ fun RenderAppInfo(
Spacer(Modifier.width(16.dp))
Column {
Text(
AppInfo.name,
AppInfo.displayName,
fontSize = myTextSizes.xl,
fontWeight = FontWeight.Bold,
)

View File

@ -20,8 +20,10 @@ import androidx.compose.runtime.*
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import com.abdownloadmanager.UpdateManager
import com.abdownloadmanager.desktop.pages.category.CategoryDialogManager
import com.abdownloadmanager.desktop.storage.AppSettingsStorage
import com.abdownloadmanager.desktop.ui.widget.MessageDialogType
import com.abdownloadmanager.resources.Res
import com.abdownloadmanager.utils.DownloadSystem
import com.abdownloadmanager.utils.FileIconProvider
@ -41,10 +43,12 @@ import ir.amirab.util.flow.mapTwoWayStateFlow
import com.abdownloadmanager.utils.extractors.linkextractor.DownloadCredentialFromStringExtractor
import ir.amirab.downloader.downloaditem.contexts.RemovedBy
import ir.amirab.downloader.downloaditem.contexts.User
import ir.amirab.util.AppVersionTracker
import ir.amirab.util.compose.asStringSource
import ir.amirab.util.compose.asStringSourceWithARgs
import ir.amirab.util.osfileutil.FileUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
@ -413,6 +417,8 @@ class HomeComponent(
private val queueManager: QueueManager by inject()
private val pageStorage: PageStatesStorage by inject()
private val appSettings: AppSettingsStorage by inject()
private val updateManager: UpdateManager by inject()
private val appVersionTracker: AppVersionTracker by inject()
val filterState = FilterState()
val mergeTopBarWithTitleBar = appSettings.mergeTopBarWithTitleBar
@ -589,8 +595,9 @@ class HomeComponent(
+gotoSettingsAction
}
subMenu(Res.string.help.asStringSource()) {
//TODO Enable Updater
// +checkForUpdateAction
if (updateManager.isUpdateSupported()) {
+checkForUpdateAction
}
+supportActionGroup
separator()
+openOpenSourceThirdPartyLibraries
@ -814,6 +821,33 @@ class HomeComponent(
downloads.any { it.id == previouslySelectedItem }
}
}.launchIn(scope)
// if the app is updated then clean downloaded files
if (appVersionTracker.isUpgraded()) {
// clean update files
scope.launch {
// temporary fix:
// at the moment we relly on DownloadMonitor for getting the list of downloads by their folder
// so wait for the download list to be updated by the download monitor
delay(1000)
// then clean up the downloaded files
updateManager.cleanDownloadedFiles()
}
// show user about update
scope.launch {
// let user focus to the app
delay(1000)
notificationSender.sendNotification(
title = Res.string.update_updater.asStringSource(),
description = Res.string.update_app_updated_to_version_n.asStringSourceWithARgs(
Res.string.update_app_updated_to_version_n_createArgs(
version = appVersionTracker.currentVersion.toString()
)
),
type = NotificationType.Success,
tag = "Updater"
)
}
}
}
private val selectionListItems = combineStateFlows(

View File

@ -13,9 +13,6 @@ import com.abdownloadmanager.desktop.ui.customwindow.rememberWindowController
import com.abdownloadmanager.desktop.ui.icon.MyIcons
import com.abdownloadmanager.desktop.utils.AppInfo
import com.abdownloadmanager.desktop.utils.mvi.HandleEffects
import com.abdownloadmanager.resources.Res
import com.abdownloadmanager.resources.*
import ir.amirab.util.compose.resources.myStringResource
import java.awt.Dimension
@Composable
@ -31,7 +28,7 @@ fun HomeWindow(
val onCloseRequest = onCLoseRequest
val windowIcon = MyIcons.appIcon
val windowController = rememberWindowController(
AppInfo.name,
AppInfo.displayName,
windowIcon.rememberPainter(),
)

View File

@ -1,5 +1,6 @@
package com.abdownloadmanager.desktop.pages.updater
import androidx.compose.foundation.*
import com.abdownloadmanager.desktop.ui.customwindow.WindowIcon
import com.abdownloadmanager.desktop.ui.customwindow.WindowTitle
import com.abdownloadmanager.desktop.ui.icon.MyIcons
@ -8,69 +9,143 @@ import com.abdownloadmanager.desktop.ui.theme.myTextSizes
import com.abdownloadmanager.desktop.ui.widget.ActionButton
import com.abdownloadmanager.utils.compose.WithContentAlpha
import com.abdownloadmanager.desktop.utils.div
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import com.abdownloadmanager.desktop.ui.widget.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.BlurredEdgeTreatment
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.abdownloadmanager.desktop.ui.theme.myMarkdownColors
import com.abdownloadmanager.desktop.ui.theme.myMarkdownTypography
import com.abdownloadmanager.resources.Res
import io.github.z4kn4fein.semver.Version
import com.abdownloadmanager.updatechecker.VersionData
import com.abdownloadmanager.updatechecker.UpdateInfo
import com.abdownloadmanager.utils.compose.needScroll
import com.mikepenz.markdown.compose.Markdown
import ir.amirab.util.compose.resources.myStringResource
@Composable
fun NewUpdatePage(
versionVersionData: VersionData,
newVersionInfo: UpdateInfo,
currentVersion: Version,
update: () -> Unit,
cancel: () -> Unit,
) {
WindowTitle("New Update")
WindowIcon(MyIcons.appIcon)
WindowTitle(myStringResource(Res.string.update_updater))
WindowIcon(MyIcons.refresh)
Box {
BackgroundEffects()
Column(
Modifier
.fillMaxSize()
) {
Column(
Modifier
.padding(horizontal = 16.dp)
.padding(
bottom = 16.dp,
top = 8.dp
)
.weight(1f)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "There is a new version of app is available",
text = myStringResource(Res.string.update_available),
fontSize = myTextSizes.xl,
fontWeight = FontWeight.Bold
)
Spacer(Modifier.height(4.dp))
WithContentAlpha(0.75f){
Spacer(Modifier.width(8.dp))
Text(
text = "you can press on update button to update to the latest version",
fontSize = myTextSizes.base,
text = myStringResource(
Res.string.version_n, Res.string.version_n_createArgs(
newVersionInfo.version.toString()
)
),
fontSize = myTextSizes.xl,
fontWeight = FontWeight.Bold,
color = myColors.success,
)
}
Spacer(Modifier.height(8.dp))
Row {
RenderKeyValue("Current Version", currentVersion.toString())
Spacer(Modifier.width(16.dp))
RenderKeyValue("Latest Version", versionVersionData.version.toString())
WithContentAlpha(0.8f) {
Text(
text = myStringResource(Res.string.update_available_suggest_to_to_update),
fontSize = myTextSizes.base,
)
}
Spacer(Modifier.height(8.dp))
RenderChangeLog(
Modifier
.fillMaxWidth()
.weight(1f),
versionVersionData.changeLog
newVersionInfo.changeLog
)
Spacer(Modifier.height(8.dp))
Row(
}
Actions(
Modifier.fillMaxWidth(),
update,
cancel
)
}
}
}
@Composable
private fun BoxScope.BackgroundEffects() {
Box(
Modifier
.align(Alignment.TopCenter)
.offset(y = (-148).dp)
.fillMaxWidth(0.5f)
.height(200.dp)
.blur(
56.dp,
edgeTreatment = BlurredEdgeTreatment.Unbounded
)
.clip(CircleShape)
.background(
myColors.primary / 0.15f
)
)
Box(
Modifier
.align(Alignment.BottomEnd)
.size(180.dp)
.offset(x = 32.dp, y = (-32).dp)
.blur(
56.dp,
edgeTreatment = BlurredEdgeTreatment.Unbounded
)
.clip(CircleShape)
.background(
myColors.secondary / 0.15f
)
)
}
@Composable
fun Actions(modifier: Modifier, update: () -> Unit, cancel: () -> Unit) {
Column(modifier) {
Spacer(
Modifier
.fillMaxWidth()
.height(1.dp)
.background(myColors.onBackground / 0.15f)
)
Row(
Modifier
.fillMaxWidth()
.background(myColors.surface / 0.5f)
.padding(horizontal = 16.dp)
.padding(vertical = 16.dp),
horizontalArrangement = Arrangement.End
) {
UpdateButton(Modifier, update)
@ -99,7 +174,7 @@ fun UpdateButton(
}
)
ActionButton(
text = "Update",
text = myStringResource(Res.string.update),
modifier = modifier,
onClick = update,
backgroundColor = backgroundColor,
@ -115,35 +190,59 @@ fun CancelButton(
cancel: () -> Unit,
) {
ActionButton(
text = "Cancel",
text = myStringResource(Res.string.cancel),
modifier = modifier,
onClick = cancel,
)
}
@Composable
fun RenderChangeLog(modifier: Modifier, changeLog: String) {
private fun RenderChangeLog(modifier: Modifier, changeLog: String) {
val trimmedChangelog = remember {
changeLog
.lines()
.filterNot { it.isBlank() }
.joinToString("\n")
}
Column(modifier) {
Text(
text = "Changelog",
fontSize = myTextSizes.base,
text = myStringResource(Res.string.update_release_notes),
fontWeight = FontWeight.Bold,
fontSize = myTextSizes.lg,
)
Spacer(Modifier.height(4.dp))
Box(
Spacer(Modifier.height(8.dp))
val shape = RoundedCornerShape(6.dp)
val scrollState = rememberScrollState()
val scrollbarAdapter = rememberScrollbarAdapter(scrollState)
Row(
Modifier
.fillMaxSize()
.clip(RoundedCornerShape(6.dp))
.background(myColors.onBackground / 5)
.verticalScroll(rememberScrollState())
.padding(8.dp)
.clip(shape)
.border(1.dp, myColors.onBackground / 0.05f, shape)
.background(myColors.surface / 75)
) {
SelectionContainer {
WithContentAlpha(0.75f) {
Text(
text = changeLog,
fontSize = myTextSizes.base,
Markdown(
modifier = Modifier
.weight(1f)
.verticalScroll(scrollState)
.padding(8.dp),
content = trimmedChangelog,
colors = myMarkdownColors(),
typography = myMarkdownTypography()
)
if (scrollbarAdapter.needScroll()) {
VerticalScrollbar(
modifier = Modifier
.fillMaxHeight()
.padding(
vertical = 4.dp,
horizontal = 4.dp
),
style = LocalScrollbarStyle.current.copy(
thickness = 8.dp
),
adapter = scrollbarAdapter
)
}
}
}
}
@ -156,9 +255,17 @@ private fun RenderKeyValue(
) {
Row(verticalAlignment = Alignment.CenterVertically) {
WithContentAlpha(0.50f) {
Text(key, fontSize = myTextSizes.base)
Text(
key,
fontSize = myTextSizes.base,
maxLines = 1,
)
}
Spacer(Modifier.width(8.dp))
Text(value, fontSize = myTextSizes.base)
Text(
value,
fontSize = myTextSizes.base,
maxLines = 1,
)
}
}

View File

@ -1,88 +1,63 @@
package com.abdownloadmanager.desktop.pages.updater
import com.abdownloadmanager.desktop.storage.AppSettingsStorage
import com.abdownloadmanager.desktop.utils.AppVersion
import com.abdownloadmanager.desktop.utils.BaseComponent
import com.abdownloadmanager.utils.DownloadSystem
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.abdownloadmanager.UpdateManager
import com.abdownloadmanager.desktop.NotificationSender
import com.abdownloadmanager.desktop.ui.widget.MessageDialogType
import com.arkivanov.decompose.ComponentContext
import ir.amirab.downloader.downloaditem.DownloadItem
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import com.abdownloadmanager.updateapplier.JavaUpdateApplier
import com.abdownloadmanager.updateapplier.UpdateDownloader
import com.abdownloadmanager.updatechecker.UpdateChecker
import com.abdownloadmanager.updatechecker.VersionData
import java.io.File
sealed interface UpdateStatus {
data object IDLE : UpdateStatus
data object NoUpdate : UpdateStatus
data object NewUpdate : UpdateStatus
data class Error(val e: Throwable) : UpdateStatus
data object Checking : UpdateStatus
}
import com.abdownloadmanager.updatechecker.UpdateInfo
import ir.amirab.util.compose.asStringSource
class UpdateComponent(
ctx: ComponentContext,
) : BaseComponent(
ctx
),
private val notificationSender: NotificationSender,
) : BaseComponent(ctx),
KoinComponent {
private val updateChecker: UpdateChecker by inject()
//maybe create it via DI
// private val updateApplier: UpdateApplier by inject()
private val downloadSystem: DownloadSystem by inject()
private val updateManager: UpdateManager by inject()
val currentVersion = AppVersion.get()
val showNewUpdate = MutableStateFlow(false)
val newVersionData = MutableStateFlow(null as VersionData?)
private val appSettings: AppSettingsStorage by inject()
val newVersionData = updateManager.newVersionData
private var updateApplierJob: Job? = null
var updateCheckStatus by mutableStateOf<UpdateStatus>(UpdateStatus.IDLE)
var updateCheckStatus = updateManager.updateCheckStatus
fun performUpdate() {
val versionData = newVersionData.value ?: error("there is no new version!")
val updateApplier = JavaUpdateApplier(
versionData,
UpdateDownloaderViaDownloadSystem(
downloadSystem,
appSettings.defaultDownloadFolder.value,
name = versionData.name
)
)
updateApplierJob?.cancel()
updateApplierJob = scope.launch {
updateApplier.applyUpdate()
try {
updateManager.update()
} catch (e: Exception) {
showMessage(e)
}
}
}
fun showNewUpdate(versionData: VersionData) {
newVersionData.update { versionData }
private fun showMessage(e: Exception) {
e.printStackTrace()
notificationSender.sendDialogNotification(
"Update Error".asStringSource(),
e.localizedMessage.orEmpty().asStringSource(),
type = MessageDialogType.Error,
)
}
fun showNewUpdate() {
showNewUpdate.update { true }
}
fun requestCheckForUpdate() {
scope.launch {
try {
updateCheckStatus = UpdateStatus.Checking
val result = updateChecker.check()
if (result != null) {
showNewUpdate(result)
updateCheckStatus = UpdateStatus.NewUpdate
} else {
updateCheckStatus = UpdateStatus.NoUpdate
}
updateCheckStatus = UpdateStatus.IDLE
}catch (e:Exception){
updateCheckStatus = UpdateStatus.Error(e)
updateManager
.checkForUpdate()
?.let {
showNewUpdate()
}
}
}
@ -91,38 +66,3 @@ class UpdateComponent(
showNewUpdate.update { false }
}
}
class UpdateDownloaderViaDownloadSystem(
private val downloadSystem: DownloadSystem,
private val saveFolder: String,
private val name: String,
) : UpdateDownloader,
KoinComponent {
override suspend fun download(link: String): File {
val id = downloadSystem.getOrCreateDownloadByLink(
DownloadItem(
id = -1,
link = link,
folder = saveFolder,
name = name,
)
)
val downloaded = coroutineScope {
val waiter = async {
downloadSystem.downloadMonitor.waitForDownloadToFinishOrCancel(id)
}
downloadSystem.manualResume(id)
waiter.await()
}
if (!downloaded) {
error("Download Cancelled")
}
// we recheck download info maybe some dude change the file name!
val downloadedItem = downloadSystem.getDownloadItemById(id)
requireNotNull(downloadedItem) {
"Download is removed!"
}
return downloadSystem.getDownloadFile(downloadedItem)
}
}

View File

@ -0,0 +1,76 @@
package com.abdownloadmanager.desktop.pages.updater
import com.abdownloadmanager.UpdateDownloadLocationProvider
import com.abdownloadmanager.updateapplier.UpdateDownloader
import com.abdownloadmanager.updatechecker.UpdateSource
import com.abdownloadmanager.utils.DownloadSystem
import ir.amirab.downloader.downloaditem.DownloadItem
import ir.amirab.downloader.downloaditem.EmptyContext
import ir.amirab.downloader.utils.OnDuplicateStrategy
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import java.io.File
class UpdateDownloaderViaDownloadSystem(
private val downloadSystem: DownloadSystem,
private val updateDownloadLocationProvider: UpdateDownloadLocationProvider,
) : UpdateDownloader {
override suspend fun downloadUpdate(updateDirectDownloadLink: UpdateSource.DirectDownloadLink): File {
val updateDownloadsFolder = updateDownloadLocationProvider.getSaveLocation().path
val updateDownloads = downloadSystem.getDownloadItemsByFolder(updateDownloadsFolder)
val pausedDownload = updateDownloads.find {
it.name == updateDirectDownloadLink.name
}
// at the moment if the download was finished but removed from the filesystem
// download will not be restarted automatically
val requireRestartDownload = pausedDownload?.getFullPath()?.exists()?.not() ?: false
val id = pausedDownload?.id
?: downloadSystem.addDownload(
downloadItem = DownloadItem(
id = -1,
link = updateDirectDownloadLink.link,
folder = updateDownloadsFolder,
name = updateDirectDownloadLink.name,
),
onDuplicateStrategy = OnDuplicateStrategy.AddNumbered,
queueId = null,
categoryId = null,
)
coroutineScope {
if (requireRestartDownload) {
downloadSystem.reset(id)
}
val waiter = async {
downloadSystem.downloadMonitor.waitForDownloadToFinishOrCancel(id)
}
downloadSystem.manualResume(id, EmptyContext)
waiter.await()
}
// we recheck download info maybe some dude change the file name!
val downloadedItem = downloadSystem.getDownloadItemById(id)
requireNotNull(downloadedItem) {
"Download is removed!"
}
return downloadSystem.getDownloadFile(downloadedItem)
}
override suspend fun removeUpdate(updateDirectDownloadLink: UpdateSource.DirectDownloadLink) {
val id = downloadSystem
.getDownloadItemsByFolder(updateDownloadLocationProvider.getSaveLocation().path)
.find { it.name == updateDirectDownloadLink.name }?.id
id?.let {
downloadSystem.removeDownload(id, true, EmptyContext)
}
}
override suspend fun removeAllUpdates() {
val ids = downloadSystem
.getDownloadItemsByFolder(updateDownloadLocationProvider.getSaveLocation().path)
.map { it.id }
for (id in ids) {
downloadSystem.removeDownload(
id = id, alsoRemoveFile = true, EmptyContext
)
}
}
}

View File

@ -1,15 +1,20 @@
package com.abdownloadmanager.desktop.pages.updater
import com.abdownloadmanager.desktop.ui.customwindow.CustomWindow
import com.abdownloadmanager.desktop.ui.widget.NotificationModel
import com.abdownloadmanager.desktop.ui.widget.NotificationType
import com.abdownloadmanager.desktop.ui.widget.ShowNotification
import com.abdownloadmanager.desktop.ui.widget.useNotification
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.rememberWindowState
import com.abdownloadmanager.UpdateCheckStatus
import com.abdownloadmanager.desktop.ui.theme.LocalUiScale
import com.abdownloadmanager.resources.Res
import ir.amirab.util.compose.StringSource
import ir.amirab.util.compose.asStringSource
import ir.amirab.util.desktop.screen.applyUiScale
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@ -21,9 +26,9 @@ fun ShowUpdaterDialog(updaterComponent: UpdateComponent) {
val closeUpdatePage = {
updaterComponent.requestClose()
}
val status = updaterComponent.updateCheckStatus
val status = updaterComponent.updateCheckStatus.collectAsState().value
var message by remember { mutableStateOf(null as String?) }
var message by remember { mutableStateOf(null as StringSource?) }
var notificationType by remember { mutableStateOf(null as NotificationType?) }
LaunchedEffect(status) {
fun CoroutineScope.clearMessageAfter(delay: Long) {
@ -33,23 +38,27 @@ fun ShowUpdaterDialog(updaterComponent: UpdateComponent) {
}
}
when (status) {
UpdateStatus.Checking -> {
message = "Checking for update"
UpdateCheckStatus.Checking -> {
message = Res.string.update_checking_for_update.asStringSource()
notificationType = NotificationType.Loading(null)
}
is UpdateStatus.Error -> {
is UpdateCheckStatus.Error -> {
clearMessageAfter(3000)
message = """
Error while checking for update
${status.e.localizedMessage}
""".trimIndent()
message = StringSource.CombinedStringSource(
listOf(
Res.string.update_check_error.asStringSource(),
status.e.localizedMessage.orEmpty().asStringSource(),
),
"\n",
)
status.e.printStackTrace()
notificationType = NotificationType.Error
}
UpdateStatus.NoUpdate -> {
UpdateCheckStatus.NoUpdate -> {
clearMessageAfter(3000)
message = "No update"
message = Res.string.update_no_update.asStringSource()
notificationType = NotificationType.Info
}
@ -62,21 +71,23 @@ fun ShowUpdaterDialog(updaterComponent: UpdateComponent) {
message?.let { message ->
ShowNotification(
title = "Updater".asStringSource(),
description = message.asStringSource(),
title = Res.string.update_updater.asStringSource(),
description = message,
type = notificationType ?: NotificationType.Info,
tag = "Updater"
)
}
if (showUpdate && newVersion != null) {
val uiScale = LocalUiScale.current
CustomWindow(
state = rememberWindowState(
size = DpSize(400.dp, 400.dp)
size = DpSize(500.dp, 400.dp).applyUiScale(uiScale),
position = WindowPosition.Aligned(Alignment.Center)
),
onCloseRequest = closeUpdatePage,
) {
NewUpdatePage(
versionVersionData = newVersion,
newVersionInfo = newVersion,
currentVersion = updaterComponent.currentVersion,
cancel = closeUpdatePage,
update = {

View File

@ -29,6 +29,7 @@ import com.abdownloadmanager.desktop.pages.credits.translators.ShowTranslators
import com.abdownloadmanager.desktop.pages.editdownload.EditDownloadWindow
import com.abdownloadmanager.desktop.pages.home.HomeWindow
import com.abdownloadmanager.desktop.pages.settings.ThemeManager
import com.abdownloadmanager.desktop.pages.updater.ShowUpdaterDialog
import com.abdownloadmanager.desktop.ui.widget.*
import com.abdownloadmanager.utils.compose.ProvideDebugInfo
import ir.amirab.util.compose.localizationmanager.LanguageManager
@ -89,8 +90,7 @@ object Ui : KoinComponent {
ShowAddDownloadDialogs(appComponent)
ShowDownloadDialogs(appComponent)
ShowCategoryDialogs(appComponent)
//TODO Enable Updater
//ShowUpdaterDialog(appComponent.updater)
ShowUpdaterDialog(appComponent.updater)
ShowAboutDialog(appComponent)
NewQueueDialog(appComponent)
ShowMessageDialogs(appComponent)

View File

@ -0,0 +1,86 @@
package com.abdownloadmanager.desktop.ui.theme
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import com.abdownloadmanager.utils.compose.LocalContentColor
import com.abdownloadmanager.utils.compose.LocalTextStyle
import com.mikepenz.markdown.model.DefaultMarkdownColors
import com.mikepenz.markdown.model.DefaultMarkdownTypography
@Composable
fun myMarkdownColors(): DefaultMarkdownColors {
return DefaultMarkdownColors(
text = LocalContentColor.current,
linkText = myColors.info,
codeText = myColors.onSurface,
inlineCodeText = myColors.onSurface,
codeBackground = myColors.surface,
dividerColor = LocalContentColor.current.copy(alpha = 0.1f),
inlineCodeBackground = myColors.surface,
)
}
@Composable
fun myMarkdownTypography(): DefaultMarkdownTypography {
val defaultTextStyle = LocalTextStyle.current
return DefaultMarkdownTypography(
h1 = defaultTextStyle.copy(
fontSize = myTextSizes.xl * 1.1f,
fontWeight = FontWeight.Bold,
),
h2 = defaultTextStyle.copy(
fontSize = myTextSizes.xl,
fontWeight = FontWeight.Bold,
),
h3 = defaultTextStyle.copy(
fontSize = myTextSizes.lg,
fontWeight = FontWeight.Bold,
),
h4 = defaultTextStyle.copy(
fontSize = myTextSizes.base,
fontWeight = FontWeight.Bold,
),
h5 = defaultTextStyle.copy(
fontSize = myTextSizes.sm,
fontWeight = FontWeight.Bold,
),
h6 = defaultTextStyle.copy(
fontSize = myTextSizes.xs,
fontWeight = FontWeight.Bold,
),
text = defaultTextStyle.copy(
fontSize = myTextSizes.base,
fontWeight = FontWeight.Bold,
),
code = defaultTextStyle.copy(
fontSize = myTextSizes.base,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily.Monospace,
),
inlineCode = defaultTextStyle.copy(
fontSize = myTextSizes.base,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily.Monospace,
),
quote = defaultTextStyle.copy(
fontSize = myTextSizes.base,
),
paragraph = defaultTextStyle.copy(
fontSize = myTextSizes.base,
),
ordered = defaultTextStyle.copy(
fontSize = myTextSizes.base,
),
bullet = defaultTextStyle.copy(
fontSize = myTextSizes.base,
),
list = defaultTextStyle.copy(
fontSize = myTextSizes.base,
fontWeight = FontWeight.Normal,
),
link = defaultTextStyle.copy(
fontSize = myTextSizes.base,
),
)
}

View File

@ -7,6 +7,7 @@ import java.io.File
object AppInfo {
val name = SharedConstants.appName
val displayName = SharedConstants.appDisplayName
val packageName = SharedConstants.packageName
val website = SharedConstants.projectWebsite
val sourceCode = SharedConstants.projectSourceCode
@ -21,6 +22,18 @@ object AppInfo {
// }
System.getProperty("jpackage.app-path")
}
val installationFolder: String? = run {
exeFile?.let(::File)
?.parentFile // executable path
?.let {
when (Platform.getCurrentPlatform()) {
Platform.Desktop.Linux -> it.parentFile // <installationFolder>/bin/ABDownloadManager
Platform.Desktop.MacOS -> it.parentFile // not checked yet
Platform.Desktop.Windows -> it // <installationFolder>/ABDownloadManager.exe
else -> null
}?.path
}
}
}
fun AppInfo.isAppInstalled(): Boolean {
@ -36,8 +49,11 @@ fun AppInfo.isInDebugMode(): Boolean {
}
val AppInfo.configDir: File get() = File(AppProperties.getConfigDirectory())
val AppInfo.systemDir: File get() = File(AppProperties.getSystemDirectory())
val AppInfo.updateDir: File get() = AppInfo.systemDir.resolve("update")
val AppInfo.logDir: File get() = AppInfo.systemDir.resolve("log")
val AppInfo.optionsDir: File get() = AppInfo.configDir.resolve("options")
val AppInfo.downloadDbDir:File get() = AppInfo.configDir.resolve("download_db")
fun AppInfo.extensions(){
val AppInfo.downloadDbDir: File get() = AppInfo.configDir.resolve("download_db")
fun AppInfo.extensions() {
}

View File

@ -20,6 +20,7 @@ object AppProperties {
private object Keys {
const val CONFIG_DIRECTORY: String = "app.config.path"
const val SYSTEM_DIRECTORY: String = "app.system.path"
const val DEBUG: String = "app.debug"
}
@ -81,6 +82,11 @@ object AppProperties {
return ensureAndGet(Keys.CONFIG_DIRECTORY)
.toString()
}
fun getSystemDirectory(): String {
return ensureAndGet(Keys.SYSTEM_DIRECTORY)
.toString()
}
//app.properties in installation directory
fun isAppPropertiesFound(): Boolean {
return foundAppProperties

View File

@ -4,8 +4,6 @@ import com.abdownloadmanager.desktop.utils.AppInfo
import com.abdownloadmanager.desktop.utils.isAppInstalled
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
data class NativeMessagingManifests(
val firefoxNativeMessagingManifest: FirefoxNativeMessagingManifest,
@ -65,8 +63,8 @@ class NativeMessaging(
if (!AppInfo.isAppInstalled()) return null
val execFile = AppInfo.exeFile!!
return FirefoxNativeMessagingManifest(
name = AppInfo.name,
description = AppInfo.name,
name = AppInfo.displayName,
description = AppInfo.displayName,
path = execFile,
type = "stdio",
allowedExtensions = listOf(
@ -78,8 +76,8 @@ class NativeMessaging(
if (!AppInfo.isAppInstalled()) return null
val execFile = AppInfo.exeFile!!
return ChromeNativeMessagingManifest(
name = AppInfo.name,
description = AppInfo.name,
name = AppInfo.displayName,
description = AppInfo.displayName,
path = execFile,
type = "stdio",
allowedOrigins = listOf(

View File

@ -1,2 +1,3 @@
app.config.path="${user.home}/.abdm/config"
app.system.path="${user.home}/.abdm/system"
app.debug="false"

View File

@ -260,8 +260,8 @@ class DownloadMonitor(
)
override suspend fun waitForDownloadToFinishOrCancel(
id: Long
): Boolean {
id: Long,
) {
val event = downloadManager
.listOfJobsEvents
.filter {
@ -278,10 +278,8 @@ class DownloadMonitor(
is DownloadManagerEvents.OnJobStarting -> false
}
}
if (event is DownloadManagerEvents.OnJobCompleted) {
return true
} else {
return false
if (event is DownloadManagerEvents.OnJobCanceled) {
throw event.e
}
}
}

View File

@ -14,8 +14,8 @@ interface IDownloadMonitor {
val activeDownloadCount: StateFlow<Int>
suspend fun waitForDownloadToFinishOrCancel(
id: Long
): Boolean
id: Long,
)
}
fun IDownloadMonitor.isDownloadActiveFlow(

View File

@ -26,6 +26,7 @@ semver = "2.0.0"
jgit = "6.9.0.202403050737-r"
osThemeDetector = "3.9.1"
kotlinFileWatcher = "1.3.0"
markdownRenderer = "0.27.0"
[libraries]
@ -117,7 +118,7 @@ arrow-opticKsp = { module = "io.arrow-kt:arrow-optics-ksp-plugin", version.ref =
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
osThemeDetector = { module = "com.github.Dansoftowner:jSystemThemeDetector", version.ref = "osThemeDetector" }
markdownRenderer-core = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "markdownRenderer" }
handlebarsJava = "com.github.jknack:handlebars:4.4.0"
kotlinFileWatcher = { module = "io.github.irgaly.kfswatch:kfswatch", version.ref = "kotlinFileWatcher" }

View File

@ -126,10 +126,12 @@ class DownloadSystem(
}
suspend fun manualResume(id: Long): Boolean {
// if (mainDownloadQueue.isQueueActive) {
// return false
// }
downloadManager.resume(id, ResumedBy(User))
manualResume(id, ResumedBy(User))
return true
}
suspend fun manualResume(id: Long, context: DownloadItemContext): Boolean {
downloadManager.resume(id, context)
return true
}
@ -211,6 +213,11 @@ class DownloadSystem(
it.getFullPath().path == path
}
}
fun getDownloadItemsByFolder(folder: String): List<IDownloadItemState> {
return downloadMonitor.downloadListFlow.value.filter {
it.folder == folder
}
}
suspend fun getFilePathById(id: Long): File? {

View File

@ -0,0 +1,29 @@
package com.abdownloadmanager.utils.appinfo
import io.github.z4kn4fein.semver.Version
import java.io.File
class PreviousVersion(
systemPath: File,
private val currentVersion: Version,
) {
private val versionFile = File(systemPath, ".version")
private var previousVersion: Version? = null
fun get(): Version? {
return previousVersion
}
fun boot() {
previousVersion = kotlin.runCatching {
// maybe versionFile is null but we catch it
val versionString = versionFile.readText()
Version.parse(versionString)
}.getOrNull()
kotlin.runCatching {
versionFile.parentFile.mkdirs()
versionFile.writeText(currentVersion.toString())
}.onFailure {
it.printStackTrace()
}
}
}

View File

@ -0,0 +1,5 @@
package com.abdownloadmanager.utils.compose
fun androidx.compose.foundation.v2.ScrollbarAdapter.needScroll(): Boolean {
return contentSize > viewportSize
}

View File

@ -316,3 +316,13 @@ meet_the_translators=Meet the Translators
localized_by_translators=Localized by Translators
confirm_exit=Confirm Exit
confirm_exit_description=Are you sure you want to exit AB Download Manager?\nActive downloads/queues will be stopped!
update=Update
update_updater=Updater
update_available=Update Available
update_available_suggest_to_to_update=You can update to the latest version to enjoy new features, enhancements, and performance improvements.
update_release_notes=Release Notes
update_check_for_update=Check for Update
update_checking_for_update=Checking for Update
update_no_update=You are using the latest version
update_check_error=Error while checking for update
update_app_updated_to_version_n=App updated to version {{version}}

View File

@ -7,4 +7,6 @@ dependencies {
api(libs.okhttp.okhttp)
api(libs.kotlin.coroutines.core)
implementation(project(":shared:utils"))
implementation(libs.jna.platform)
implementation(libs.semver)
}

View File

@ -1,44 +1,37 @@
package com.abdownloadmanager
import io.github.z4kn4fein.semver.Version
import ir.amirab.util.platform.Arch
import ir.amirab.util.platform.Platform
data class AppArtifactInfo(
val version: Version,
val platform: Platform,
val arch: Arch,
)
object ArtifactUtil {
private val versionPatern = "(\\d+\\.\\d+\\.\\d+)"
val versionRegex = "_$versionPatern".toRegex()
val platformRegex = "_${versionPatern}_([a-zA-Z]+)".toRegex()
fun extractVersion(name: String): Version? {
versionRegex.toString()
val versionString = versionRegex.find(name)?.groupValues?.get(1) ?: return null
return Version.parse(versionString)
}
fun extractVersionFromTag(tagName: String): Version? {
return versionRegex.find(tagName)?.value?.let {
Version.parse(it)
}
}
private fun extractPlatformFromName(name: String): Platform? {
val platformString = platformRegex.find(name)?.groupValues?.get(2) ?: return null
return Platform.fromString(platformString)
}
val artifactRegex =
"(?<appName>[a-zA-Z]+)_(?<version>(\\d+\\.\\d+\\.\\d+))_(?<platform>[a-zA-Z]+)_(?<arch>[a-zA-Z0-9]+)\\.(?<extension>.+)".toRegex()
fun getArtifactInfo(name: String): AppArtifactInfo? {
val version = extractVersion(name) ?: return null
val platform = extractPlatformFromName(name) ?: return null
val values = artifactRegex.find(name)?.groups ?: return null
val version = runCatching { values.get("version")?.value }
.getOrNull()
?.let(Version::parse)
?: return null
val platform = runCatching { values.get("platform")?.value }
.getOrNull()
?.let(Platform::fromString)
?: return null
val arch = runCatching { values.get("arch")?.value }
.getOrNull()
?.let(Arch::fromString)
?: return null
return AppArtifactInfo(
version = version,
platform = platform,
arch = arch,
)
}
}

View File

@ -0,0 +1,7 @@
package com.abdownloadmanager
import java.io.File
fun interface UpdateDownloadLocationProvider {
fun getSaveLocation(): File
}

View File

@ -0,0 +1,68 @@
package com.abdownloadmanager
import com.abdownloadmanager.updateapplier.UpdateApplier
import com.abdownloadmanager.updatechecker.UpdateChecker
import com.abdownloadmanager.updatechecker.UpdateInfo
import ir.amirab.util.AppVersionTracker
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
sealed interface UpdateCheckStatus {
data object IDLE : UpdateCheckStatus
data object NoUpdate : UpdateCheckStatus
data object NewUpdate : UpdateCheckStatus
data class Error(val e: Throwable) : UpdateCheckStatus
data object Checking : UpdateCheckStatus
}
class UpdateManager(
private val updateChecker: UpdateChecker,
private val updateApplier: UpdateApplier,
private val appVersionTracker: AppVersionTracker,
) {
private var _newVersionData: MutableStateFlow<UpdateInfo?> = MutableStateFlow(null)
val newVersionData = _newVersionData.asStateFlow()
private val _updateCheckStatus: MutableStateFlow<UpdateCheckStatus> = MutableStateFlow(UpdateCheckStatus.IDLE)
val updateCheckStatus = _updateCheckStatus.asStateFlow()
suspend fun cleanDownloadedFiles() {
kotlin.runCatching {
updateApplier.cleanup()
}.onFailure {
it.printStackTrace()
}
}
fun isUpdateSupported(): Boolean {
return updateApplier.updateSupported()
}
suspend fun checkForUpdate(): UpdateInfo? {
val newUpdateCheck = try {
_updateCheckStatus.update { UpdateCheckStatus.Checking }
val checkedData = updateChecker.check()
_updateCheckStatus.value = if (checkedData == null) {
UpdateCheckStatus.NoUpdate
} else {
UpdateCheckStatus.NewUpdate
}
checkedData
} catch (e: Exception) {
_updateCheckStatus.update { UpdateCheckStatus.Error(e) }
null
}
_newVersionData.update { newUpdateCheck }
return newUpdateCheck
}
suspend fun update() {
_newVersionData.value?.let {
if (updateApplier.updateSupported()) {
updateApplier.applyUpdate(it)
}
}
}
// TODO add onAfter update installed
// ...
}

View File

@ -0,0 +1,109 @@
package com.abdownloadmanager.updateapplier;
import com.abdownloadmanager.updatechecker.UpdateInfo
import com.abdownloadmanager.updatechecker.UpdateSource
import ir.amirab.util.platform.Platform
import java.io.File
class DesktopUpdateApplier(
private val installationFolder: String?,
private val appName: String,
private val updateFolder: String,
private val logDir: String,
private val updateDownloader: UpdateDownloader,
) : UpdateApplier {
private var downloading: Boolean = false
override fun updateSupported(): Boolean {
val installationFolder = installationFolder ?: return false
return File(installationFolder).canWrite()
}
private fun isAppInstalledWithNSIS(): Boolean {
return File(installationFolder, "uninstall.exe").exists()
}
private fun extension(name: String): String {
return name.substringAfterLast('.', "")
}
private fun isArchiveFile(name: String): Boolean {
return name.endsWith(".tar.gz") || name.endsWith(".zip")
}
private fun isExeFile(name: String): Boolean {
return name.endsWith(".exe") || name.endsWith(".zip")
}
override suspend fun applyUpdate(
updateInfo: UpdateInfo,
) {
if (!updateSupported()) {
return
}
val installationFolder = requireNotNull(installationFolder) {
"update applier can only apply update if installation folder is not null"
}
//it is only check for same instance
// if I faced to multiple update (when user press "update" many times)
// I have to cancel this suspension job and create a new instance instead
if (downloading) {
return
}
downloading = true
val downloadableSources = updateInfo.updateSource.filterIsInstance<UpdateSource.DirectDownloadLink>()
var downloadSource = downloadableSources.find {
isArchiveFile(it.name)
}
if (Platform.getCurrentPlatform() == Platform.Desktop.Windows) {
val exeDirectDownloadLink = downloadableSources.find {
isExeFile(it.name)
}
if (isAppInstalledWithNSIS() && exeDirectDownloadLink != null) {
downloadSource = exeDirectDownloadLink
}
}
requireNotNull(downloadSource) {
"Can't find proper download link for your platform! Please update it manually"
}
val downloadedFile = updateDownloader.downloadUpdate(downloadSource)
if (!downloadedFile.exists()) {
downloading = false
return
}
val updateInstaller = when {
isArchiveFile(downloadSource.name) -> {
UpdateInstallerFromArchiveFile(
archiveFile = downloadedFile,
installationFolder = installationFolder,
appFolderInArchive = appName,
folderToExtractUpdate = File(updateFolder).resolve("extracted"),
logDir = logDir,
)
}
isExeFile(downloadedFile.name) -> {
UpdateInstallerByWindowsExecutable(downloadedFile)
}
else -> {
// should not happen btw
error("can't install ${extension(downloadSource.name)} format automatically! please update it manually!")
}
}
// updateDownloader.removeUpdate(updateInfo)
try {
updateInstaller.installUpdate()
} catch (e: Exception) {
throw RuntimeException(
buildString {
appendLine("can't start installation")
e.localizedMessage?.let(this::append)
},
e,
)
}
}
override suspend fun cleanup() {
updateDownloader.removeAllUpdates()
}
}

View File

@ -1,32 +0,0 @@
package com.abdownloadmanager.updateapplier;
import ir.amirab.util.osfileutil.FileUtils
import com.abdownloadmanager.updatechecker.VersionData
import java.io.File
interface UpdateDownloader{
suspend fun download(link:String):File
}
class JavaUpdateApplier(
private val versionData: VersionData,
private val updateDownloader: UpdateDownloader
) : UpdateApplier() {
private var downloading: Boolean = false
override suspend fun applyUpdate() {
//it is only check for same instance
// if I faced to multiple update (when user press "update" many times)
// I have to cancel this suspension job and create a new instance instead
if (downloading){
return
}
downloading=true
val executableFile = updateDownloader.download(versionData.link)
if (!executableFile.exists() || !executableFile.canExecute()){
downloading=false
return
}
//TODO investigate on possible security risks
FileUtils.openFile(executableFile)
}
}

View File

@ -1,5 +1,9 @@
package com.abdownloadmanager.updateapplier
abstract class UpdateApplier{
abstract suspend fun applyUpdate()
import com.abdownloadmanager.updatechecker.UpdateInfo
interface UpdateApplier {
fun updateSupported(): Boolean
suspend fun applyUpdate(updateInfo: UpdateInfo)
suspend fun cleanup()
}

View File

@ -0,0 +1,10 @@
package com.abdownloadmanager.updateapplier
import com.abdownloadmanager.updatechecker.UpdateSource
import java.io.File
interface UpdateDownloader {
suspend fun downloadUpdate(updateDirectDownloadLink: UpdateSource.DirectDownloadLink): File
suspend fun removeUpdate(updateDirectDownloadLink: UpdateSource.DirectDownloadLink)
suspend fun removeAllUpdates()
}

View File

@ -0,0 +1,6 @@
package com.abdownloadmanager.updateapplier
interface UpdateInstaller {
fun installUpdate()
}

View File

@ -0,0 +1,14 @@
package com.abdownloadmanager.updateapplier
import java.io.File
class UpdateInstallerByWindowsExecutable(
private val executable: File,
) : UpdateInstaller {
override fun installUpdate() {
val file = executable.absolutePath
ProcessBuilder()
.command("cmd", "/c", file, "/S")
.start()
}
}

View File

@ -0,0 +1,185 @@
package com.abdownloadmanager.updateapplier
import ir.amirab.util.platform.Platform
import okio.FileSystem
import okio.Path.Companion.toPath
import okio.buffer
import okio.use
import java.io.File
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
/**
* the duty of the script is
* it accepts [folderToExtractUpdate], [installationFolder]
* 1. stop the app
* 2. remove the installed app files
* 3. copy [folderToExtractUpdate] into [installationFolder]
* 4. remove [folderToExtractUpdate]
* 5. start the app again
*/
class UpdateInstallerFromArchiveFile(
private val archiveFile: File,
private val installationFolder: String,
private val folderToExtractUpdate: File,
private val appFolderInArchive: String,
private val logDir: String,
) : UpdateInstaller {
private fun getScriptPath(logFile: String): String {
val platform = Platform.getCurrentPlatform()
val scriptForPlatform = when (platform) {
Platform.Desktop.Linux -> {
"com/abdownloadmanager/updater/updater_linux.sh"
}
Platform.Desktop.Windows -> {
"com/abdownloadmanager/updater/updater_windows.bat"
}
else -> error("script for this platform not found")
}.toPath()
extractTo(archiveFile, folderToExtractUpdate)
val updateFolder = folderToExtractUpdate.resolve(appFolderInArchive)
require(updateFolder.exists()) {
"Can't find required files for this update please update it manually"
}
val scriptExtension = scriptForPlatform.toString().substringAfterLast('.', "")
val scriptContent = FileSystem.RESOURCES.source(scriptForPlatform).buffer().use {
it.readUtf8()
}
val scriptPathInTempFolder = FileSystem.SYSTEM_TEMPORARY_DIRECTORY.resolve(
"abdm-updater.$scriptExtension"
)
scriptPathInTempFolder.toFile().writeText(scriptContent)
val scriptContentFile = scriptPathInTempFolder.toString()
val commandToRun = when (platform) {
Platform.Desktop.Linux -> execInBash(
scriptPath = scriptContentFile,
updateFolder = updateFolder.path,
installationFolder = installationFolder,
logFile = logFile,
)
Platform.Desktop.MacOS -> execInBash(
scriptPath = scriptContentFile,
updateFolder = updateFolder.path,
installationFolder = installationFolder,
logFile = logFile,
)
Platform.Desktop.Windows -> execInCMD(
scriptPath = scriptContentFile,
updateFolder = updateFolder.path,
installationFolder = installationFolder,
logFile = logFile,
)
else -> error("platform ${platform} not supported")
}
val scriptToRun = FileSystem.SYSTEM_TEMPORARY_DIRECTORY.resolve("abdm-updater.run.$scriptExtension")
scriptToRun.toFile().writeText(commandToRun)
return scriptToRun.toString()
}
private fun executeScript() {
val logFile = File(logDir, "update_log.txt")
.apply {
parentFile.mkdirs()
}.path
val scriptPath = getScriptPath(logFile)
val command = when (val p = Platform.getCurrentPlatform()) {
Platform.Desktop.Linux -> arrayOf("bash", scriptPath)
Platform.Desktop.MacOS -> arrayOf("bash", scriptPath)
Platform.Desktop.Windows -> arrayOf("cmd", "/c", scriptPath)
else -> error("platform: $p not supported for updating by script")
}
// println("execute script $command")
ProcessBuilder()
.command(*command)
.apply {
// in linux if I don't remove it the program won't restart
environment().remove("_JPACKAGE_LAUNCHER")
}
.start()
}
private fun execInCMD(
scriptPath: String,
updateFolder: String,
installationFolder: String,
logFile: String,
): String {
return """
cmd /c ""${scriptPath}" "${updateFolder}" "${installationFolder}" > "${logFile}" 2>&1"
""".trimIndent()
}
private fun execInBash(
scriptPath: String,
updateFolder: String,
installationFolder: String,
logFile: String,
): String {
return """
bash "${scriptPath}" "${updateFolder}" "${installationFolder}" > "${logFile}" 2>&1 &
""".trimIndent()
}
override fun installUpdate() {
executeScript()
}
}
private fun extractTo(archiveFile: File, destinationFolder: File) {
val name = archiveFile.name
require(!destinationFolder.isFile) {
"destination folder is a file!"
}
destinationFolder.mkdirs()
require(destinationFolder.isDirectory) {
"destination folder is not created!"
}
when {
name.endsWith(".zip") -> extractZip(archiveFile, destinationFolder)
name.endsWith("tar.gz") -> extractTarGzUsingTar(archiveFile, destinationFolder)
else -> error("archive file not detected for this file name: $name")
}
}
private fun extractZip(zipFile: File, outputDirPath: File) {
ZipInputStream(zipFile.inputStream()).use { zis ->
var entry: ZipEntry? = null
while (true) {
entry = zis.nextEntry
if (entry == null) break
val outputFile = outputDirPath.resolve(entry.name)
if (entry.isDirectory) {
outputFile.mkdirs()
} else {
outputFile.parentFile.mkdirs()
outputFile.outputStream().use { fileOutputStream ->
zis.copyTo(fileOutputStream)
}
}
}
}
}
private fun extractTarGzUsingTar(tarGzFilePath: File, outputDirPath: File) {
val tarCommand = listOf("tar", "-xzvf", tarGzFilePath.path, "-C", outputDirPath.path)
try {
val process = ProcessBuilder(tarCommand)
.start()
val exitCode = process.waitFor()
if (exitCode == 0) {
println("Extraction completed successfully.")
} else {
println("Error during extraction. Exit code: $exitCode")
}
} catch (e: Exception) {
println("Failed to execute tar command: ${e.message}")
}
}

View File

@ -0,0 +1,34 @@
package com.abdownloadmanager.updatechecker
import io.github.z4kn4fein.semver.Version
import ir.amirab.util.platform.Arch
import ir.amirab.util.platform.Platform
import kotlinx.coroutines.delay
class DummyUpdateChecker(currentVersion: Version) : UpdateChecker(currentVersion) {
override suspend fun getMyPlatformLatestVersion(): UpdateInfo {
val newVersion = currentVersion.copy(
major = currentVersion.minor + 1,
preRelease = null,
buildMetadata = null,
)
delay(1000)
// error("Something wrong")
return UpdateInfo(
version = newVersion,
platform = Platform.getCurrentPlatform(),
arch = Arch.getCurrentArch(),
updateSource = listOf(
UpdateSource.DirectDownloadLink(
link = "http://127.0.0.1:8080/ABDownloadManager_1.4.4_windows_x64.zip",
name = "ABDownloadManager_1.4.4_windows_x64.zip",
hash = "md5:0123456789abcdef",
)
),
changeLog = """
1. there is an improve on download engine.
2. fix known bugs.
""".trimIndent()
)
}
}

View File

@ -0,0 +1,54 @@
package com.abdownloadmanager.updatechecker
import GithubApi
import com.abdownloadmanager.ArtifactUtil
import io.github.z4kn4fein.semver.Version
import ir.amirab.util.platform.Arch
import ir.amirab.util.platform.Platform
class GithubUpdateChecker(
currentVersion: Version,
private val githubApi: GithubApi,
) : UpdateChecker(currentVersion) {
override suspend fun getMyPlatformLatestVersion(): UpdateInfo {
return getLatestVersionsForThisDevice()
}
private suspend fun getLatestVersionsForThisDevice(): UpdateInfo {
val release = githubApi.getLatestReleases()
val currentPlatform = Platform.getCurrentPlatform()
val currentArch = Arch.getCurrentArch()
val updateSources = mutableListOf<UpdateSource>()
var foundVersion: Version? = null
var initializedVersionFromAssetNames = false
for (asset in release.assets) {
val v = ArtifactUtil.getArtifactInfo(asset.name) ?: continue
if (v.platform != currentPlatform) continue
if (v.arch != currentArch) continue
if (!initializedVersionFromAssetNames) {
foundVersion = v.version
initializedVersionFromAssetNames = true
}
val isHashFile = asset.name.endsWith(".md5")
if (isHashFile) {
// nothing for now!
} else {
updateSources.add(
UpdateSource.DirectDownloadLink(
asset.downloadLink,
asset.name,
null,
)
)
}
}
return UpdateInfo(
version = foundVersion
?: Version.parse(release.version.substring("v".length)),
platform = currentPlatform,
arch = currentArch,
changeLog = release.body ?: "",
updateSource = updateSources
)
}
}

View File

@ -1,66 +1,17 @@
package com.abdownloadmanager.updatechecker
import GithubApi
import com.abdownloadmanager.ArtifactUtil
import io.github.z4kn4fein.semver.Version
import ir.amirab.util.platform.Platform
import kotlinx.coroutines.delay
private class GithubUpdateChecker(
currentVersion: Version,
val githubApi: GithubApi,
) : UpdateChecker(currentVersion) {
override suspend fun getMyPlatformLatestVersion(): VersionData {
val all=getLatestVersions()
val versionData = all.find { it.platform == Platform.getCurrentPlatform() }
return requireNotNull(versionData){
"could not find latest version for current platform"
}
}
suspend fun getLatestVersions(): List<VersionData> {
val release = githubApi.getLatestReleases()
return release.assets.mapNotNull {
val v = ArtifactUtil.getArtifactInfo(it.name) ?: return@mapNotNull null
VersionData(
name = it.name,
version = v.version,
link = it.downloadLink,
platform = v.platform,
changeLog = release.body?:""
)
}
}
}
class DummyUpdateChecker(currentVersion :Version): UpdateChecker(currentVersion ){
override suspend fun getMyPlatformLatestVersion(): VersionData {
val newVersion=currentVersion.copy(major = currentVersion.major+1)
delay(5000)
error("Something wrong")
return VersionData(
version = newVersion,
platform = Platform.getCurrentPlatform(),
link = "http://localhost:3000/app_1.0.1_windows.msi",
name = "app_1.0.1_windows.msi",
changeLog = """
1. there is an improve on download engine.
2. fix known bugs.
""".trimIndent()
)
}
}
abstract class UpdateChecker(
protected val currentVersion: Version,
) {
abstract suspend fun getMyPlatformLatestVersion(): VersionData
suspend fun check(): VersionData? {
val latest=getMyPlatformLatestVersion()
requireNotNull(latest){ "There is no release for this platform" }
abstract suspend fun getMyPlatformLatestVersion(): UpdateInfo
suspend fun check(): UpdateInfo? {
val latest = getMyPlatformLatestVersion()
require(latest.updateSource.isNotEmpty()) { "There is no release for this platform" }
return latest.takeIf {
it.version>currentVersion
it.version > currentVersion
}
}
}

View File

@ -0,0 +1,21 @@
package com.abdownloadmanager.updatechecker
import io.github.z4kn4fein.semver.Version
import ir.amirab.util.platform.Arch
import ir.amirab.util.platform.Platform
data class UpdateInfo(
val version: Version,
val platform: Platform,
val arch: Arch,
val updateSource: List<UpdateSource>,
val changeLog: String,
)
sealed interface UpdateSource {
data class DirectDownloadLink(
val link: String,
val name: String,
val hash: String?,
) : UpdateSource
}

View File

@ -1,12 +0,0 @@
package com.abdownloadmanager.updatechecker
import io.github.z4kn4fein.semver.Version
import ir.amirab.util.platform.Platform
data class VersionData(
val version: Version,
val platform: Platform,
val name:String,
val link: String,
val changeLog:String,
)

View File

@ -0,0 +1,86 @@
APP_NAME="ABDownloadManager"
awaitTermination(){
local processName="${1:?}"
local count=0
while true; do
local pids=$(pidof "$processName")
if [ -z "$pids" ]; then
break
fi
if [ $count -eq 10 ]; then
echo "timeout waiting for $processName to terminate"
break
fi
echo "waiting for $processName to terminate"
sleep 1
done
}
stopApp(){
echo "stopping the app"
local pids=$(pidof "$APP_NAME")
if [ -z "$pids" ]; then
echo "no process found with name $APP_NAME"
return
fi
kill -9 "$pids"
awaitTermination "$APP_NAME"
if [ $? -ne 0 ]; then
echo "failed to stop $APP_NAME"
return 1
fi
echo "process $APP_NAME stopped"
}
removeCurrentInstallation(){
local installationFolder="${1:?}"
filesToRemove=(
"bin"
"lib"
)
echo "removing current installation"
for filesToRemove in "${filesToRemove[@]}" ; do
echo "executing rm -rf \"$installationFolder/$filesToRemove\""
rm -rf "$installationFolder/$filesToRemove"
done
}
copyUpdateToInstallationFolder(){
local updateFile="$1"
local installationFolder="${2:?"installationFolder not passed"}"
echo "copying update files to installation folder"
echo "executing: cp -a \"$updateFile/.\" $installationFolder"
cp -a "$updateFile/." "$installationFolder"
}
removeUpdateFiles(){
local updateFile="$1"
echo "removing update folder"
echo "executing: rm -rf \"$updateFile\""
rm -rf "$updateFile"
}
executablePath(){
local installationFolder="${1:?}"
echo "$installationFolder/bin/$APP_NAME"
}
executeProgram(){
local installationFolder=$1
local path=$(executablePath "$installationFolder")
echo "starting $APP_NAME..."
echo "executing: \"$path\""
"$path"
}
main(){
local updateFile="$1"
local installationFolder="$2"
stopApp "$installationFolder"
if [ $? -ne 0 ]; then
echo "returning back to program"
executeProgram "$installationFolder"
exit 1
fi
removeCurrentInstallation "$installationFolder"
copyUpdateToInstallationFolder "$updateFile" "$installationFolder"
removeUpdateFiles "$updateFile"
executeProgram "$installationFolder"
}
main "$@"

View File

@ -0,0 +1,82 @@
@echo off
set APP_NAME=ABDownloadManager
call :main "%1" "%2"
goto :eof
:stopApp
echo execute: taskkill /IM %APP_NAME%.exe /F
taskkill /IM %APP_NAME%.exe /F
call :wait_for_termination
echo %APP_NAME% is terminated
goto :eof
:wait_for_termination
echo checking for termination of %APP_NAME%
tasklist /FI "IMAGENAME eq %APP_NAME%.exe" | find /I "%APP_NAME%.exe" >nul 2>&1
if errorlevel 1 (
goto :eof
) else (
ping 127.0.0.1 -n 2 >nul 2>&1
goto wait_for_termination
)
:removeCurrentInstallation
setlocal
set installationFolder=%~1
set filesToRemove=("app" "runtime" "ABDownloadManager.exe" "ABDownloadManager.ico")
for %%f in %filesToRemove% do (
if exist %installationFolder%\%%f (
if exist %installationFolder%\%%f\* (
echo executing rmdir /S /Q %installationFolder%\%%f
rmdir /S /Q "%installationFolder%\%%f"
) else (
echo executing del /F /Q %installationFolder%\%%f
del /F /Q "%installationFolder%\%%f"
)
)
)
endlocal
goto :eof
:copyUpdateToInstallationFolder
setlocal
set updateFile=%1
set installationFolder=%2
echo executing: xcopy /E /I /Y %updateFile% %installationFolder%
xcopy /E /I /Y %updateFile% %installationFolder%
endlocal
goto :eof
:removeUpdateFolder
setlocal
set updateFolder=%1
echo executing rmdir /S /Q "%updateFolder%"
rmdir /S /Q "%updateFolder%"
endlocal
goto :eof
:executeProgram
setlocal
set installationFolder=%~1
set code=%2
set message=%3
echo executing %installationFolder%\%APP_NAME%.exe
start "" %installationFolder%\%APP_NAME%.exe
endlocal
goto :eof
:main
setlocal
set updateFile=%1
set installationFolder=%2
call :stopApp
call :removeCurrentInstallation %installationFolder%
call :copyUpdateToInstallationFolder %updateFile% %installationFolder%
call :removeUpdateFolder %updateFile%
call :executeProgram %installationFolder%
endlocal
goto :eof

View File

@ -0,0 +1,24 @@
package ir.amirab.util
import io.github.z4kn4fein.semver.Version
class AppVersionTracker(
val previousVersion: () -> Version?,
val currentVersion: Version,
) {
fun isNewInstall(): Boolean {
return previousVersion() == null
}
fun isUpgraded(): Boolean {
val previousVersion = previousVersion() ?: return false
return previousVersion < currentVersion
}
fun isDowngraded(): Boolean {
val previousVersion = previousVersion() ?: return false
return previousVersion > currentVersion
}
fun isNewOrUpdated() = isNewInstall() || isUpgraded()
}