From 4d5a38938cc15327ef557eb240d8104303a502f1 Mon Sep 17 00:00:00 2001 From: AmirHossein Abdolmotallebi Date: Thu, 26 Dec 2024 08:43:19 +0330 Subject: [PATCH] added in app update (#319) --- desktop/app/build.gradle.kts | 20 +- desktop/app/resources/common/app.properties | 1 + .../installer/nsis-script-template.nsi | 8 +- .../com/abdownloadmanager/desktop/App.kt | 9 +- .../abdownloadmanager/desktop/AppComponent.kt | 8 +- .../desktop/SharedConstants.kt | 6 + .../abdownloadmanager/desktop/actions/main.kt | 6 +- .../com/abdownloadmanager/desktop/di/Di.kt | 63 ++++- .../desktop/pages/about/AboutPage.kt | 4 +- .../desktop/pages/home/HomeComponent.kt | 38 ++- .../desktop/pages/home/HomeWindow.kt | 5 +- .../desktop/pages/updater/NewUpdatePage.kt | 223 +++++++++++++----- .../desktop/pages/updater/UpdateComponent.kt | 118 +++------ .../UpdateDownloaderViaDownloadSystem.kt | 76 ++++++ .../desktop/pages/updater/UpdaterDialog.kt | 45 ++-- .../com/abdownloadmanager/desktop/ui/Ui.kt | 4 +- .../desktop/ui/theme/Markdown.kt | 86 +++++++ .../desktop/utils/AppInfo.kt | 20 +- .../desktop/utils/AppProperties.kt | 6 + .../utils/native_messaging/NativeMessaging.kt | 10 +- .../resources/configs/app_default.properties | 1 + .../downloader/monitor/DownloadMonitor.kt | 10 +- .../downloader/monitor/IDownloadMonitor.kt | 4 +- gradle/libs.versions.toml | 3 +- .../abdownloadmanager/utils/DownloadSystem.kt | 15 +- .../utils/appinfo/PreviousVersion.kt | 29 +++ .../utils/compose/Scrollbar.kt | 5 + .../resources/locales/en_US.properties | 12 +- shared/updater/build.gradle.kts | 2 + .../com/abdownloadmanager/ArtifactUtil.kt | 43 ++-- .../UpdateDownloadLocationProvider.kt | 7 + .../com/abdownloadmanager/UpdateManager.kt | 68 ++++++ .../updateapplier/DesktopUpdateApplier.kt | 109 +++++++++ .../updateapplier/JavaUpdateApplier.kt | 32 --- .../updateapplier/UpdateApplier.kt | 8 +- .../updateapplier/UpdateDownloader.kt | 10 + .../updateapplier/UpdateInstaller.kt | 6 + .../UpdateInstallerByWindowsExecutable.kt | 14 ++ .../UpdateInstallerFromArchiveFile.kt | 185 +++++++++++++++ .../updatechecker/DummyUpdateChecker.kt | 34 +++ .../updatechecker/GithubUpdateChecker.kt | 54 +++++ .../updatechecker/UpdateChecker.kt | 59 +---- .../updatechecker/UpdateInfo.kt | 21 ++ .../updatechecker/VersionData.kt | 12 - .../updater/updater_linux.sh | 86 +++++++ .../updater/updater_windows.bat | 82 +++++++ .../ir/amirab/util/AppVersionTracker.kt | 24 ++ 47 files changed, 1356 insertions(+), 335 deletions(-) create mode 100644 desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/updater/UpdateDownloaderViaDownloadSystem.kt create mode 100644 desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/theme/Markdown.kt create mode 100644 shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/appinfo/PreviousVersion.kt create mode 100644 shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/compose/Scrollbar.kt create mode 100644 shared/updater/src/main/kotlin/com/abdownloadmanager/UpdateDownloadLocationProvider.kt create mode 100644 shared/updater/src/main/kotlin/com/abdownloadmanager/UpdateManager.kt create mode 100644 shared/updater/src/main/kotlin/com/abdownloadmanager/updateapplier/DesktopUpdateApplier.kt delete mode 100644 shared/updater/src/main/kotlin/com/abdownloadmanager/updateapplier/JavaUpdateApplier.kt create mode 100644 shared/updater/src/main/kotlin/com/abdownloadmanager/updateapplier/UpdateDownloader.kt create mode 100644 shared/updater/src/main/kotlin/com/abdownloadmanager/updateapplier/UpdateInstaller.kt create mode 100644 shared/updater/src/main/kotlin/com/abdownloadmanager/updateapplier/UpdateInstallerByWindowsExecutable.kt create mode 100644 shared/updater/src/main/kotlin/com/abdownloadmanager/updateapplier/UpdateInstallerFromArchiveFile.kt create mode 100644 shared/updater/src/main/kotlin/com/abdownloadmanager/updatechecker/DummyUpdateChecker.kt create mode 100644 shared/updater/src/main/kotlin/com/abdownloadmanager/updatechecker/GithubUpdateChecker.kt create mode 100644 shared/updater/src/main/kotlin/com/abdownloadmanager/updatechecker/UpdateInfo.kt delete mode 100644 shared/updater/src/main/kotlin/com/abdownloadmanager/updatechecker/VersionData.kt create mode 100644 shared/updater/src/main/resources/com/abdownloadmanager/updater/updater_linux.sh create mode 100644 shared/updater/src/main/resources/com/abdownloadmanager/updater/updater_windows.bat create mode 100644 shared/utils/src/main/kotlin/ir/amirab/util/AppVersionTracker.kt diff --git a/desktop/app/build.gradle.kts b/desktop/app/build.gradle.kts index 6b27322..595abe7 100644 --- a/desktop/app/build.gradle.kts +++ b/desktop/app/build.gradle.kts @@ -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 { diff --git a/desktop/app/resources/common/app.properties b/desktop/app/resources/common/app.properties index 7011bd8..0cd13f6 100644 --- a/desktop/app/resources/common/app.properties +++ b/desktop/app/resources/common/app.properties @@ -1,2 +1,3 @@ app.config.path="${user.home}/.abdm/config" +app.system.path="${user.home}/.abdm/system" app.debug="false" diff --git a/desktop/app/resources/installer/nsis-script-template.nsi b/desktop/app/resources/installer/nsis-script-template.nsi index 13fba40..a46b789 100644 --- a/desktop/app/resources/installer/nsis-script-template.nsi +++ b/desktop/app/resources/installer/nsis-script-template.nsi @@ -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 diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/App.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/App.kt index 046ac70..540e883 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/App.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/App.kt @@ -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) { 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) } diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/AppComponent.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/AppComponent.kt index dbd305d..767a37f 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/AppComponent.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/AppComponent.kt @@ -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) diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/SharedConstants.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/SharedConstants.kt index 0a063c0..4172360 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/SharedConstants.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/SharedConstants.kt @@ -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 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 = listOf( BrowserIntegrationModel( BrowserType.Chrome,BuildConfig.INTEGRATION_CHROME_LINK diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/actions/main.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/actions/main.kt index de26bb6..64c369a 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/actions/main.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/actions/main.kt @@ -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, diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/di/Di.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/di/Di.kt index e2f08ba..c356198 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/di/Di.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/di/Di.kt @@ -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 { + DesktopUpdateApplier( + installationFolder = AppInfo.installationFolder, + updateFolder = AppInfo.updateDir.path, + logDir = AppInfo.logDir.path, + appName = AppInfo.name, + updateDownloader = UpdateDownloaderViaDownloadSystem( + get(), + get(), + ), + ) + } single { - 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().get() + }, + currentVersion = AppInfo.version, + ) + } } diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/about/AboutPage.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/about/AboutPage.kt index 60d6a86..7b19fe2 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/about/AboutPage.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/about/AboutPage.kt @@ -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, ) diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/home/HomeComponent.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/home/HomeComponent.kt index 13cf8b2..8bf3ce8 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/home/HomeComponent.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/home/HomeComponent.kt @@ -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( diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/home/HomeWindow.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/home/HomeWindow.kt index 269d809..52e87b5 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/home/HomeWindow.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/home/HomeWindow.kt @@ -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(), ) diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/updater/NewUpdatePage.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/updater/NewUpdatePage.kt index 74138c5..8106c07 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/updater/NewUpdatePage.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/updater/NewUpdatePage.kt @@ -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) - Column( + 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 = myStringResource(Res.string.update_available), + fontSize = myTextSizes.xl, + fontWeight = FontWeight.Bold + ) + Spacer(Modifier.width(8.dp)) + Text( + 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)) + 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), + newVersionInfo.changeLog + ) + } + Actions( + Modifier.fillMaxWidth(), + update, + cancel + ) + } + } +} + +@Composable +private fun BoxScope.BackgroundEffects() { + Box( Modifier - .fillMaxSize() - .padding(horizontal = 16.dp) - .padding( - bottom = 16.dp, - top = 8.dp + .align(Alignment.TopCenter) + .offset(y = (-148).dp) + .fillMaxWidth(0.5f) + .height(200.dp) + .blur( + 56.dp, + edgeTreatment = BlurredEdgeTreatment.Unbounded ) - ) { - Text( - text = "There is a new version of app is available", - fontSize = myTextSizes.xl, - fontWeight = FontWeight.Bold - ) - Spacer(Modifier.height(4.dp)) - WithContentAlpha(0.75f){ - Text( - text = "you can press on update button to update to the latest version", - fontSize = myTextSizes.base, + .clip(CircleShape) + .background( + myColors.primary / 0.15f ) - } - Spacer(Modifier.height(8.dp)) - Row { - RenderKeyValue("Current Version", currentVersion.toString()) - Spacer(Modifier.width(16.dp)) - RenderKeyValue("Latest Version", versionVersionData.version.toString()) - } - Spacer(Modifier.height(8.dp)) - RenderChangeLog( + ) + 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() - .weight(1f), - versionVersionData.changeLog + .height(1.dp) + .background(myColors.onBackground / 0.15f) ) - Spacer(Modifier.height(8.dp)) Row( - Modifier.fillMaxWidth(), + 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, + ) } } \ No newline at end of file diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/updater/UpdateComponent.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/updater/UpdateComponent.kt index 0f1994a..cfa8992 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/updater/UpdateComponent.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/updater/UpdateComponent.kt @@ -1,89 +1,64 @@ 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.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 + updateManager + .checkForUpdate() + ?.let { + showNewUpdate() } - updateCheckStatus = UpdateStatus.IDLE - }catch (e:Exception){ - updateCheckStatus = UpdateStatus.Error(e) - } } } @@ -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) - } - -} \ No newline at end of file diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/updater/UpdateDownloaderViaDownloadSystem.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/updater/UpdateDownloaderViaDownloadSystem.kt new file mode 100644 index 0000000..34fb3cd --- /dev/null +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/updater/UpdateDownloaderViaDownloadSystem.kt @@ -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 + ) + } + } +} \ No newline at end of file diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/updater/UpdaterDialog.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/updater/UpdaterDialog.kt index 585cc1f..ec8bcfe 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/updater/UpdaterDialog.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/pages/updater/UpdaterDialog.kt @@ -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 = { diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/Ui.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/Ui.kt index 97d6b33..28ba4a1 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/Ui.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/Ui.kt @@ -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) diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/theme/Markdown.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/theme/Markdown.kt new file mode 100644 index 0000000..211b90a --- /dev/null +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/ui/theme/Markdown.kt @@ -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, + ), + ) +} \ No newline at end of file diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/AppInfo.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/AppInfo.kt index 630c90d..dc1a866 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/AppInfo.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/AppInfo.kt @@ -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 // /bin/ABDownloadManager + Platform.Desktop.MacOS -> it.parentFile // not checked yet + Platform.Desktop.Windows -> it // /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() { } \ No newline at end of file diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/AppProperties.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/AppProperties.kt index 5a56f8a..307ec47 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/AppProperties.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/AppProperties.kt @@ -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 diff --git a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/native_messaging/NativeMessaging.kt b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/native_messaging/NativeMessaging.kt index 6b081c5..bb147be 100644 --- a/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/native_messaging/NativeMessaging.kt +++ b/desktop/app/src/main/kotlin/com/abdownloadmanager/desktop/utils/native_messaging/NativeMessaging.kt @@ -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( diff --git a/desktop/app/src/main/resources/configs/app_default.properties b/desktop/app/src/main/resources/configs/app_default.properties index 7011bd8..0cd13f6 100644 --- a/desktop/app/src/main/resources/configs/app_default.properties +++ b/desktop/app/src/main/resources/configs/app_default.properties @@ -1,2 +1,3 @@ app.config.path="${user.home}/.abdm/config" +app.system.path="${user.home}/.abdm/system" app.debug="false" diff --git a/downloader/monitor/src/main/kotlin/ir/amirab/downloader/monitor/DownloadMonitor.kt b/downloader/monitor/src/main/kotlin/ir/amirab/downloader/monitor/DownloadMonitor.kt index 19d1a0f..17aba62 100644 --- a/downloader/monitor/src/main/kotlin/ir/amirab/downloader/monitor/DownloadMonitor.kt +++ b/downloader/monitor/src/main/kotlin/ir/amirab/downloader/monitor/DownloadMonitor.kt @@ -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 } } } \ No newline at end of file diff --git a/downloader/monitor/src/main/kotlin/ir/amirab/downloader/monitor/IDownloadMonitor.kt b/downloader/monitor/src/main/kotlin/ir/amirab/downloader/monitor/IDownloadMonitor.kt index cfd6f63..4d6a551 100644 --- a/downloader/monitor/src/main/kotlin/ir/amirab/downloader/monitor/IDownloadMonitor.kt +++ b/downloader/monitor/src/main/kotlin/ir/amirab/downloader/monitor/IDownloadMonitor.kt @@ -14,8 +14,8 @@ interface IDownloadMonitor { val activeDownloadCount: StateFlow suspend fun waitForDownloadToFinishOrCancel( - id: Long - ): Boolean + id: Long, + ) } fun IDownloadMonitor.isDownloadActiveFlow( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 938c92f..71915fa 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/DownloadSystem.kt b/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/DownloadSystem.kt index be5c3f3..d504a29 100644 --- a/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/DownloadSystem.kt +++ b/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/DownloadSystem.kt @@ -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 { + return downloadMonitor.downloadListFlow.value.filter { + it.folder == folder + } + } suspend fun getFilePathById(id: Long): File? { diff --git a/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/appinfo/PreviousVersion.kt b/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/appinfo/PreviousVersion.kt new file mode 100644 index 0000000..48d798b --- /dev/null +++ b/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/appinfo/PreviousVersion.kt @@ -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() + } + } +} \ No newline at end of file diff --git a/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/compose/Scrollbar.kt b/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/compose/Scrollbar.kt new file mode 100644 index 0000000..af8adbb --- /dev/null +++ b/shared/app-utils/src/main/kotlin/com/abdownloadmanager/utils/compose/Scrollbar.kt @@ -0,0 +1,5 @@ +package com.abdownloadmanager.utils.compose + +fun androidx.compose.foundation.v2.ScrollbarAdapter.needScroll(): Boolean { + return contentSize > viewportSize +} \ No newline at end of file diff --git a/shared/resources/src/main/resources/com/abdownloadmanager/resources/locales/en_US.properties b/shared/resources/src/main/resources/com/abdownloadmanager/resources/locales/en_US.properties index afe2050..6d77bf5 100644 --- a/shared/resources/src/main/resources/com/abdownloadmanager/resources/locales/en_US.properties +++ b/shared/resources/src/main/resources/com/abdownloadmanager/resources/locales/en_US.properties @@ -315,4 +315,14 @@ contribute=Contribute 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! \ No newline at end of file +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}} \ No newline at end of file diff --git a/shared/updater/build.gradle.kts b/shared/updater/build.gradle.kts index 1fccfe3..5161b2c 100644 --- a/shared/updater/build.gradle.kts +++ b/shared/updater/build.gradle.kts @@ -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) } \ No newline at end of file diff --git a/shared/updater/src/main/kotlin/com/abdownloadmanager/ArtifactUtil.kt b/shared/updater/src/main/kotlin/com/abdownloadmanager/ArtifactUtil.kt index 0a2539c..18f640c 100644 --- a/shared/updater/src/main/kotlin/com/abdownloadmanager/ArtifactUtil.kt +++ b/shared/updater/src/main/kotlin/com/abdownloadmanager/ArtifactUtil.kt @@ -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 = + "(?[a-zA-Z]+)_(?(\\d+\\.\\d+\\.\\d+))_(?[a-zA-Z]+)_(?[a-zA-Z0-9]+)\\.(?.+)".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, ) - - } - } \ No newline at end of file diff --git a/shared/updater/src/main/kotlin/com/abdownloadmanager/UpdateDownloadLocationProvider.kt b/shared/updater/src/main/kotlin/com/abdownloadmanager/UpdateDownloadLocationProvider.kt new file mode 100644 index 0000000..ff1db2d --- /dev/null +++ b/shared/updater/src/main/kotlin/com/abdownloadmanager/UpdateDownloadLocationProvider.kt @@ -0,0 +1,7 @@ +package com.abdownloadmanager + +import java.io.File + +fun interface UpdateDownloadLocationProvider { + fun getSaveLocation(): File +} \ No newline at end of file diff --git a/shared/updater/src/main/kotlin/com/abdownloadmanager/UpdateManager.kt b/shared/updater/src/main/kotlin/com/abdownloadmanager/UpdateManager.kt new file mode 100644 index 0000000..d2f095b --- /dev/null +++ b/shared/updater/src/main/kotlin/com/abdownloadmanager/UpdateManager.kt @@ -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 = MutableStateFlow(null) + val newVersionData = _newVersionData.asStateFlow() + private val _updateCheckStatus: MutableStateFlow = 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 + // ... +} diff --git a/shared/updater/src/main/kotlin/com/abdownloadmanager/updateapplier/DesktopUpdateApplier.kt b/shared/updater/src/main/kotlin/com/abdownloadmanager/updateapplier/DesktopUpdateApplier.kt new file mode 100644 index 0000000..cbf0d8f --- /dev/null +++ b/shared/updater/src/main/kotlin/com/abdownloadmanager/updateapplier/DesktopUpdateApplier.kt @@ -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() + 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() + } +} diff --git a/shared/updater/src/main/kotlin/com/abdownloadmanager/updateapplier/JavaUpdateApplier.kt b/shared/updater/src/main/kotlin/com/abdownloadmanager/updateapplier/JavaUpdateApplier.kt deleted file mode 100644 index d6bf030..0000000 --- a/shared/updater/src/main/kotlin/com/abdownloadmanager/updateapplier/JavaUpdateApplier.kt +++ /dev/null @@ -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) - } -} diff --git a/shared/updater/src/main/kotlin/com/abdownloadmanager/updateapplier/UpdateApplier.kt b/shared/updater/src/main/kotlin/com/abdownloadmanager/updateapplier/UpdateApplier.kt index f523b13..4bc296f 100644 --- a/shared/updater/src/main/kotlin/com/abdownloadmanager/updateapplier/UpdateApplier.kt +++ b/shared/updater/src/main/kotlin/com/abdownloadmanager/updateapplier/UpdateApplier.kt @@ -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() } \ No newline at end of file diff --git a/shared/updater/src/main/kotlin/com/abdownloadmanager/updateapplier/UpdateDownloader.kt b/shared/updater/src/main/kotlin/com/abdownloadmanager/updateapplier/UpdateDownloader.kt new file mode 100644 index 0000000..f5659f8 --- /dev/null +++ b/shared/updater/src/main/kotlin/com/abdownloadmanager/updateapplier/UpdateDownloader.kt @@ -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() +} \ No newline at end of file diff --git a/shared/updater/src/main/kotlin/com/abdownloadmanager/updateapplier/UpdateInstaller.kt b/shared/updater/src/main/kotlin/com/abdownloadmanager/updateapplier/UpdateInstaller.kt new file mode 100644 index 0000000..ce24160 --- /dev/null +++ b/shared/updater/src/main/kotlin/com/abdownloadmanager/updateapplier/UpdateInstaller.kt @@ -0,0 +1,6 @@ +package com.abdownloadmanager.updateapplier + +interface UpdateInstaller { + fun installUpdate() +} + diff --git a/shared/updater/src/main/kotlin/com/abdownloadmanager/updateapplier/UpdateInstallerByWindowsExecutable.kt b/shared/updater/src/main/kotlin/com/abdownloadmanager/updateapplier/UpdateInstallerByWindowsExecutable.kt new file mode 100644 index 0000000..889a252 --- /dev/null +++ b/shared/updater/src/main/kotlin/com/abdownloadmanager/updateapplier/UpdateInstallerByWindowsExecutable.kt @@ -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() + } +} \ No newline at end of file diff --git a/shared/updater/src/main/kotlin/com/abdownloadmanager/updateapplier/UpdateInstallerFromArchiveFile.kt b/shared/updater/src/main/kotlin/com/abdownloadmanager/updateapplier/UpdateInstallerFromArchiveFile.kt new file mode 100644 index 0000000..7bdc442 --- /dev/null +++ b/shared/updater/src/main/kotlin/com/abdownloadmanager/updateapplier/UpdateInstallerFromArchiveFile.kt @@ -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}") + } +} diff --git a/shared/updater/src/main/kotlin/com/abdownloadmanager/updatechecker/DummyUpdateChecker.kt b/shared/updater/src/main/kotlin/com/abdownloadmanager/updatechecker/DummyUpdateChecker.kt new file mode 100644 index 0000000..f095f92 --- /dev/null +++ b/shared/updater/src/main/kotlin/com/abdownloadmanager/updatechecker/DummyUpdateChecker.kt @@ -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() + ) + } +} \ No newline at end of file diff --git a/shared/updater/src/main/kotlin/com/abdownloadmanager/updatechecker/GithubUpdateChecker.kt b/shared/updater/src/main/kotlin/com/abdownloadmanager/updatechecker/GithubUpdateChecker.kt new file mode 100644 index 0000000..c6fe517 --- /dev/null +++ b/shared/updater/src/main/kotlin/com/abdownloadmanager/updatechecker/GithubUpdateChecker.kt @@ -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() + 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 + ) + } +} \ No newline at end of file diff --git a/shared/updater/src/main/kotlin/com/abdownloadmanager/updatechecker/UpdateChecker.kt b/shared/updater/src/main/kotlin/com/abdownloadmanager/updatechecker/UpdateChecker.kt index 90d16d7..d1d5b11 100644 --- a/shared/updater/src/main/kotlin/com/abdownloadmanager/updatechecker/UpdateChecker.kt +++ b/shared/updater/src/main/kotlin/com/abdownloadmanager/updatechecker/UpdateChecker.kt @@ -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 { - 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 } } } \ No newline at end of file diff --git a/shared/updater/src/main/kotlin/com/abdownloadmanager/updatechecker/UpdateInfo.kt b/shared/updater/src/main/kotlin/com/abdownloadmanager/updatechecker/UpdateInfo.kt new file mode 100644 index 0000000..d78c4e6 --- /dev/null +++ b/shared/updater/src/main/kotlin/com/abdownloadmanager/updatechecker/UpdateInfo.kt @@ -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, + val changeLog: String, +) + +sealed interface UpdateSource { + data class DirectDownloadLink( + val link: String, + val name: String, + val hash: String?, + ) : UpdateSource +} diff --git a/shared/updater/src/main/kotlin/com/abdownloadmanager/updatechecker/VersionData.kt b/shared/updater/src/main/kotlin/com/abdownloadmanager/updatechecker/VersionData.kt deleted file mode 100644 index 6173597..0000000 --- a/shared/updater/src/main/kotlin/com/abdownloadmanager/updatechecker/VersionData.kt +++ /dev/null @@ -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, -) \ No newline at end of file diff --git a/shared/updater/src/main/resources/com/abdownloadmanager/updater/updater_linux.sh b/shared/updater/src/main/resources/com/abdownloadmanager/updater/updater_linux.sh new file mode 100644 index 0000000..900969b --- /dev/null +++ b/shared/updater/src/main/resources/com/abdownloadmanager/updater/updater_linux.sh @@ -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 "$@" \ No newline at end of file diff --git a/shared/updater/src/main/resources/com/abdownloadmanager/updater/updater_windows.bat b/shared/updater/src/main/resources/com/abdownloadmanager/updater/updater_windows.bat new file mode 100644 index 0000000..6a3c854 --- /dev/null +++ b/shared/updater/src/main/resources/com/abdownloadmanager/updater/updater_windows.bat @@ -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 + + diff --git a/shared/utils/src/main/kotlin/ir/amirab/util/AppVersionTracker.kt b/shared/utils/src/main/kotlin/ir/amirab/util/AppVersionTracker.kt new file mode 100644 index 0000000..e734406 --- /dev/null +++ b/shared/utils/src/main/kotlin/ir/amirab/util/AppVersionTracker.kt @@ -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() +} \ No newline at end of file