diff --git a/.github/scripts/updateKnownGoodVersionsWithDownloads.js b/.github/scripts/updateKnownGoodVersionsWithDownloads.js new file mode 100644 index 00000000..c80d8b6f --- /dev/null +++ b/.github/scripts/updateKnownGoodVersionsWithDownloads.js @@ -0,0 +1,66 @@ +const fs = require('fs'); +const axios = require('axios'); + +function convertUrl(url) { + const match = url.match(/\/(\d+\.\d+\.\d+\.\d+)\/(.+)\/(.+\.zip)/); + if (match) { + const [, version, platform, filename] = match; + return `https://cdn.npmmirror.com/binaries/chrome-for-testing/${version}/${platform}/${filename}`; + } + return url; +} + +function processJson(inputJson) { + const processedJson = JSON.parse(JSON.stringify(inputJson)); + + processedJson.versions.forEach(version => { + if (version.downloads && version.downloads.chromedriver) { + version.downloads.chromedriver.forEach(item => { + item.url = convertUrl(item.url); + }); + } + if (version.downloads) { + version.downloads = { chromedriver: version.downloads.chromedriver || [] }; + } + }); + + return processedJson; +} + +async function fetchAndProcessJson() { + const url = "https://raw.githubusercontent.com/GoogleChromeLabs/chrome-for-testing/main/data/known-good-versions-with-downloads.json"; + + try { + console.log(`Fetching JSON from: ${url}`); + const response = await axios.get(url, { timeout: 10000 }); + console.log(`Status Code: ${response.status}`); + + const inputJson = response.data; + const processedJson = processJson(inputJson); + + fs.writeFileSync('known-good-versions-with-downloads.json', JSON.stringify(processedJson, null, 2)); + console.log("JSON processing complete. Output saved to known-good-versions-with-downloads.json"); + } catch (error) { + if (error.response) { + // The request was made and the server responded with a status code that falls out of the range of 2xx + console.error(`HTTP error! status: ${error.response.status}`); + } else if (error.request) { + // The request was made but no response was received + console.error('No response received:', error.message); + } else { + // Something happened in setting up the request that triggered an Error + console.error('Error:', error.message); + } + throw error; + } +} + +// If the script is run directly, execute the processing +if (require.main === module) { + fetchAndProcessJson().catch(error => { + console.error("Script failed:", error); + process.exit(1); + }); +} + +module.exports = { processJson, fetchAndProcessJson }; \ No newline at end of file diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index df39a949..09f31664 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -104,7 +104,20 @@ jobs: node-version: '16' - name: Install dependencies - run: npm install semver fs-extra + run: npm install semver fs-extra axios + + - name: Update known-good-versions-with-downloads.json + run: node .github/scripts/updateKnownGoodVersionsWithDownloads.js + + - name: Upload known-good-versions-with-downloads.json + id: upload_release_app + uses: CrossPaste/oss-upload-action@main + with: + key-id: ${{ secrets.ALIYUN_ACCESSKEY_ID }} + key-secret: ${{ secrets.ALIYUN_ACCESSKEY_SECRET }} + region: oss-cn-shenzhen + bucket: crosspaste-desktop + assets: known-good-versions-with-downloads.json:known-good-versions-with-downloads.json - name: Validate and update version run: node .github/scripts/validateAndUpdateVersion.js @@ -188,7 +201,7 @@ jobs: - name: Upload release app id: upload_release_app - uses: JohnGuan/oss-upload-action@main + uses: CrossPaste/oss-upload-action@main with: key-id: ${{ secrets.ALIYUN_ACCESSKEY_ID }} key-secret: ${{ secrets.ALIYUN_ACCESSKEY_SECRET }} diff --git a/.gitignore b/.gitignore index da1b5069..cdd17f6b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ development.properties local.properties local.conveyor.conf package*.json +selenium-manager +selenium-manager.exe !src/**/build/ build buildSrc/build/ @@ -17,8 +19,6 @@ composeApp/GPUCache/ composeApp/build/ composeApp/dylib/ composeApp/jbr/ -chrome-headless-shell-*/ -chromedriver-*/ logs/ node_modules/ output*/ diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index b4e92c06..65835d72 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -6,6 +6,7 @@ import org.yaml.snakeyaml.Yaml import org.yaml.snakeyaml.constructor.Constructor import java.io.FileReader import java.util.Properties +import java.util.zip.ZipFile val versionProperties = Properties() versionProperties.load( @@ -265,6 +266,12 @@ compose.desktop { } } + val seleniumManagerJar: File = + configurations.detachedConfiguration(dependencies.create("org.seleniumhq.selenium:selenium-manager:4.23.1")) + .resolve().first() + + extract(seleniumManagerJar, appResourcesRootDir.get().asFile) + // Add download info of jbr on all platforms val jbrYamlFile = project.projectDir.toPath().resolve("jbr.yaml").toFile() val jbrReleases = loadJbrReleases(jbrYamlFile) @@ -273,11 +280,6 @@ compose.desktop { jbrDir.mkdirs() } - // Add download info of chrome-driver and chrome-headless-shell on all platforms - val webDriverProperties = Properties() - val webDriverFile = project.projectDir.toPath().resolve("webDriver.properties").toFile() - webDriverProperties.load(FileReader(webDriverFile)) - if (os.isMacOsX || buildFullPlatform) { targetFormats(TargetFormat.Dmg) @@ -305,26 +307,18 @@ compose.desktop { val result = process.inputStream.bufferedReader().use { it.readText() }.trim() if (result == "x86_64" || buildFullPlatform) { - getAllDependencies( + getJbrReleases( + "osx-x64", jbrReleases, jbrDir, - webDriverProperties, - appResourcesRootDir.get(), - "osx-x64", - "mac-x64", - "macos-x64", ) } if (result == "arm64" || buildFullPlatform) { - getAllDependencies( + getJbrReleases( + "osx-aarch64", jbrReleases, jbrDir, - webDriverProperties, - appResourcesRootDir.get(), - "osx-aarch64", - "mac-arm64", - "macos-arm64", ) } } @@ -337,14 +331,10 @@ compose.desktop { val architecture = System.getProperty("os.arch") if (architecture.contains("64")) { - getAllDependencies( + getJbrReleases( + "windows-x64", jbrReleases, jbrDir, - webDriverProperties, - appResourcesRootDir.get(), - "windows-x64", - "win64", - "windows-x64", ) } else { throw IllegalArgumentException("Unsupported architecture: $architecture") @@ -356,14 +346,10 @@ compose.desktop { linux { targetFormats(TargetFormat.Deb) - getAllDependencies( + getJbrReleases( + "linux-x64", jbrReleases, jbrDir, - webDriverProperties, - appResourcesRootDir.get(), - "linux-x64", - "linux64", - "linux-x64", ) } } @@ -391,27 +377,6 @@ configurations.all { } } -fun getAllDependencies( - jbrReleases: JbrReleases, - jbrDir: File, - webDriverProperties: Properties, - chromeDir: Directory, - jbrArch: String, - chromeArch: String, - chromeDirName: String, -) { - getJbrReleases( - jbrArch, - jbrReleases, - jbrDir, - ) - getChromeDriver( - chromeArch, - webDriverProperties, - chromeDir.dir(chromeDirName), - ) -} - fun getJbrReleases( arch: String, jbrReleases: JbrReleases, @@ -484,6 +449,23 @@ fun loadJbrReleases(file: File): JbrReleases { } } +fun extractFile( + zip: ZipFile, + entry: java.util.zip.ZipEntry, + targetDir: Directory, +) { + val targetFile = targetDir.file(entry.name.substringAfterLast("/")) + targetFile.asFile.parentFile.mkdirs() + zip.getInputStream(entry).use { input -> + targetFile.asFile.outputStream().use { output -> + input.copyTo(output) + } + } + // Make the file executable + targetFile.asFile.setExecutable(true, false) + println("Extracted: ${targetFile.asFile.absolutePath}") +} + data class JbrReleases( var jbr: Map = mutableMapOf(), ) @@ -492,3 +474,45 @@ data class JbrDetails( var url: String = "", var sha512: String = "", ) + +fun extract( + jar: File, + outDir: File, +) { + ZipFile(jar).use { zip -> + zip.entries().asSequence().forEach { entry -> + when (entry.name) { + "org/openqa/selenium/manager/linux/selenium-manager" -> { + extractFile(zip, entry, outDir.resolve("linux-x64")) + } + "org/openqa/selenium/manager/macos/selenium-manager" -> { + extractFile(zip, entry, outDir.resolve("macos-x64")) + extractFile(zip, entry, outDir.resolve("macos-arm64")) + } + "org/openqa/selenium/manager/windows/selenium-manager.exe" -> { + extractFile(zip, entry, outDir.resolve("windows-x64")) + } + } + } + } +} + +fun extractFile( + zip: ZipFile, + entry: java.util.zip.ZipEntry, + targetDir: File, +) { + val targetFile = targetDir.resolve(entry.name.substringAfterLast("/")) + targetFile.parentFile.mkdirs() + if (!targetFile.exists() || targetFile.lastModified() < entry.lastModifiedTime.toMillis()) { + zip.getInputStream(entry).use { input -> + targetFile.outputStream().use { output -> + input.copyTo(output) + } + } + targetFile.setExecutable(true, false) + println("Extracted: ${targetFile.absolutePath}") + } else { + println("Skipped (up to date): ${targetFile.absolutePath}") + } +} diff --git a/composeApp/src/commonMain/kotlin/com/crosspaste/app/AppFileType.kt b/composeApp/src/commonMain/kotlin/com/crosspaste/app/AppFileType.kt index 33080fd9..6163691a 100644 --- a/composeApp/src/commonMain/kotlin/com/crosspaste/app/AppFileType.kt +++ b/composeApp/src/commonMain/kotlin/com/crosspaste/app/AppFileType.kt @@ -5,6 +5,7 @@ enum class AppFileType { USER, LOG, ENCRYPT, + MODULE, DATA, HTML, ICON, // use for app icon diff --git a/composeApp/src/commonMain/kotlin/com/crosspaste/paste/ChromeService.kt b/composeApp/src/commonMain/kotlin/com/crosspaste/html/ChromeService.kt similarity index 60% rename from composeApp/src/commonMain/kotlin/com/crosspaste/paste/ChromeService.kt rename to composeApp/src/commonMain/kotlin/com/crosspaste/html/ChromeService.kt index 66a17d35..877a4492 100644 --- a/composeApp/src/commonMain/kotlin/com/crosspaste/paste/ChromeService.kt +++ b/composeApp/src/commonMain/kotlin/com/crosspaste/html/ChromeService.kt @@ -1,7 +1,9 @@ -package com.crosspaste.paste +package com.crosspaste.html interface ChromeService { + var startSuccess: Boolean + fun html2Image(html: String): ByteArray? fun quit() diff --git a/composeApp/src/commonMain/kotlin/com/crosspaste/image/ImageLoader.kt b/composeApp/src/commonMain/kotlin/com/crosspaste/image/ImageLoader.kt index 28c71e2a..157b56c9 100644 --- a/composeApp/src/commonMain/kotlin/com/crosspaste/image/ImageLoader.kt +++ b/composeApp/src/commonMain/kotlin/com/crosspaste/image/ImageLoader.kt @@ -1,17 +1,13 @@ package com.crosspaste.image +import com.crosspaste.utils.Loader import okio.Path -interface ImageLoader { +interface FaviconLoader : Loader - fun load(value: T): R? -} - -interface FaviconLoader : ImageLoader - -interface FileExtImageLoader : ImageLoader +interface FileExtImageLoader : Loader -interface ThumbnailLoader : ImageLoader { +interface ThumbnailLoader : Loader { // Based on the original path, calculate the thumbnail path fun getThumbnailPath(path: Path): Path diff --git a/composeApp/src/commonMain/kotlin/com/crosspaste/module/ModuleLoader.kt b/composeApp/src/commonMain/kotlin/com/crosspaste/module/ModuleLoader.kt new file mode 100644 index 00000000..fcc145b3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/crosspaste/module/ModuleLoader.kt @@ -0,0 +1,95 @@ +package com.crosspaste.module + +import com.crosspaste.app.AppFileType +import com.crosspaste.path.UserDataPathProvider +import com.crosspaste.utils.CodecsUtils +import com.crosspaste.utils.FileUtils +import com.crosspaste.utils.Loader +import com.crosspaste.utils.RetryUtils +import okio.Path + +interface ModuleLoader : Loader { + + val retryUtils: RetryUtils + + val fileUtils: FileUtils + + val codecsUtils: CodecsUtils + + val userDataPathProvider: UserDataPathProvider + + /** + * Verify module by path and sha256 + */ + fun verifyInstall( + path: Path, + sha256: String, + ): Boolean { + return codecsUtils.sha256(path) == sha256 + } + + /** + * Install module from path to path + */ + fun installModule( + downloadPath: Path, + installPath: Path, + ): Boolean + + /** + * Download module from url to path + */ + fun downloadModule( + url: String, + path: Path, + ): Boolean + + fun makeInstalled(installPath: Path) { + fileUtils.createFile(installPath.resolve(".success")) + } + + fun installed(installPath: Path): Boolean { + return fileUtils.existFile(installPath.resolve(".success")) + } + + override fun load(value: ModuleLoaderConfig): Boolean { + val installPath = value.installPath + for (moduleItem in value.moduleItems) { + val urls = moduleItem.getUrls() + val installResult: Boolean? = + retryUtils.retry(value.retryNumber) { + val downTempPath = userDataPathProvider.resolve(moduleItem.downloadFileName, AppFileType.TEMP) + if (!fileUtils.existFile(downTempPath)) { + if (!downloadModule(urls[it], downTempPath)) { + fileUtils.deleteFile(downTempPath) + return@retry null + } + } + + if (!verifyInstall(downTempPath, moduleItem.sha256)) { + fileUtils.deleteFile(downTempPath) + return@retry null + } + + if (installModule(downTempPath, installPath)) { + return@retry true + } else { + return@retry null + } + } + + if (installResult != true) { + return false + } + } + + makeInstalled(installPath) + + for (moduleItem in value.moduleItems) { + val downTempPath = userDataPathProvider.resolve(moduleItem.downloadFileName, AppFileType.TEMP) + fileUtils.deleteFile(downTempPath) + } + + return true + } +} diff --git a/composeApp/src/commonMain/kotlin/com/crosspaste/module/ModuleLoaderConfig.kt b/composeApp/src/commonMain/kotlin/com/crosspaste/module/ModuleLoaderConfig.kt new file mode 100644 index 00000000..df4cd2b0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/crosspaste/module/ModuleLoaderConfig.kt @@ -0,0 +1,36 @@ +package com.crosspaste.module + +import okio.Path + +data class ModuleLoaderConfig( + val installPath: Path, + val moduleName: String, + val moduleItems: List, + val retryNumber: Int = 2, +) { + fun getModuleItem(moduleItemName: String): ModuleItem? { + return moduleItems.find { it.moduleItemName == moduleItemName } + } +} + +data class ModuleItem( + val hosts: List, + val path: String, + val moduleItemName: String, + val downloadFileName: String = path.substringAfterLast("/"), + val relativePath: List, + val sha256: String, +) { + + fun getModuleFilePath(installPath: Path): Path { + var path = installPath + relativePath.forEach { + path = path.resolve(it) + } + return path + } + + fun getUrls(): List { + return hosts.map { host -> "$host$path" } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/crosspaste/module/ServiceModule.kt b/composeApp/src/commonMain/kotlin/com/crosspaste/module/ServiceModule.kt new file mode 100644 index 00000000..5fb0e258 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/crosspaste/module/ServiceModule.kt @@ -0,0 +1,6 @@ +package com.crosspaste.module + +interface ServiceModule { + + fun getModuleLoaderConfig(): ModuleLoaderConfig? +} diff --git a/composeApp/src/commonMain/kotlin/com/crosspaste/path/PathProvider.kt b/composeApp/src/commonMain/kotlin/com/crosspaste/path/PathProvider.kt index 3979a7fa..e8910b20 100644 --- a/composeApp/src/commonMain/kotlin/com/crosspaste/path/PathProvider.kt +++ b/composeApp/src/commonMain/kotlin/com/crosspaste/path/PathProvider.kt @@ -34,10 +34,11 @@ interface PathProvider { } fun autoCreateDir(path: Path) { - if (!path.toFile().exists()) { - if (!path.toFile().mkdirs()) { - throw PasteException(StandardErrorCode.CANT_CREATE_DIR.toErrorCode(), "Failed to create directory: $path") - } + if (!fileUtils.createDir(path)) { + throw PasteException( + StandardErrorCode.CANT_CREATE_DIR.toErrorCode(), + "Failed to create directory: $path", + ) } } } diff --git a/composeApp/src/commonMain/kotlin/com/crosspaste/utils/CodecsUtils.kt b/composeApp/src/commonMain/kotlin/com/crosspaste/utils/CodecsUtils.kt index d67eb368..a5d2ab7b 100644 --- a/composeApp/src/commonMain/kotlin/com/crosspaste/utils/CodecsUtils.kt +++ b/composeApp/src/commonMain/kotlin/com/crosspaste/utils/CodecsUtils.kt @@ -1,5 +1,7 @@ package com.crosspaste.utils +import okio.Path + expect fun getCodecsUtils(): CodecsUtils interface CodecsUtils { @@ -17,4 +19,6 @@ interface CodecsUtils { fun md5ByArray(array: Array): String fun md5ByString(string: String): String + + fun sha256(path: Path): String } diff --git a/composeApp/src/commonMain/kotlin/com/crosspaste/utils/FileUtils.kt b/composeApp/src/commonMain/kotlin/com/crosspaste/utils/FileUtils.kt index 0c502da5..79c6b0a5 100644 --- a/composeApp/src/commonMain/kotlin/com/crosspaste/utils/FileUtils.kt +++ b/composeApp/src/commonMain/kotlin/com/crosspaste/utils/FileUtils.kt @@ -53,6 +53,14 @@ interface FileUtils { fun getFileMd5(path: Path): String + fun existFile(path: Path): Boolean + + fun deleteFile(path: Path): Boolean + + fun createFile(path: Path): Boolean + + fun createDir(path: Path): Boolean + fun copyPath( src: Path, dest: Path, diff --git a/composeApp/src/commonMain/kotlin/com/crosspaste/utils/Loader.kt b/composeApp/src/commonMain/kotlin/com/crosspaste/utils/Loader.kt new file mode 100644 index 00000000..caa75bb7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/crosspaste/utils/Loader.kt @@ -0,0 +1,6 @@ +package com.crosspaste.utils + +interface Loader { + + fun load(value: T): R? +} diff --git a/composeApp/src/commonMain/kotlin/com/crosspaste/utils/RetryUtils.kt b/composeApp/src/commonMain/kotlin/com/crosspaste/utils/RetryUtils.kt new file mode 100644 index 00000000..a44afa3b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/crosspaste/utils/RetryUtils.kt @@ -0,0 +1,10 @@ +package com.crosspaste.utils + +expect fun getRetryUtils(): RetryUtils + +interface RetryUtils { + fun retry( + maxRetries: Int, + block: (Int) -> T, + ): T? +} diff --git a/composeApp/src/desktopMain/kotlin/com/crosspaste/CrossPaste.kt b/composeApp/src/desktopMain/kotlin/com/crosspaste/CrossPaste.kt index ae428356..e843cacf 100644 --- a/composeApp/src/desktopMain/kotlin/com/crosspaste/CrossPaste.kt +++ b/composeApp/src/desktopMain/kotlin/com/crosspaste/CrossPaste.kt @@ -44,6 +44,8 @@ import com.crosspaste.dao.task.PasteTaskDao import com.crosspaste.dao.task.PasteTaskRealm import com.crosspaste.endpoint.DesktopEndpointInfoFactory import com.crosspaste.endpoint.EndpointInfoFactory +import com.crosspaste.html.ChromeService +import com.crosspaste.html.DesktopChromeService import com.crosspaste.i18n.GlobalCopywriter import com.crosspaste.i18n.GlobalCopywriterImpl import com.crosspaste.image.DesktopFaviconLoader @@ -87,8 +89,6 @@ import com.crosspaste.net.plugin.SignalServerDecryptionPluginFactory import com.crosspaste.net.plugin.SignalServerEncryptPluginFactory import com.crosspaste.paste.CacheManager import com.crosspaste.paste.CacheManagerImpl -import com.crosspaste.paste.ChromeService -import com.crosspaste.paste.DesktopChromeService import com.crosspaste.paste.DesktopPastePreviewService import com.crosspaste.paste.DesktopPasteSearchService import com.crosspaste.paste.DesktopPasteSyncProcessManager @@ -328,7 +328,7 @@ class CrossPaste { ), ) } - single { DesktopChromeService(get()) } + single { DesktopChromeService(get(), get()) } single { DesktopPastePreviewService(get()) } single> { DesktopPasteSyncProcessManager() } single { DesktopPasteSearchService(get(), get(), get()) } @@ -340,7 +340,7 @@ class CrossPaste { DeletePasteTaskExecutor(get()), PullFileTaskExecutor(get(), get(), get(), get(), get(), get()), CleanPasteTaskExecutor(get(), get()), - Html2ImageTaskExecutor(get(), get(), get(), get()), + Html2ImageTaskExecutor(lazy { get() }, get(), get(), get()), PullIconTaskExecutor(get(), get(), get(), get()), ), get(), diff --git a/composeApp/src/desktopMain/kotlin/com/crosspaste/html/ChromeModuleLoader.kt b/composeApp/src/desktopMain/kotlin/com/crosspaste/html/ChromeModuleLoader.kt new file mode 100644 index 00000000..1f7396cd --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/crosspaste/html/ChromeModuleLoader.kt @@ -0,0 +1,63 @@ +package com.crosspaste.html + +import com.crosspaste.module.AbstractModuleLoader +import com.crosspaste.path.UserDataPathProvider +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import okio.Path +import java.util.zip.ZipInputStream +import kotlin.io.path.createDirectories + +class ChromeModuleLoader( + override val userDataPathProvider: UserDataPathProvider, +) : AbstractModuleLoader() { + + override val logger: KLogger = KotlinLogging.logger {} + + override fun installModule( + downloadPath: Path, + installPath: Path, + ): Boolean { + if (!downloadPath.toString().lowercase().endsWith(".zip")) { + logger.error { "Error: Downloaded file is not a zip archive" } + return false + } + + try { + // Decompress the downloaded file to installPath, this function needs to be idempotent and can be executed repeatedly + unzipFile(downloadPath, installPath) + logger.info { "Module installed successfully" } + return true + } catch (e: Exception) { + logger.error { "Error during module installation: ${e.message}" } + return false + } + } + + private fun unzipFile( + zipFile: Path, + destDir: Path, + ) { + // Ensure the destination directory exists + destDir.toNioPath().createDirectories() + + ZipInputStream(zipFile.toFile().inputStream()).use { zis -> + var zipEntry = zis.nextEntry + while (zipEntry != null) { + val newFile = destDir.resolve(zipEntry.name) + if (zipEntry.isDirectory) { + newFile.toNioPath().createDirectories() + } else { + // Create parent directories if they don't exist + newFile.parent?.toNioPath()?.createDirectories() + // Write file content + newFile.toFile().outputStream().use { fos -> + zis.copyTo(fos) + } + } + zipEntry = zis.nextEntry + } + zis.closeEntry() + } + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/crosspaste/html/ChromeServiceServiceModule.kt b/composeApp/src/desktopMain/kotlin/com/crosspaste/html/ChromeServiceServiceModule.kt new file mode 100644 index 00000000..822928a0 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/crosspaste/html/ChromeServiceServiceModule.kt @@ -0,0 +1,127 @@ +package com.crosspaste.html + +import com.crosspaste.app.AppFileType +import com.crosspaste.module.ModuleItem +import com.crosspaste.module.ModuleLoaderConfig +import com.crosspaste.module.ServiceModule +import com.crosspaste.path.DesktopAppPathProvider +import com.crosspaste.platform.currentPlatform +import java.util.Properties + +class ChromeServiceServiceModule( + private val properties: Properties, +) : ServiceModule { + + companion object { + const val CHROME_SERVICE_MODULE_NAME = "ChromeService" + const val CHROME_DRIVER_MODULE_ITEM_NAME = "chromedriver" + const val CHROME_HEADLESS_SHELL_MODULE_ITEM_NAME = "chrome-headless-shell" + + const val DEFAULT_HOST = "https://storage.googleapis.com/chrome-for-testing-public" + const val MIRROR_HOST = "https://cdn.npmmirror.com/binaries/chrome-for-testing" + + val CHROME_SERVICE_DIR = + DesktopAppPathProvider.resolve(appFileType = AppFileType.MODULE) + .resolve(CHROME_SERVICE_MODULE_NAME) + } + + private val platform = currentPlatform() + + private val hosts = listOf(DEFAULT_HOST, MIRROR_HOST) + + override fun getModuleLoaderConfig(): ModuleLoaderConfig? { + if (platform.isWindows() && platform.is64bit()) { + return ModuleLoaderConfig( + installPath = CHROME_SERVICE_DIR, + moduleName = CHROME_SERVICE_MODULE_NAME, + moduleItems = + listOf( + ModuleItem( + hosts = hosts, + path = properties.getProperty("chromedriver-win64"), + moduleItemName = CHROME_DRIVER_MODULE_ITEM_NAME, + relativePath = listOf("chromedriver-win64", "chromedriver.exe"), + sha256 = properties.getProperty("chromedriver-win64-sha256"), + ), + ModuleItem( + hosts = hosts, + path = properties.getProperty("chrome-headless-shell-win64"), + moduleItemName = CHROME_HEADLESS_SHELL_MODULE_ITEM_NAME, + relativePath = listOf("chrome-headless-shell-win64", "chrome-headless-shell.exe"), + sha256 = properties.getProperty("chrome-headless-shell-win64-sha256"), + ), + ), + ) + } else if (platform.isMacos()) { + return if (platform.arch.contains("x86_64")) { + ModuleLoaderConfig( + installPath = CHROME_SERVICE_DIR, + moduleName = CHROME_SERVICE_MODULE_NAME, + moduleItems = + listOf( + ModuleItem( + hosts = hosts, + path = properties.getProperty("chromedriver-mac-x64"), + moduleItemName = CHROME_DRIVER_MODULE_ITEM_NAME, + relativePath = listOf("chromedriver-mac-x64", "chromedriver"), + sha256 = properties.getProperty("chromedriver-mac-x64-sha256"), + ), + ModuleItem( + hosts = hosts, + path = properties.getProperty("chrome-headless-shell-mac-x64"), + moduleItemName = CHROME_HEADLESS_SHELL_MODULE_ITEM_NAME, + relativePath = listOf("chrome-headless-shell-mac-x64", "chrome-headless-shell"), + sha256 = properties.getProperty("chrome-headless-shell-mac-x64-sha256"), + ), + ), + ) + } else { + return ModuleLoaderConfig( + installPath = CHROME_SERVICE_DIR, + moduleName = CHROME_SERVICE_MODULE_NAME, + moduleItems = + listOf( + ModuleItem( + hosts = hosts, + path = properties.getProperty("chromedriver-mac-arm64"), + moduleItemName = CHROME_DRIVER_MODULE_ITEM_NAME, + relativePath = listOf("chromedriver-mac-arm64", "chromedriver"), + sha256 = properties.getProperty("chromedriver-mac-arm64-sha256"), + ), + ModuleItem( + hosts = hosts, + path = properties.getProperty("chrome-headless-shell-mac-arm64"), + moduleItemName = CHROME_HEADLESS_SHELL_MODULE_ITEM_NAME, + relativePath = listOf("chrome-headless-shell-mac-arm64", "chrome-headless-shell"), + sha256 = properties.getProperty("chrome-headless-shell-mac-arm64-sha256"), + ), + ), + ) + } + } else if (platform.isLinux() && platform.is64bit()) { + return ModuleLoaderConfig( + installPath = CHROME_SERVICE_DIR, + moduleName = CHROME_SERVICE_MODULE_NAME, + moduleItems = + listOf( + ModuleItem( + hosts = hosts, + path = properties.getProperty("chromedriver-linux64"), + moduleItemName = CHROME_DRIVER_MODULE_ITEM_NAME, + relativePath = listOf("chromedriver-linux64", "chromedriver"), + sha256 = properties.getProperty("chromedriver-linux64-sha256"), + ), + ModuleItem( + hosts = hosts, + path = properties.getProperty("chrome-headless-shell-linux64"), + moduleItemName = CHROME_HEADLESS_SHELL_MODULE_ITEM_NAME, + relativePath = listOf("chrome-headless-shell-linux64", "chrome-headless-shell"), + sha256 = properties.getProperty("chrome-headless-shell-linux64-sha256"), + ), + ), + ) + } else { + return null + } + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/crosspaste/paste/DesktopChromeService.kt b/composeApp/src/desktopMain/kotlin/com/crosspaste/html/DesktopChromeService.kt similarity index 62% rename from composeApp/src/desktopMain/kotlin/com/crosspaste/paste/DesktopChromeService.kt rename to composeApp/src/desktopMain/kotlin/com/crosspaste/html/DesktopChromeService.kt index 311b6436..e3bc6aad 100644 --- a/composeApp/src/desktopMain/kotlin/com/crosspaste/paste/DesktopChromeService.kt +++ b/composeApp/src/desktopMain/kotlin/com/crosspaste/html/DesktopChromeService.kt @@ -1,13 +1,19 @@ -package com.crosspaste.paste +package com.crosspaste.html -import com.crosspaste.app.AppEnv +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import com.crosspaste.app.AppWindowManager +import com.crosspaste.html.ChromeServiceServiceModule.Companion.CHROME_DRIVER_MODULE_ITEM_NAME +import com.crosspaste.html.ChromeServiceServiceModule.Companion.CHROME_HEADLESS_SHELL_MODULE_ITEM_NAME +import com.crosspaste.module.ModuleLoaderConfig import com.crosspaste.os.windows.WinProcessUtils import com.crosspaste.os.windows.WinProcessUtils.killProcessSet import com.crosspaste.os.windows.WindowDpiHelper -import com.crosspaste.path.DesktopAppPathProvider +import com.crosspaste.path.UserDataPathProvider import com.crosspaste.platform.currentPlatform import com.crosspaste.utils.DesktopHtmlUtils.dataUrl +import com.crosspaste.utils.DesktopResourceUtils import com.crosspaste.utils.Retry import com.crosspaste.utils.ioDispatcher import io.github.oshai.kotlinlogging.KotlinLogging @@ -16,7 +22,6 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeoutOrNull -import okio.Path import org.openqa.selenium.Dimension import org.openqa.selenium.OutputType import org.openqa.selenium.chrome.ChromeDriver @@ -24,15 +29,12 @@ import org.openqa.selenium.chrome.ChromeDriverService import org.openqa.selenium.chrome.ChromeOptions import kotlin.math.max -class DesktopChromeService(private val appWindowManager: AppWindowManager) : ChromeService { +class DesktopChromeService( + private val appWindowManager: AppWindowManager, + private val userDataPathProvider: UserDataPathProvider, +) : ChromeService { - companion object { - private const val CHROME_DRIVER = "chromedriver" - - private const val CHROME_HEADLESS_SHELL = "chrome-headless-shell" - - private val logger = KotlinLogging.logger {} - } + private val logger = KotlinLogging.logger {} private val currentPlatform = currentPlatform() @@ -62,34 +64,6 @@ class DesktopChromeService(private val appWindowManager: AppWindowManager) : Chr baseOptions } - private val initChromeDriver: (String, String, String, Path) -> Unit = { chromeSuffix, driverName, headlessName, resourcesPath -> - val chromeDriverFile = - resourcesPath - .resolve("$CHROME_DRIVER-$chromeSuffix") - .resolve(driverName) - .toFile() - - val chromeHeadlessShellFile = - resourcesPath - .resolve("$CHROME_HEADLESS_SHELL-$chromeSuffix") - .resolve(headlessName) - .toFile() - - if (!chromeDriverFile.canExecute()) { - chromeDriverFile.setExecutable(true) - } - - if (!chromeHeadlessShellFile.canExecute()) { - chromeHeadlessShellFile.setExecutable(true) - } - - System.setProperty( - "webdriver.chrome.driver", - chromeDriverFile.absolutePath, - ) - options.setBinary(chromeHeadlessShellFile.absolutePath) - } - private val windowDimension: Dimension = run { val detailViewDpSize = appWindowManager.searchWindowDetailViewDpSize @@ -108,83 +82,62 @@ class DesktopChromeService(private val appWindowManager: AppWindowManager) : Chr private var chromeDriver: ChromeDriver? = null + override var startSuccess: Boolean by mutableStateOf(false) + + private val chromeDriverProperties = + DesktopResourceUtils + .loadProperties("chrome-driver.properties") + init { initChromeDriver() } private fun initChromeDriver() { - val resourcesPath = - if (AppEnv.CURRENT.isDevelopment()) { - DesktopAppPathProvider.pasteAppJarPath.resolve("resources") - } else { - DesktopAppPathProvider.pasteAppJarPath + val chromeModuleLoader = ChromeModuleLoader(userDataPathProvider) + val chromeServiceModule = ChromeServiceServiceModule(chromeDriverProperties) + + val optLoaderConfig = chromeServiceModule.getModuleLoaderConfig() + optLoaderConfig?.let { loaderConfig -> + if (chromeModuleLoader.installed(loaderConfig.installPath)) { + startByLoaderModule(loaderConfig) + startSuccess = true + return } + } - // todo not support 32 bit OS - - if (currentPlatform.isMacos()) { - if (currentPlatform.arch.contains("x86_64")) { - val macX64ResourcesPath = - if (AppEnv.CURRENT.isDevelopment()) { - resourcesPath.resolve("macos-x64") - } else { - resourcesPath - } - initChromeDriver.invoke( - "mac-x64", - "chromedriver", - "chrome-headless-shell", - macX64ResourcesPath, - ) - } else { - val macArm64ResourcesPath = - if (AppEnv.CURRENT.isDevelopment()) { - resourcesPath.resolve("macos-arm64") - } else { - resourcesPath - } - initChromeDriver.invoke( - "mac-arm64", - "chromedriver", - "chrome-headless-shell", - macArm64ResourcesPath, - ) - } - } else if (currentPlatform.isWindows()) { - if (currentPlatform.is64bit()) { - val win64ResourcesPath = - if (AppEnv.CURRENT.isDevelopment()) { - resourcesPath.resolve("windows-x64") - } else { - resourcesPath - } - initChromeDriver.invoke( - "win64", - "chromedriver.exe", - "chrome-headless-shell.exe", - win64ResourcesPath, - ) + try { + chromeDriverService = ChromeDriverService.createDefaultService() + chromeDriver = ChromeDriver(chromeDriverService, options) + startSuccess = true + } catch (e: Exception) { + logger.error(e) { "chromeDriver auto init fail" } + optLoaderConfig?.let { loaderConfig -> + if (chromeModuleLoader.load(loaderConfig)) { + startByLoaderModule(loaderConfig) + startSuccess = true + return + } } - } else if (currentPlatform.isLinux()) { - if (currentPlatform.is64bit()) { - val linux64ResourcesPath = - if (AppEnv.CURRENT.isDevelopment()) { - resourcesPath.resolve("linux-x64") - } else { - resourcesPath - } - initChromeDriver.invoke( - "linux64", - "chromedriver", - "chrome-headless-shell", - linux64ResourcesPath, + startSuccess = false + return + } + } + + private fun startByLoaderModule(moduleLoaderConfig: ModuleLoaderConfig): Boolean { + val installPath = moduleLoaderConfig.installPath + moduleLoaderConfig.getModuleItem(CHROME_DRIVER_MODULE_ITEM_NAME)?.let { chromeDriverModule -> + moduleLoaderConfig.getModuleItem(CHROME_HEADLESS_SHELL_MODULE_ITEM_NAME)?.let { chromeHeadlessShellModule -> + chromeDriverService = ChromeDriverService.createDefaultService() + System.setProperty( + "webdriver.chrome.driver", + chromeDriverModule.getModuleFilePath(installPath).toString(), ) + options.setBinary(chromeHeadlessShellModule.getModuleFilePath(installPath).toFile()) + chromeDriver = ChromeDriver(chromeDriverService, options) + return true } } - - chromeDriverService = ChromeDriverService.createDefaultService() - - chromeDriver = ChromeDriver(chromeDriverService, options) + return false } @Synchronized diff --git a/composeApp/src/desktopMain/kotlin/com/crosspaste/image/ConcurrentLoader.kt b/composeApp/src/desktopMain/kotlin/com/crosspaste/image/ConcurrentLoader.kt index 433d22fb..b7d4d3dd 100644 --- a/composeApp/src/desktopMain/kotlin/com/crosspaste/image/ConcurrentLoader.kt +++ b/composeApp/src/desktopMain/kotlin/com/crosspaste/image/ConcurrentLoader.kt @@ -1,10 +1,11 @@ package com.crosspaste.image import com.crosspaste.utils.ConcurrentPlatformMap +import com.crosspaste.utils.Loader import com.crosspaste.utils.PlatformLock import com.crosspaste.utils.createPlatformLock -interface ConcurrentLoader : ImageLoader { +interface ConcurrentLoader : Loader { val lockMap: ConcurrentPlatformMap diff --git a/composeApp/src/desktopMain/kotlin/com/crosspaste/module/AbstractModuleLoader.kt b/composeApp/src/desktopMain/kotlin/com/crosspaste/module/AbstractModuleLoader.kt new file mode 100644 index 00000000..5f32a666 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/crosspaste/module/AbstractModuleLoader.kt @@ -0,0 +1,112 @@ +package com.crosspaste.module + +import com.crosspaste.net.DesktopProxy +import com.crosspaste.utils.getCodecsUtils +import com.crosspaste.utils.getFileUtils +import com.crosspaste.utils.getRetryUtils +import io.github.oshai.kotlinlogging.KLogger +import okio.Path +import java.net.InetSocketAddress +import java.net.ProxySelector +import java.net.URL +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.time.Duration +import java.time.Instant + +abstract class AbstractModuleLoader : ModuleLoader { + + abstract val logger: KLogger + + override val retryUtils = getRetryUtils() + + override val fileUtils = getFileUtils() + + override val codecsUtils = getCodecsUtils() + + override fun downloadModule( + url: String, + path: Path, + ): Boolean { + return try { + fileUtils.deleteFile(path) + + logger.info { "Downloading: $url" } + + val httpsUrl = URL(url) + val uri = httpsUrl.toURI() + val proxy = DesktopProxy.getProxy(uri) + + val clientBuilder = + HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + + (proxy.address() as? InetSocketAddress)?.let { address -> + logger.info { "Using proxy: $address" } + clientBuilder.proxy(ProxySelector.of(address)) + } + + val client = clientBuilder.build() + val request = + HttpRequest.newBuilder(uri) + .timeout(Duration.ofMinutes(30)) + .build() + + val response = client.send(request, HttpResponse.BodyHandlers.ofInputStream()) + + if (response.statusCode() == 200) { + val contentLength = + response.headers() + .firstValue("Content-Length") + .orElse("-1").toLong() + var bytesRead = 0L + var lastLogTime = Instant.EPOCH + var lastLoggedProgress = -1L + + response.body().use { input -> + path.toFile().outputStream().buffered().use { output -> + val buffer = ByteArray(8192) // 8KB buffer + var read: Int + while (input.read(buffer).also { read = it } != -1) { + output.write(buffer, 0, read) + bytesRead += read + + val currentTime = Instant.now() + val progress = if (contentLength > 0) bytesRead * 100 / contentLength else -1 + + if (shouldLogProgress(currentTime, progress, lastLogTime, lastLoggedProgress)) { + logger.info { "Downloaded: $bytesRead bytes ($progress%)" } + lastLogTime = currentTime + lastLoggedProgress = progress + } + } + } + } + logger.info { "Download completed: $path" } + true + } else { + logger.error { "Failed to download. Status code: ${response.statusCode()}" } + false + } + } catch (e: Exception) { + logger.error { "Error during download: ${e.message}" } + false + } + } + + private fun shouldLogProgress( + currentTime: Instant, + progress: Long, + lastLogTime: Instant, + lastLoggedProgress: Long, + ): Boolean { + val logInterval = Duration.ofSeconds(5) + val timeSinceLastLog = Duration.between(lastLogTime, currentTime) + val progressDelta = progress - lastLoggedProgress + + return timeSinceLastLog >= logInterval || + (progressDelta >= 5 && timeSinceLastLog >= Duration.ofSeconds(1)) || + progress == 100L + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/crosspaste/net/DesktopProxy.kt b/composeApp/src/desktopMain/kotlin/com/crosspaste/net/DesktopProxy.kt index ed60dda9..c5af9996 100644 --- a/composeApp/src/desktopMain/kotlin/com/crosspaste/net/DesktopProxy.kt +++ b/composeApp/src/desktopMain/kotlin/com/crosspaste/net/DesktopProxy.kt @@ -65,4 +65,30 @@ object DesktopProxy { false } } + + fun proxyToCommandLine(proxy: Proxy): String? { + return when (proxy.type()) { + Proxy.Type.DIRECT -> null + Proxy.Type.HTTP -> { + try { + proxy.address()?.let { address -> + (address as? InetSocketAddress)?.let { inetSocketAddress -> + val port = inetSocketAddress.port + inetSocketAddress.hostName?.let { + "http://$it:$port" + } ?: run { + inetSocketAddress.address?.hostAddress?.let { + "http://$it:$port" + } + } + } + } + } catch (e: Exception) { + logger.warn { "Invalid proxy configuration: $e" } + null + } + } + else -> null + } + } } diff --git a/composeApp/src/desktopMain/kotlin/com/crosspaste/path/DesktopAppPathProvider.kt b/composeApp/src/desktopMain/kotlin/com/crosspaste/path/DesktopAppPathProvider.kt index 10d4bc36..39ae22ef 100644 --- a/composeApp/src/desktopMain/kotlin/com/crosspaste/path/DesktopAppPathProvider.kt +++ b/composeApp/src/desktopMain/kotlin/com/crosspaste/path/DesktopAppPathProvider.kt @@ -34,6 +34,7 @@ object DesktopAppPathProvider : AppPathProvider, PathProvider { AppFileType.LOG -> pasteUserPath.resolve("logs") AppFileType.ENCRYPT -> pasteUserPath.resolve("encrypt") AppFileType.USER -> pasteUserPath + AppFileType.MODULE -> pasteUserPath.resolve("module") else -> pasteAppPath } @@ -77,7 +78,7 @@ class DevelopmentAppPathProvider : AppPathProvider { override val pasteAppPath: Path = getAppPath() - override val pasteAppJarPath: Path = getAppPath() + override val pasteAppJarPath: Path = getResources() override val pasteUserPath: Path = getUserPath() @@ -106,6 +107,26 @@ class DevelopmentAppPathProvider : AppPathProvider { return composeAppDir.toPath() } } + + private fun getResources(): Path { + val resources = composeAppDir.toPath().resolve("resources") + val platform = currentPlatform() + val platformAndArch = + if (platform.isWindows() && platform.is64bit()) { + "windows-x64" + } else if (platform.isMacos()) { + if (platform.arch.contains("x86_64")) { + "macos-x64" + } else { + "macos-arm64" + } + } else if (platform.isLinux() && platform.is64bit()) { + "linux-x64" + } else { + throw IllegalStateException("Unknown platform: ${platform.name}") + } + return resources.resolve(platformAndArch) + } } class WindowsAppPathProvider : AppPathProvider { diff --git a/composeApp/src/desktopMain/kotlin/com/crosspaste/task/Html2ImageTaskExecutor.kt b/composeApp/src/desktopMain/kotlin/com/crosspaste/task/Html2ImageTaskExecutor.kt index c815995a..603d95fb 100644 --- a/composeApp/src/desktopMain/kotlin/com/crosspaste/task/Html2ImageTaskExecutor.kt +++ b/composeApp/src/desktopMain/kotlin/com/crosspaste/task/Html2ImageTaskExecutor.kt @@ -4,8 +4,8 @@ import com.crosspaste.dao.paste.PasteDao import com.crosspaste.dao.task.PasteTask import com.crosspaste.dao.task.TaskType import com.crosspaste.exception.StandardErrorCode +import com.crosspaste.html.ChromeService import com.crosspaste.net.clientapi.createFailureResult -import com.crosspaste.paste.ChromeService import com.crosspaste.paste.item.PasteHtml import com.crosspaste.path.UserDataPathProvider import com.crosspaste.presist.FilePersist @@ -13,12 +13,16 @@ import com.crosspaste.task.extra.BaseExtraInfo import com.crosspaste.ui.paste.preview.getPasteItem import com.crosspaste.utils.TaskUtils import com.crosspaste.utils.TaskUtils.createFailurePasteTaskResult +import com.crosspaste.utils.ioDispatcher import io.github.oshai.kotlinlogging.KotlinLogging +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock class Html2ImageTaskExecutor( - private val chromeService: ChromeService, + private val lazyChromeService: Lazy, private val pasteDao: PasteDao, private val filePersist: FilePersist, private val userDataPathProvider: UserDataPathProvider, @@ -30,8 +34,14 @@ class Html2ImageTaskExecutor( private val mutex = Mutex() + private val chromeServiceDeferred: Deferred = + CoroutineScope(ioDispatcher).async { + lazyChromeService.value + } + override suspend fun doExecuteTask(pasteTask: PasteTask): PasteTaskResult { mutex.withLock { + val chromeService = chromeServiceDeferred.await() try { pasteDao.getPasteData(pasteTask.pasteDataId!!)?.let { pasteData -> pasteData.getPasteItem()?.let { pasteItem -> diff --git a/composeApp/src/desktopMain/kotlin/com/crosspaste/utils/CodecsUtils.desktop.kt b/composeApp/src/desktopMain/kotlin/com/crosspaste/utils/CodecsUtils.desktop.kt index ec5e57e4..acbf6276 100644 --- a/composeApp/src/desktopMain/kotlin/com/crosspaste/utils/CodecsUtils.desktop.kt +++ b/composeApp/src/desktopMain/kotlin/com/crosspaste/utils/CodecsUtils.desktop.kt @@ -1,6 +1,9 @@ package com.crosspaste.utils +import okio.Path import java.io.ByteArrayOutputStream +import java.io.FileInputStream +import java.security.MessageDigest import java.util.Base64 actual fun getCodecsUtils(): CodecsUtils { @@ -26,7 +29,7 @@ object DesktopCodecsUtils : CodecsUtils { } override fun md5(bytes: ByteArray): String { - val md = java.security.MessageDigest.getInstance("MD5") + val md = MessageDigest.getInstance("MD5") val digest = md.digest(bytes) return digest.fold("") { str, it -> str + "%02x".format(it) } } @@ -49,4 +52,17 @@ object DesktopCodecsUtils : CodecsUtils { override fun md5ByString(string: String): String { return md5(string.toByteArray()) } + + override fun sha256(path: Path): String { + val buffer = ByteArray(8192) // 8KB buffer + val digest = MessageDigest.getInstance("SHA-256") + var bytesRead: Int + + FileInputStream(path.toFile()).use { fis -> + while (fis.read(buffer).also { bytesRead = it } != -1) { + digest.update(buffer, 0, bytesRead) + } + } + return digest.digest().joinToString("") { "%02x".format(it) } + } } diff --git a/composeApp/src/desktopMain/kotlin/com/crosspaste/utils/FileUtils.desktop.kt b/composeApp/src/desktopMain/kotlin/com/crosspaste/utils/FileUtils.desktop.kt index 223bc99c..92c39d6f 100644 --- a/composeApp/src/desktopMain/kotlin/com/crosspaste/utils/FileUtils.desktop.kt +++ b/composeApp/src/desktopMain/kotlin/com/crosspaste/utils/FileUtils.desktop.kt @@ -128,6 +128,35 @@ object DesktopFileUtils : FileUtils { return hc.toString() } + override fun existFile(path: Path): Boolean { + return path.toFile().exists() + } + + override fun deleteFile(path: Path): Boolean { + return path.toFile().delete() + } + + override fun createFile(path: Path): Boolean { + return if (path.toFile().exists()) { + false + } else { + try { + path.toFile().createNewFile() + } catch (e: Exception) { + logger.warn(e) { "Failed to create file: $path" } + false + } + } + } + + override fun createDir(path: Path): Boolean { + return if (!path.toFile().exists()) { + path.toFile().mkdirs() + } else { + true + } + } + override fun copyPath( src: Path, dest: Path, diff --git a/composeApp/src/desktopMain/kotlin/com/crosspaste/utils/RetryUtils.desktop.kt b/composeApp/src/desktopMain/kotlin/com/crosspaste/utils/RetryUtils.desktop.kt new file mode 100644 index 00000000..45e02ae6 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/crosspaste/utils/RetryUtils.desktop.kt @@ -0,0 +1,36 @@ +package com.crosspaste.utils + +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging + +actual fun getRetryUtils(): RetryUtils { + return DesktopRetryUtils +} + +object DesktopRetryUtils : RetryUtils { + + private val logger: KLogger = KotlinLogging.logger {} + + override fun retry( + maxRetries: Int, + block: (Int) -> T, + ): T? { + repeat(maxRetries) { attempt -> + try { + val result = block(attempt) + if (result != null) { + return result + } + } catch (e: Exception) { + logger.error { + "Attempt ${attempt + 1}/$maxRetries failed with " + + "${e::class.simpleName}: ${e.message}" + } + if (attempt == maxRetries - 1) { + return null + } + } + } + return null + } +} diff --git a/composeApp/src/desktopMain/kotlin/org/openqa/selenium/manager/SeleniumManager.kt b/composeApp/src/desktopMain/kotlin/org/openqa/selenium/manager/SeleniumManager.kt index 7767fc5d..71ed34c3 100644 --- a/composeApp/src/desktopMain/kotlin/org/openqa/selenium/manager/SeleniumManager.kt +++ b/composeApp/src/desktopMain/kotlin/org/openqa/selenium/manager/SeleniumManager.kt @@ -16,22 +16,24 @@ // under the License. package org.openqa.selenium.manager +import com.crosspaste.net.DesktopProxy +import com.crosspaste.path.DesktopAppPathProvider +import com.crosspaste.platform.currentPlatform +import com.crosspaste.utils.getJsonUtils +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging import org.openqa.selenium.Beta import org.openqa.selenium.BuildInfo -import org.openqa.selenium.Platform import org.openqa.selenium.WebDriverException -import org.openqa.selenium.json.Json import org.openqa.selenium.json.JsonException import org.openqa.selenium.os.ExternalProcess import java.io.IOException +import java.net.URL import java.nio.charset.StandardCharsets import java.nio.file.Files import java.nio.file.Path import java.nio.file.Paths import java.time.Duration -import java.util.function.Consumer -import java.util.logging.Level -import java.util.logging.Logger import kotlin.concurrent.Volatile /** @@ -48,8 +50,15 @@ import kotlin.concurrent.Volatile */ @Beta class SeleniumManager private constructor() { - private val managerPath: String? = System.getenv("SE_MANAGER_PATH") - private var binary = if (managerPath == null) null else Paths.get(managerPath) + + private val platform = currentPlatform() + private val fileName = + if (platform.isWindows()) { + "selenium-manager.exe" + } else { + "selenium-manager" + } + private var binary = DesktopAppPathProvider.pasteAppJarPath.resolve(fileName).toNioPath() private val seleniumManagerVersion: String private var binaryInTemporalFolder = false @@ -59,28 +68,6 @@ class SeleniumManager private constructor() { val releaseLabel = info.releaseLabel val lastDot = releaseLabel.lastIndexOf(".") seleniumManagerVersion = BETA_PREFIX + releaseLabel.substring(0, lastDot) - if (managerPath == null) { - Runtime.getRuntime() - .addShutdownHook( - Thread { - if (binaryInTemporalFolder && binary != null && Files.exists(binary)) { - try { - Files.delete(binary) - } catch (e: IOException) { - LOG.warning( - String.format( - "%s deleting temporal file: %s", - e.javaClass.simpleName, - e.message, - ), - ) - } - } - }, - ) - } else { - LOG.fine(String.format("Selenium Manager set by env 'SE_MANAGER_PATH': %s", managerPath)) - } } /** @@ -89,50 +76,7 @@ class SeleniumManager private constructor() { * @return the path to the Selenium Manager binary. */ @Synchronized - private fun getBinary(): Path? { - if (binary == null) { - try { - val current = Platform.getCurrent() - var folder = "" - var extension = "" - if (current.`is`(Platform.WINDOWS)) { - extension = EXE - folder = "windows" - } else if (current.`is`(Platform.MAC)) { - folder = "macos" - } else if (current.`is`(Platform.LINUX)) { - folder = "linux" - } else if (current.`is`(Platform.UNIX)) { - LOG.warning( - String.format( - "Selenium Manager binary may not be compatible with %s; verify settings", - current, - ), - ) - folder = "linux" - } else { - throw WebDriverException("Unsupported platform: $current") - } - - binary = getBinaryInCache(SELENIUM_MANAGER + extension) - if (!binary!!.toFile().exists()) { - val binaryPathInJar = String.format("%s/%s%s", folder, SELENIUM_MANAGER, extension) - this.javaClass.getResourceAsStream(binaryPathInJar).use { inputStream -> - binary!!.parent.toFile().mkdirs() - Files.copy(inputStream, binary) - } - } - } catch (e: Exception) { - throw WebDriverException("Unable to obtain Selenium Manager Binary", e) - } - } else if (!Files.exists(binary)) { - throw WebDriverException( - String.format("Unable to obtain Selenium Manager Binary at: %s", binary), - ) - } - binary!!.toFile().setExecutable(true) - - LOG.fine(String.format("Selenium Manager binary found at: %s", binary)) + private fun getBinary(): Path { return binary } @@ -143,31 +87,40 @@ class SeleniumManager private constructor() { * @return the locations of the assets from Selenium Manager execution */ fun getBinaryPaths(arguments: List): SeleniumManagerOutput.Result { - val args: MutableList = ArrayList(arguments.size + 5) + val args: MutableList = mutableListOf() args.addAll(arguments) args.add("--language-binding") args.add("java") args.add("--output") args.add("json") - if (logLevel.intValue() <= Level.FINE.intValue()) { - args.add("--debug") + getBinaryByDefault(args).let { + if (it.code != 0) { + return getBinaryWithMirror(args) + } + return it } + } + private fun getBinaryByDefault(arguments: List): SeleniumManagerOutput.Result { + val args: MutableList = mutableListOf() + args.addAll(arguments) + val uri = URL("https://storage.googleapis.com").toURI() + val proxy = DesktopProxy.getProxy(uri) + DesktopProxy.proxyToCommandLine(proxy)?.let { + args.add("--proxy") + args.add(it) + } return runCommand(getBinary(), args) } - private val logLevel: Level - get() { - var level = LOG.level - if (level == null && LOG.parent != null) { - level = LOG.parent.level - } - if (level == null) { - return Level.INFO - } - return level - } + private fun getBinaryWithMirror(arguments: List): SeleniumManagerOutput.Result { + val args: MutableList = mutableListOf() + args.addAll(arguments) + args.add("--driver-mirror-url") + args.add("https://oss.crosspaste.com") + return runCommand(getBinary(), args) + } @Throws(IOException::class) private fun getBinaryInCache(binaryName: String): Path { @@ -193,7 +146,8 @@ class SeleniumManager private constructor() { } companion object { - private val LOG: Logger = Logger.getLogger(SeleniumManager::class.java.name) + + private val logger: KLogger = KotlinLogging.logger {} private const val SELENIUM_MANAGER = "selenium-manager" private const val DEFAULT_CACHE_PATH = "~/.cache/selenium" @@ -230,7 +184,7 @@ class SeleniumManager private constructor() { binary: Path?, arguments: List, ): SeleniumManagerOutput.Result { - LOG.fine(String.format("Executing Process: %s", arguments)) + logger.info { String.format("Executing Process: %s", arguments) } val output: String val code: Int @@ -251,51 +205,25 @@ class SeleniumManager private constructor() { processBuilder.command(binary!!.toAbsolutePath().toString(), arguments).start() if (!process.waitFor(Duration.ofHours(1))) { - LOG.warning("Selenium Manager did not exit, shutting it down") + logger.warn { "Selenium Manager did not exit, shutting it down" } process.shutdown() } code = process.exitValue() output = process.getOutput(StandardCharsets.UTF_8) + logger.info { "code=$code\noutput=$output" } } catch (e: Exception) { throw WebDriverException("Failed to run command: $arguments", e) } - var jsonOutput: SeleniumManagerOutput? = null - var failedToParse: JsonException? = null - var dump = output - if (!output.isEmpty()) { - try { - jsonOutput = - Json().toType( - output, - SeleniumManagerOutput::class.java, - ) - jsonOutput?.logs?.forEach( - Consumer { logged: SeleniumManagerOutput.Log -> - val currentLevel = - if (logged.level === Level.INFO) Level.FINE else logged.level - LOG.log( - currentLevel, - logged.message, - ) - }, - ) - dump = jsonOutput?.result?.message ?: output - } catch (e: JsonException) { - failedToParse = e - } - } - if (code != 0) { - throw WebDriverException( - "Command failed with code: $code, executed: $arguments\n$dump", - failedToParse, - ) - } else if (failedToParse != null || jsonOutput == null) { + + try { + val jsonOutput = getJsonUtils().JSON.decodeFromString(output) + return jsonOutput.result + } catch (e: JsonException) { throw WebDriverException( - "Failed to parse json output, executed: $arguments\n$dump", - failedToParse, + "Failed to parse json output, executed: $arguments\n$output", + e, ) } - return jsonOutput.result!! } } } diff --git a/composeApp/src/desktopMain/kotlin/org/openqa/selenium/manager/SeleniumManagerOutput.kt b/composeApp/src/desktopMain/kotlin/org/openqa/selenium/manager/SeleniumManagerOutput.kt index df4b5f30..2f775cd0 100644 --- a/composeApp/src/desktopMain/kotlin/org/openqa/selenium/manager/SeleniumManagerOutput.kt +++ b/composeApp/src/desktopMain/kotlin/org/openqa/selenium/manager/SeleniumManagerOutput.kt @@ -1,122 +1,29 @@ -// Licensed to the Software Freedom Conservancy (SFC) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The SFC licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. package org.openqa.selenium.manager -import org.openqa.selenium.internal.Require -import org.openqa.selenium.json.JsonInput -import java.util.Locale -import java.util.Objects -import java.util.logging.Level +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SeleniumManagerOutput( + val logs: List, + val result: Result, +) { + + @Serializable + data class Log( + val level: String, + val timestamp: Long, + val message: String, + ) + + @Serializable + data class Result( + val code: Int, + val message: String?, + @SerialName("driver_path") val driverPath: String?, + @SerialName("browser_path") val browserPath: String?, + ) { -class SeleniumManagerOutput { - var logs: List? = null - var result: Result? = null - - class Log(level: Level, val timestamp: Long, message: String) { - val level: Level - val message: String - - init { - this.level = Require.nonNull("level", level) - this.message = Require.nonNull("message", message) - } - - companion object { - private fun fromJson(input: JsonInput): Log { - var level = Level.FINE - var timestamp = System.currentTimeMillis() - var message = "" - - input.beginObject() - while (input.hasNext()) { - when (input.nextName()) { - "level" -> - level = - when (input.nextString().lowercase(Locale.getDefault())) { - "error", "warn" -> Level.WARNING - "info" -> Level.INFO - else -> Level.FINE - } - - "timestamp" -> timestamp = input.nextNumber().toLong() - "message" -> message = input.nextString() - } - } - input.endObject() - - return Log(level, timestamp, message) - } - } - } - - class Result(val code: Int, val message: String?, val driverPath: String?, val browserPath: String?) { constructor(driverPath: String?) : this(0, null, driverPath, null) - - override fun toString(): String { - return ( - "Result{" + - "code=" + - code + - ", message='" + - message + - '\'' + - ", driverPath='" + - driverPath + - '\'' + - ", browserPath='" + - browserPath + - '\'' + - '}' - ) - } - - override fun equals(o: Any?): Boolean { - if (o !is Result) { - return false - } - val that = o - return code == that.code && message == that.message && driverPath == that.driverPath && browserPath == that.browserPath - } - - override fun hashCode(): Int { - return Objects.hash(code, message, driverPath, browserPath) - } - - companion object { - private fun fromJson(input: JsonInput): Result { - var code = 0 - var message: String? = null - var driverPath: String? = null - var browserPath: String? = null - - input.beginObject() - while (input.hasNext()) { - when (input.nextName()) { - "code" -> code = input.read(Int::class.java) - "message" -> message = input.read(String::class.java) - "driver_path" -> driverPath = input.read(String::class.java) - "browser_path" -> browserPath = input.read(String::class.java) - else -> input.skipValue() - } - } - input.endObject() - - return Result(code, message, driverPath, browserPath) - } - } } } diff --git a/composeApp/src/desktopMain/resources/chrome-driver.properties b/composeApp/src/desktopMain/resources/chrome-driver.properties new file mode 100644 index 00000000..a2653c20 --- /dev/null +++ b/composeApp/src/desktopMain/resources/chrome-driver.properties @@ -0,0 +1,16 @@ +chromedriver-linux64=/127.0.6533.119/linux64/chromedriver-linux64.zip +chromedriver-linux64-sha256=e84e8b6900b1fb65ddfef8e36a2411def26090fecbe0c46403b05bd234eb6a47 +chromedriver-mac-arm64=/127.0.6533.119/mac-arm64/chromedriver-mac-arm64.zip +chromedriver-mac-arm64-sha256=abad0d58891add0ccbb168375fffaa4d66f2e944dfef0be30837595a5523ccf0 +chromedriver-mac-x64=/127.0.6533.119/mac-x64/chromedriver-mac-x64.zip +chromedriver-mac-x64-sha256=cfed3c4750988b9dde74d1034af277d37fa173fa6a885bd5854903fa0e2aead1 +chromedriver-win64=/127.0.6533.119/win64/chromedriver-win64.zip +chromedriver-win64-sha256=e8c01d662d356d242a44730a38f2fd55ff6ab664f3e5755753f6fd72fd070644 +chrome-headless-shell-linux64=/127.0.6533.119/linux64/chrome-headless-shell-linux64.zip +chrome-headless-shell-linux64-sha256=ddc5bdaa3a8a5dd247d795f0ff67b396c7f0777e99e6fb9030f3a6df74c0e934 +chrome-headless-shell-mac-arm64=/127.0.6533.119/mac-arm64/chrome-headless-shell-mac-arm64.zip +chrome-headless-shell-mac-arm64-sha256=e1edd2f500fc05c78a12c44fd52fcc01aeb0da82c181bed405095807a615cf78 +chrome-headless-shell-mac-x64=/127.0.6533.119/mac-x64/chrome-headless-shell-mac-x64.zip +chrome-headless-shell-mac-x64-sha256=216e13d41242bbc2a8f734ee23db8fed4f7f28f577c08af90456bee05d78cb19 +chrome-headless-shell-win64=/127.0.6533.119/win64/chrome-headless-shell-win64.zip +chrome-headless-shell-win64-sha256=cef317dfed4ed2d38d4a5ea1b2db97b27c0b51a657c880e9e3ba12a1e5cd89c6 \ No newline at end of file diff --git a/composeApp/webDriver.properties b/composeApp/webDriver.properties deleted file mode 100644 index f6743d00..00000000 --- a/composeApp/webDriver.properties +++ /dev/null @@ -1,8 +0,0 @@ -chromedriver-linux64=https://storage.googleapis.com/chrome-for-testing-public/125.0.6422.76/linux64/chromedriver-linux64.zip -chromedriver-mac-arm64=https://storage.googleapis.com/chrome-for-testing-public/125.0.6422.76/mac-arm64/chromedriver-mac-arm64.zip -chromedriver-mac-x64=https://storage.googleapis.com/chrome-for-testing-public/125.0.6422.76/mac-x64/chromedriver-mac-x64.zip -chromedriver-win64=https://storage.googleapis.com/chrome-for-testing-public/125.0.6422.76/win64/chromedriver-win64.zip -chrome-headless-shell-linux64=https://storage.googleapis.com/chrome-for-testing-public/125.0.6422.76/linux64/chrome-headless-shell-linux64.zip -chrome-headless-shell-mac-arm64=https://storage.googleapis.com/chrome-for-testing-public/125.0.6422.76/mac-arm64/chrome-headless-shell-mac-arm64.zip -chrome-headless-shell-mac-x64=https://storage.googleapis.com/chrome-for-testing-public/125.0.6422.76/mac-x64/chrome-headless-shell-mac-x64.zip -chrome-headless-shell-win64=https://storage.googleapis.com/chrome-for-testing-public/125.0.6422.76/win64/chrome-headless-shell-win64.zip \ No newline at end of file diff --git a/conveyor.conf b/conveyor.conf index bd0b9d9f..2fd228ba 100644 --- a/conveyor.conf +++ b/conveyor.conf @@ -28,7 +28,7 @@ app { icons = "composeApp/src/desktopMain/resources/icon/crosspaste.mac.png" info-plist.CFBundleIdentifier = "com.crosspaste.mac" - info-plist.LSMinimumSystemVersion = 12.7.0 + info-plist.LSMinimumSystemVersion = 13.0.0 info-plist.LSUIElement = true updates = background sparkle-options.SUScheduledCheckInterval = 3600