diff --git a/.gitignore b/.gitignore index 94914a00..1e90da86 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ iosApp/iosApp.xcodeproj/* webview/webview.podspec /convention-plugins/ keySec.gpg +desktopApp/jcef-bundle \ No newline at end of file diff --git a/README.md b/README.md index bb7ebf4c..6e082a70 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Thus I created a fork of it and used it as the base for this library. If you jus The iOS implementation of this library relies on [WKWebView](https://developer.apple.com/documentation/webkit/wkwebview). -The Desktop implementation of this library relies on [JavaFX WebView](https://docs.oracle.com/javase/8/javafx/api/javafx/scene/web/WebView.html). +The Desktop implementation of this library relies on [JavaFX WebView](https://docs.oracle.com/javase/8/javafx/api/javafx/scene/web/WebView.html) for version <= 1.2.0. ## Basic Usage @@ -35,38 +35,32 @@ This will display a WebView in your Compose layout that shows the URL provided. ## WebView State This library provides a *WebViewState* class as a state holder to hold the state for the WebView. ```kotlin -public class WebViewState(webContent: WebContent) { - public var lastLoadedUrl: String? by mutableStateOf(null) +class WebViewState(webContent: WebContent) { + var lastLoadedUrl: String? by mutableStateOf(null) internal set /** * The content being loaded by the WebView */ - public var content: WebContent by mutableStateOf(webContent) + var content: WebContent by mutableStateOf(webContent) /** * Whether the WebView is currently [LoadingState.Loading] data in its main frame (along with * progress) or the data loading has [LoadingState.Finished]. See [LoadingState] */ - public var loadingState: LoadingState by mutableStateOf(LoadingState.Initializing) + var loadingState: LoadingState by mutableStateOf(LoadingState.Initializing) internal set /** * Whether the webview is currently loading data in its main frame */ - public val isLoading: Boolean - get() = loadingState !is Finished + val isLoading: Boolean + get() = loadingState !is LoadingState.Finished /** * The title received from the loaded content of the current page */ - public var pageTitle: String? by mutableStateOf(null) - internal set - - /** - * the favicon received from the loaded content of the current page - */ - public var pageIcon: Bitmap? by mutableStateOf(null) + var pageTitle: String? by mutableStateOf(null) internal set /** @@ -74,22 +68,20 @@ public class WebViewState(webContent: WebContent) { * Errors could be from any resource (iframe, image, etc.), not just for the main page. * For more fine grained control use the OnError callback of the WebView. */ - public val errorsForCurrentRequest: SnapshotStateList = mutableStateListOf() + val errorsForCurrentRequest: SnapshotStateList = mutableStateListOf() /** - * The saved view state from when the view was destroyed last. To restore state, - * use the navigator and only call loadUrl if the bundle is null. - * See WebViewSaveStateSample. + * Custom Settings for WebView. */ - public var viewState: Bundle? = null - internal set + val webSettings: WebSettings by mutableStateOf(WebSettings()) // We need access to this in the state saver. An internal DisposableEffect or AndroidView // onDestroy is called after the state saver and so can't be used. - internal var webView by mutableStateOf(null) + internal var webView by mutableStateOf(null) } ``` It can be created using the *rememberWebViewState* function, which can be remembered across Compositions. + ```kotlin val state = rememberWebViewState("https://github.com/KevinnZou/compose-webview-multiplatform") @@ -101,7 +93,7 @@ val state = rememberWebViewState("https://github.com/KevinnZou/compose-webview-m * Note that these headers are used for all subsequent requests of the WebView. */ @Composable -public fun rememberWebViewState( +fun rememberWebViewState( url: String, additionalHttpHeaders: Map = emptyMap() ): WebViewState = @@ -200,11 +192,12 @@ class WebViewNavigator(private val coroutineScope: CoroutineScope) { } ``` It can be created using the *rememberWebViewNavigator* function, which can be remembered across Compositions. + ```kotlin val navigator = rememberWebViewNavigator() @Composable -public fun rememberWebViewNavigator( +fun rememberWebViewNavigator( coroutineScope: CoroutineScope = rememberCoroutineScope() ): WebViewNavigator = remember(coroutineScope) { WebViewNavigator(coroutineScope) } ``` diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 2be51ef9..d5bc732d 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -28,3 +28,11 @@ compose.desktop { } } } + +afterEvaluate { + tasks.withType { + jvmArgs("--add-opens", "java.desktop/sun.awt=ALL-UNNAMED") + jvmArgs("--add-opens", "java.desktop/sun.lwawt=ALL-UNNAMED") + jvmArgs("--add-opens", "java.desktop/sun.lwawt.macosx=ALL-UNNAMED") + } +} diff --git a/desktopApp/src/jvmMain/kotlin/main.kt b/desktopApp/src/jvmMain/kotlin/main.kt index db836ad0..a3486246 100644 --- a/desktopApp/src/jvmMain/kotlin/main.kt +++ b/desktopApp/src/jvmMain/kotlin/main.kt @@ -1,9 +1,33 @@ +import androidx.compose.material.Text +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.window.Window import androidx.compose.ui.window.application import com.multiplatform.webview.MainWebView +import com.multiplatform.webview.web.Cef +import java.io.File fun main() = application { Window(onCloseRequest = ::exitApplication) { - MainWebView() + var restartRequired by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + Cef.init(builder = { + installDir = File("jcef-bundle") + }, onError = { + it.printStackTrace() + }) { + restartRequired = true + } + } + + if (restartRequired) { + Text(text = "Restart required.") + } else { + MainWebView() + } } } \ No newline at end of file diff --git a/webview/build.gradle.kts b/webview/build.gradle.kts index 1010788d..8c541a2f 100644 --- a/webview/build.gradle.kts +++ b/webview/build.gradle.kts @@ -1,7 +1,5 @@ @file:Suppress("UNUSED_VARIABLE", "OPT_IN_USAGE") -import org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode - plugins { kotlin("multiplatform") id("com.android.library") @@ -9,16 +7,6 @@ plugins { // id("convention.publication") } -val os = org.gradle.internal.os.OperatingSystem.current() - -val platform = when { - os.isWindows -> "win" - os.isMacOsX -> "mac" - else -> "linux" -} - -val jdkVersion = "17" - kotlin { // explicitApi = ExplicitApiMode.Strict @@ -72,13 +60,8 @@ kotlin { val desktopMain by getting { dependencies { implementation(compose.desktop.common) - implementation("org.openjfx:javafx-base:$jdkVersion:${platform}") - implementation("org.openjfx:javafx-graphics:$jdkVersion:${platform}") - implementation("org.openjfx:javafx-controls:$jdkVersion:${platform}") - implementation("org.openjfx:javafx-media:$jdkVersion:${platform}") - implementation("org.openjfx:javafx-web:$jdkVersion:${platform}") - implementation("org.openjfx:javafx-swing:$jdkVersion:${platform}") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-javafx:1.7.2") + implementation("me.friwi:jcefmaven:110.0.25.1") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.7.2") } } } diff --git a/webview/src/commonMain/kotlin/com/multiplatform/webview/WebViewApp.kt b/webview/src/commonMain/kotlin/com/multiplatform/webview/WebViewApp.kt index ba9b1bd2..90517a62 100644 --- a/webview/src/commonMain/kotlin/com/multiplatform/webview/WebViewApp.kt +++ b/webview/src/commonMain/kotlin/com/multiplatform/webview/WebViewApp.kt @@ -20,7 +20,8 @@ internal fun WebViewApp() { @Composable internal fun WebViewSample() { MaterialTheme { - val webViewState = rememberWebViewState("https://github.com/KevinnZou/compose-webview-multiplatform") + val webViewState = + rememberWebViewState("https://github.com/KevinnZou/compose-webview-multiplatform") webViewState.webSettings.apply { isJavaScriptEnabled = true androidWebSettings.apply { diff --git a/webview/src/commonMain/kotlin/com/multiplatform/webview/sample/HtmlWebViewSample.kt b/webview/src/commonMain/kotlin/com/multiplatform/webview/sample/HtmlWebViewSample.kt index e728ef6b..31232eec 100644 --- a/webview/src/commonMain/kotlin/com/multiplatform/webview/sample/HtmlWebViewSample.kt +++ b/webview/src/commonMain/kotlin/com/multiplatform/webview/sample/HtmlWebViewSample.kt @@ -28,7 +28,7 @@ internal fun BasicWebViewWithHTMLSample() { Compose WebView Multiplatform diff --git a/webview/src/commonMain/kotlin/com/multiplatform/webview/setting/PlatformWebSettings.kt b/webview/src/commonMain/kotlin/com/multiplatform/webview/setting/PlatformWebSettings.kt index f9f78813..f7126b5a 100644 --- a/webview/src/commonMain/kotlin/com/multiplatform/webview/setting/PlatformWebSettings.kt +++ b/webview/src/commonMain/kotlin/com/multiplatform/webview/setting/PlatformWebSettings.kt @@ -172,7 +172,10 @@ sealed class PlatformWebSettings { var safeBrowsingEnabled: Boolean = true ) : PlatformWebSettings() - data object DesktopWebSettings : PlatformWebSettings() + data class DesktopWebSettings( + var offScreenRendering: Boolean = false, + var transparent: Boolean = false, + ) : PlatformWebSettings() data object IOSWebSettings : PlatformWebSettings() } \ No newline at end of file diff --git a/webview/src/commonMain/kotlin/com/multiplatform/webview/setting/WebSettings.kt b/webview/src/commonMain/kotlin/com/multiplatform/webview/setting/WebSettings.kt index fb263838..c386c89a 100644 --- a/webview/src/commonMain/kotlin/com/multiplatform/webview/setting/WebSettings.kt +++ b/webview/src/commonMain/kotlin/com/multiplatform/webview/setting/WebSettings.kt @@ -14,7 +14,7 @@ class WebSettings { /** * Desktop platform specific settings */ - val desktopWebSettings = PlatformWebSettings.DesktopWebSettings + val desktopWebSettings = PlatformWebSettings.DesktopWebSettings() /** * iOS platform specific settings diff --git a/webview/src/commonMain/kotlin/com/multiplatform/webview/web/IWebView.kt b/webview/src/commonMain/kotlin/com/multiplatform/webview/web/IWebView.kt index d9954f95..d996855d 100644 --- a/webview/src/commonMain/kotlin/com/multiplatform/webview/web/IWebView.kt +++ b/webview/src/commonMain/kotlin/com/multiplatform/webview/web/IWebView.kt @@ -50,5 +50,10 @@ interface IWebView { */ fun stopLoading() + /** + * Evaluates the given JavaScript in the context of the currently displayed page. + * and returns the result of the evaluation. + * Note: The callback will not be called from desktop platform because it is not supported by CEF currently. + */ fun evaluateJavaScript(script: String, callback: ((String) -> Unit)? = null) } \ No newline at end of file diff --git a/webview/src/desktopMain/kotlin/com/multiplatform/webview/web/Cef.kt b/webview/src/desktopMain/kotlin/com/multiplatform/webview/web/Cef.kt new file mode 100644 index 00000000..e884aa4f --- /dev/null +++ b/webview/src/desktopMain/kotlin/com/multiplatform/webview/web/Cef.kt @@ -0,0 +1,495 @@ +package com.multiplatform.webview.web + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import me.friwi.jcefmaven.CefAppBuilder +import me.friwi.jcefmaven.EnumProgress +import me.friwi.jcefmaven.IProgressHandler +import me.friwi.jcefmaven.MavenCefAppHandlerAdapter +import me.friwi.jcefmaven.impl.progress.ConsoleProgressHandler +import me.friwi.jcefmaven.impl.step.check.CefInstallationChecker +import org.cef.CefApp +import org.cef.CefClient +import org.cef.CefSettings +import java.io.File + +data object Cef { + + private val state: MutableStateFlow = MutableStateFlow(State.New) + private var initError: Throwable? = null + private var cefAppInstance: CefApp? = null + + private val progressHandlers = mutableSetOf(ConsoleProgressHandler()) + private var progressState = EnumProgress.LOCATING + private var progressValue = EnumProgress.NO_ESTIMATION + + private val cefApp: CefApp + get() = checkNotNull(cefAppInstance) { + CefException.NotInitialized + } + + suspend fun init( + builder: Builder, + onError: (Throwable) -> Unit = { }, + onRestartRequired: () -> Unit = { } + ) = init( + appBuilder = builder.build(), + installDir = builder.installDir, + onError = onError, + onRestartRequired = onRestartRequired + ) + + suspend fun init( + builder: Builder.() -> Unit, + onError: (Throwable) -> Unit = { }, + onRestartRequired: () -> Unit = { } + ) = init( + builder = Builder().apply(builder), + onError = onError, + onRestartRequired = onRestartRequired + ) + + suspend fun init( + appBuilder: CefAppBuilder, + installDir: File, + onError: (Throwable) -> Unit = { }, + onRestartRequired: () -> Unit = { } + ) { + val builder = getInitBuilder(appBuilder) ?: return + val isInstallOk = CefInstallationChecker.checkInstallation(installDir) + + if (isInstallOk) { + val result = runCatching { + builder.build() + } + setInitResult(result) + result.exceptionOrNull()?.let(onError) + } else { + try { + builder.install() + } catch (error: Throwable) { + setInitResult(Result.failure(error)) + onError.invoke(error) + } + + setInitResult(Result.failure(CefException.ApplicationRestartRequired)) + onRestartRequired.invoke() + } + } + + suspend fun newClient(onProgress: IProgressHandler? = null): CefClient { + return when (state.value) { + State.New -> throw CefException.NotInitialized + State.Disposed -> throw CefException.Disposed + State.Error -> throw CefException.Error(initError) + State.Initialized -> cefApp.createClient() + State.Initializing -> { + val added = onProgress?.let { handler -> + handler.handleProgress(progressState, progressValue) + progressHandlers.add(handler) + } + + state.first { it != State.Initializing } + + if (added == true) { + progressHandlers.remove(onProgress) + } + + return newClient(onProgress) + } + } + } + + fun dispose() { + when (state.value) { + State.New, State.Disposed, State.Error -> return + State.Initializing -> { + runBlocking { + state.first { it != State.Initializing } + } + + return dispose() + } + + State.Initialized -> { + state.value = State.Disposed + cefAppInstance?.dispose() + cefAppInstance = null + } + } + } + + private fun getInitBuilder(builder: CefAppBuilder): CefAppBuilder? { + val currentState = state.value + + when (currentState) { + State.Disposed -> throw CefException.Disposed + State.Initializing, State.Initialized -> return null + State.New, State.Error -> state.value = State.Initializing + } + + if (currentState == State.Error) { + initError = null + } + + return builder.apply { + setProgressHandler(::dispatchProgress) + } + } + + private fun setInitResult(result: Result) { + val nextState = if (result.isSuccess) { + cefAppInstance = result.getOrThrow() + State.Initialized + } else { + initError = result.exceptionOrNull() + State.Error + } + + check(state.compareAndSet(State.Initializing, nextState)) { + "State.Initializing was expected." + } + } + + private fun dispatchProgress(state: EnumProgress, value: Float) { + progressState = state + progressValue = value + + progressHandlers.forEach { handler -> + handler.handleProgress(state, value) + } + } + + private sealed interface State { + data object New : State + data object Initializing : State + data object Initialized : State + data object Error : State + data object Disposed : State + } + + class Builder { + private var _mirrors: Array = arrayOf() + private var _args: Array = arrayOf() + private var _settings: Settings = Settings() + + /** + * If installation skipping is enabled, no checks against the installation directory will be performed and the download, + * installation and verification of the jcef natives has to be performed by the individual developer. + */ + var skipInstallation: Boolean = false + + /** + * Sets the install directory to use. Defaults to "./jcef-bundle". + */ + var installDir: File = File("jcef-bundle") + + /** + * Attach your own adapter to handle certain events in CEF. + */ + var appHandler: MavenCefAppHandlerAdapter? = null + + /** + * Set mirror urls that should be used when downloading jcef. First element will be attempted first. + * Mirror urls can contain placeholders that are replaced when a fetch is attempted: + * + * {mvn_version}: The version of jcefmaven (e.g. 100.0.14.3)
+ * {platform}: The desired platform for the download (e.g. linux-amd64)
+ * {tag}: The desired version tag for the download (e.g. jcef-08efede+cef-100.0.14+g4e5ba66+chromium-100.0.4896.75) + */ + fun mirrors(vararg mirror: String) = apply { + _mirrors = mirror + } + + /** + * Add one or multiple arguments to pass to the JCef library. + * Arguments may contain spaces. + * + * Due to installation using maven some arguments may be overwritten + * again depending on your platform. Make sure to not specify arguments + * that break the installation process (e.g. subprocess path, resources path...)! + * + * @param args the arguments to add + */ + fun args(vararg args: String) = apply { + _args = args + } + + /** + * Set [Settings] to change + * configuration parameters. + * + * Due to installation using maven some settings may be overwritten + * again depending on your platform. + */ + fun settings(settings: Settings) = apply { + _settings = settings + } + + /** + * Retrieve the embedded [Settings] instance to change + * configuration parameters. + * + * Due to installation using maven some settings may be overwritten + * again depending on your platform. + */ + fun settings(builder: Settings.() -> Unit) = apply { + settings(_settings.apply(builder)) + } + + fun build(): CefAppBuilder { + return CefAppBuilder().apply { + if (this@Builder._mirrors.isNotEmpty()) { + this.mirrors = this@Builder._mirrors.toList() + } + + if (this@Builder._args.isNotEmpty()) { + this.addJcefArgs(*this@Builder._args) + } + + this.setAppHandler(this@Builder.appHandler) + this.skipInstallation = this@Builder.skipInstallation + this.setInstallDir(this@Builder.installDir) + + this.cefSettings.apply { + this.cache_path = _settings.cachePath + this.browser_subprocess_path = _settings.browserSubProcessPath + this.command_line_args_disabled = _settings.commandLineArgsDisabled + this.cookieable_schemes_exclude_defaults = + _settings.cookieableSchemesExcludeDefaults + this.cookieable_schemes_list = _settings.cookieableSchemesList + this.javascript_flags = _settings.javascriptFlags + this.locale = _settings.locale + this.locales_dir_path = _settings.localesDirPath + this.log_file = _settings.logFile + this.log_severity = _settings.logSeverity.toJCefSeverity() + this.pack_loading_disabled = _settings.packLoadingDisabled + this.persist_session_cookies = _settings.persistSessionCookies + this.remote_debugging_port = _settings.remoteDebuggingPort + this.resources_dir_path = _settings.resourcesDirPath + this.uncaught_exception_stack_size = _settings.uncaughtExceptionStackSize + this.user_agent = _settings.userAgent + this.user_agent_product = _settings.userAgentProduct + this.windowless_rendering_enabled = _settings.windowlessRenderingEnabled + } + } + } + } + + data class Settings( + /** + * The location where cache data will be stored on disk. If empty an in-memory + * cache will be used for some features and a temporary disk cache for others. + * HTML5 databases such as localStorage will only persist across sessions if a + * cache path is specified. + */ + var cachePath: String? = null, + + /** + * The path to a separate executable that will be launched for sub-processes. + * By default the browser process executable is used. See the comments on + * CefExecuteProcess() for details. Also configurable using the + * "browser-subprocess-path" command-line switch. + */ + var browserSubProcessPath: String? = null, + + /** + * Set to true to disable configuration of browser process features using + * standard CEF and Chromium command-line arguments. Configuration can still + * be specified using CEF data structures or via the + * CefApp::OnBeforeCommandLineProcessing() method. + */ + var commandLineArgsDisabled: Boolean = false, + var cookieableSchemesExcludeDefaults: Boolean = false, + var cookieableSchemesList: String? = null, + + /** + * Custom flags that will be used when initializing the V8 JavaScript engine. + * The consequences of using custom flags may not be well tested. Also + * configurable using the "js-flags" command-line switch. + */ + var javascriptFlags: String? = null, + + /** + * The locale string that will be passed to Blink. If empty the default + * locale of "en-US" will be used. This value is ignored on Linux where locale + * is determined using environment variable parsing with the precedence order: + * LANGUAGE, LC_ALL, LC_MESSAGES and LANG. Also configurable using the "lang" + * command-line switch. + */ + var locale: String? = null, + + /** + * The fully qualified path for the locales directory. If this value is empty + * the locales directory must be located in the module directory. This value + * is ignored on Mac OS X where pack files are always loaded from the app + * bundle Resources directory. Also configurable using the "locales-dir-path" + * command-line switch. + */ + var localesDirPath: String? = null, + + /** + * The directory and file name to use for the debug log. If empty, the + * default name of "debug.log" will be used and the file will be written + * to the application directory. Also configurable using the "log-file" + * command-line switch. + */ + var logFile: String? = null, + + /** + * The log severity. Only messages of this severity level or higher will be + * logged. Also configurable using the "log-severity" command-line switch with + * a value of "verbose", "info", "warning", "error", "error-report" or + * "disable". + */ + var logSeverity: LogSeverity = LogSeverity.Default, + + /** + * Set to true to disable loading of pack files for resources and locales. + * A resource bundle handler must be provided for the browser and render + * processes via CefApp::GetResourceBundleHandler() if loading of pack files + * is disabled. Also configurable using the "disable-pack-loading" command- + * line switch. + */ + var packLoadingDisabled: Boolean = false, + + /** + * To persist session cookies (cookies without an expiry date or validity + * interval) by default when using the global cookie manager set this value to + * true. Session cookies are generally intended to be transient and most Web + * browsers do not persist them. A |cache_path| value must also be specified to + * enable this feature. Also configurable using the "persist-session-cookies" + * command-line switch. + */ + var persistSessionCookies: Boolean = false, + + /** + * Set to a value between 1024 and 65535 to enable remote debugging on the + * specified port. For example, if 8080 is specified the remote debugging URL + * will be http: *localhost:8080. CEF can be remotely debugged from any CEF or + * Chrome browser window. Also configurable using the "remote-debugging-port" + * command-line switch. + */ + var remoteDebuggingPort: Int = 0, + + /** + * The fully qualified path for the resources directory. If this value is + * empty the cef.pak and/or devtools_resources.pak files must be located in + * the module directory on Windows/Linux or the app bundle Resources directory + * on Mac OS X. Also configurable using the "resources-dir-path" command-line + * switch. + */ + var resourcesDirPath: String? = null, + + /** + * The number of stack trace frames to capture for uncaught exceptions. + * Specify a positive value to enable the CefV8ContextHandler:: + * OnUncaughtException() callback. Specify 0 (default value) and + * OnUncaughtException() will not be called. Also configurable using the + * "uncaught-exception-stack-size" command-line switch. + */ + var uncaughtExceptionStackSize: Int = 0, + + /** + * Value that will be returned as the User-Agent HTTP header. If empty the + * default User-Agent string will be used. Also configurable using the + * "user-agent" command-line switch. + */ + var userAgent: String? = null, + + /** + * Value that will be inserted as the product portion of the default + * User-Agent string. If empty the Chromium product version will be used. If + * |userAgent| is specified this value will be ignored. Also configurable + * using the "user_agent_product" command-line switch. + */ + var userAgentProduct: String? = null, + + /** + * Set to true to enable windowless (off-screen) rendering support. Do not + * enable this value if the application does not use windowless rendering as + * it may reduce rendering performance on some systems. + */ + var windowlessRenderingEnabled: Boolean = false + ) { + + /** + * The log severity. Only messages of this severity level or higher will be + * logged. Also configurable using the "log-severity" command-line switch with + * a value of "verbose", "info", "warning", "error", "error-report" or + * "disable". + */ + fun logSeverity(severity: CefSettings.LogSeverity) = apply { + logSeverity = LogSeverity.fromJCefSeverity(severity) + } + + /** + * Log severity levels. + * + * This way we don't need to expose the jcef module. + */ + sealed interface LogSeverity { + + /** + * Default logging (currently INFO logging). + */ + data object Default : LogSeverity + + /** + * Verbose logging. + */ + data object Verbose : LogSeverity + + /** + * INFO logging. + */ + data object Info : LogSeverity + + /** + * WARNING logging. + */ + data object Warning : LogSeverity + + /** + * ERROR logging. + */ + data object Error : LogSeverity + + /** + * FATAL logging. + */ + data object Fatal : LogSeverity + + /** + * Completely disable logging. + */ + data object Disable : LogSeverity + + fun toJCefSeverity(): CefSettings.LogSeverity = when (this) { + Default -> CefSettings.LogSeverity.LOGSEVERITY_DEFAULT + Verbose -> CefSettings.LogSeverity.LOGSEVERITY_VERBOSE + Info -> CefSettings.LogSeverity.LOGSEVERITY_INFO + Warning -> CefSettings.LogSeverity.LOGSEVERITY_WARNING + Error -> CefSettings.LogSeverity.LOGSEVERITY_ERROR + Fatal -> CefSettings.LogSeverity.LOGSEVERITY_FATAL + Disable -> CefSettings.LogSeverity.LOGSEVERITY_DISABLE + else -> CefSettings.LogSeverity.LOGSEVERITY_DEFAULT + } + + companion object { + fun fromJCefSeverity(severity: CefSettings.LogSeverity): LogSeverity = + when (severity) { + CefSettings.LogSeverity.LOGSEVERITY_DEFAULT -> Default + CefSettings.LogSeverity.LOGSEVERITY_VERBOSE -> Verbose + CefSettings.LogSeverity.LOGSEVERITY_INFO -> Info + CefSettings.LogSeverity.LOGSEVERITY_WARNING -> Warning + CefSettings.LogSeverity.LOGSEVERITY_ERROR -> Error + CefSettings.LogSeverity.LOGSEVERITY_FATAL -> Fatal + CefSettings.LogSeverity.LOGSEVERITY_DISABLE -> Disable + else -> Default + } + } + } + } +} \ No newline at end of file diff --git a/webview/src/desktopMain/kotlin/com/multiplatform/webview/web/CefException.kt b/webview/src/desktopMain/kotlin/com/multiplatform/webview/web/CefException.kt new file mode 100644 index 00000000..f7479670 --- /dev/null +++ b/webview/src/desktopMain/kotlin/com/multiplatform/webview/web/CefException.kt @@ -0,0 +1,9 @@ +package com.multiplatform.webview.web + +sealed class CefException(override val message: String) : Exception(message) { + data object NotInitialized : CefException("Cef was not initialized.") + data object Disposed : CefException("Cef is disposed.") + data object ApplicationRestartRequired : CefException("Application needs to restart.") + + data class Error(val exception: Throwable?) : CefException("Got error: ${exception?.message}") +} \ No newline at end of file diff --git a/webview/src/desktopMain/kotlin/com/multiplatform/webview/web/DesktopWebView.kt b/webview/src/desktopMain/kotlin/com/multiplatform/webview/web/DesktopWebView.kt index a5c79e55..4cb3ecc9 100644 --- a/webview/src/desktopMain/kotlin/com/multiplatform/webview/web/DesktopWebView.kt +++ b/webview/src/desktopMain/kotlin/com/multiplatform/webview/web/DesktopWebView.kt @@ -1,23 +1,22 @@ package com.multiplatform.webview.web import co.touchlab.kermit.Logger -import javafx.application.Platform -import javafx.scene.web.WebView +import org.cef.browser.CefBrowser +import org.cef.network.CefPostData +import org.cef.network.CefPostDataElement +import org.cef.network.CefRequest /** * Created By Kevin Zou On 2023/9/12 */ -class DesktopWebView(private val webView: WebView) : IWebView { - private val engine = webView.engine +class DesktopWebView(private val webView: CefBrowser) : IWebView { - override fun canGoBack() = engine.canGoBack() + override fun canGoBack() = webView.canGoBack() - override fun canGoForward() = engine.canGoForward() + override fun canGoForward() = webView.canGoForward() override fun loadUrl(url: String, additionalHttpHeaders: Map) { - Platform.runLater { - engine.load(url) - } + webView.loadURL(url) } override fun loadHtml( @@ -27,36 +26,35 @@ class DesktopWebView(private val webView: WebView) : IWebView { encoding: String?, historyUrl: String? ) { - Platform.runLater { - engine.loadContent(html) + if (html != null) { + webView.loadHtml(html) } } - override fun postUrl(url: String, postData: ByteArray) = Platform.runLater { - engine.loadContent(postData.toString(), "text/html") + override fun postUrl(url: String, postData: ByteArray) { + val request = CefRequest.create().apply { + this.url = url + this.postData = CefPostData.create().apply { + this.addElement(CefPostDataElement.create().apply { + this.setToBytes(postData.size, postData) + }) + } + } + webView.loadRequest(request) } - override fun goBack() = Platform.runLater { - engine.goBack() - } + override fun goBack() = webView.goBack() - override fun goForward() = Platform.runLater { - engine.goForward() - } + override fun goForward() = webView.goForward() - override fun reload() = Platform.runLater { - engine.reload() - } + override fun reload() = webView.reload() - override fun stopLoading() = Platform.runLater { - engine.stopLoading() - } + override fun stopLoading() = webView.stopLoad() - override fun evaluateJavaScript(script: String, callback: ((String) -> Unit)?) = Platform.runLater { + override fun evaluateJavaScript(script: String, callback: ((String) -> Unit)?) { Logger.i { "evaluateJavaScript: $script" } - val res = engine.executeScript(script) - callback?.invoke(res.toString()) + webView.executeJavaScript(script, "", 0) } } \ No newline at end of file diff --git a/webview/src/desktopMain/kotlin/com/multiplatform/webview/web/WebEngineExt.kt b/webview/src/desktopMain/kotlin/com/multiplatform/webview/web/WebEngineExt.kt index 010c4844..bb5133bd 100644 --- a/webview/src/desktopMain/kotlin/com/multiplatform/webview/web/WebEngineExt.kt +++ b/webview/src/desktopMain/kotlin/com/multiplatform/webview/web/WebEngineExt.kt @@ -1,95 +1,118 @@ package com.multiplatform.webview.web import co.touchlab.kermit.Logger -import javafx.concurrent.Worker.State.CANCELLED -import javafx.concurrent.Worker.State.FAILED -import javafx.concurrent.Worker.State.READY -import javafx.concurrent.Worker.State.RUNNING -import javafx.concurrent.Worker.State.SCHEDULED -import javafx.concurrent.Worker.State.SUCCEEDED -import javafx.scene.web.WebEngine +import org.cef.CefSettings +import org.cef.browser.CefBrowser +import org.cef.browser.CefFrame +import org.cef.handler.CefDisplayHandler +import org.cef.handler.CefLoadHandler +import org.cef.network.CefRequest /** * Created By Kevin Zou On 2023/9/12 */ -internal fun WebEngine.getCurrentUrl(): String? { - if (history.entries.size <= 0) return null - return history.entries[history.currentIndex].url +internal fun CefBrowser.getCurrentUrl(): String? { + return this.url } -internal fun WebEngine.stopLoading() { - loadWorker.cancel() -} +internal fun CefBrowser.addDisplayHandler(state: WebViewState) { + this.client.addDisplayHandler(object : CefDisplayHandler { + override fun onAddressChange(browser: CefBrowser?, frame: CefFrame?, url: String?) { + state.lastLoadedUrl = getCurrentUrl() + } -internal fun WebEngine.goForward() { - if (canGoForward()) { - history.go(1) - } -} + override fun onTitleChange(browser: CefBrowser?, title: String?) { + Logger.i { "titleProperty: $title" } + state.pageTitle = title + } -internal fun WebEngine.goBack() { - if (canGoBack()) { - history.go(-1) - } -} + override fun onTooltip(browser: CefBrowser?, text: String?): Boolean { + return false + } -internal fun WebEngine.canGoBack(): Boolean { - return history.maxSize > 0 && history.currentIndex != 0 -} + override fun onStatusMessage(browser: CefBrowser?, value: String?) {} -internal fun WebEngine.canGoForward(): Boolean { - return history.maxSize > 0 && history.currentIndex != history.maxSize - 1 -} + override fun onConsoleMessage( + browser: CefBrowser?, + level: CefSettings.LogSeverity?, + message: String?, + source: String?, + line: Int + ): Boolean { + return false + } -internal fun WebEngine.addLoadListener(state: WebViewState, navigator: WebViewNavigator) { - titleProperty().addListener { _, _, newValue -> - Logger.i { "titleProperty: $newValue" } - state.pageTitle = newValue - } + override fun onCursorChange(browser: CefBrowser?, cursorType: Int): Boolean { + return false + } - loadWorker.stateProperty().addListener { _, _, newValue -> - when (newValue) { + }) +} - READY, SCHEDULED -> { - Logger.i { "READY or SCHEDULED" } +internal fun CefBrowser.addLoadListener(state: WebViewState, navigator: WebViewNavigator) { + this.client.addLoadHandler(object : CefLoadHandler { + override fun onLoadingStateChange( + browser: CefBrowser?, + isLoading: Boolean, + canGoBack: Boolean, + canGoForward: Boolean + ) { + if (state.loadingState == LoadingState.Finished) { state.loadingState = LoadingState.Initializing - state.errorsForCurrentRequest.clear() } + navigator.canGoBack = canGoBack + navigator.canGoForward = canGoForward + } - RUNNING -> { - state.loadingState = LoadingState.Loading(0f) - } + override fun onLoadStart( + browser: CefBrowser?, + frame: CefFrame?, + transitionType: CefRequest.TransitionType? + ) { + Logger.i { "Load Start" } + state.loadingState = LoadingState.Loading(0F) + } - SUCCEEDED -> { - Logger.i { "SUCCEEDED ${getCurrentUrl()}" } - navigator.canGoBack = canGoBack() - navigator.canGoForward = canGoForward() - state.loadingState = LoadingState.Finished - state.lastLoadedUrl = getCurrentUrl() - } + override fun onLoadEnd(browser: CefBrowser?, frame: CefFrame?, httpStatusCode: Int) { + Logger.i { "Load End" } + state.loadingState = LoadingState.Finished + navigator.canGoBack = canGoBack() + navigator.canGoBack = canGoForward() + state.lastLoadedUrl = getCurrentUrl() + } - FAILED, CANCELLED -> { - state.loadingState = LoadingState.Finished - state.errorsForCurrentRequest.add( - WebViewError( - code = 404, - description = "Failed to load url: ${getCurrentUrl()}" - ) + override fun onLoadError( + browser: CefBrowser?, + frame: CefFrame?, + errorCode: CefLoadHandler.ErrorCode?, + errorText: String?, + failedUrl: String? + ) { + state.loadingState = LoadingState.Finished + state.errorsForCurrentRequest.add( + WebViewError( + code = errorCode?.code ?: 404, + description = "Failed to load url: ${failedUrl}\n$errorText" ) - } + ) } - } - - loadWorker.progressProperty().addListener { _, _, newValue -> - if (newValue.toFloat() < 0f) return@addListener - state.loadingState = LoadingState.Loading(newValue.toFloat()) - } - - history.currentIndexProperty().addListener { _, _, _ -> - val currentUrl = getCurrentUrl() - Logger.i { "currentUrl: $currentUrl" } - if (currentUrl != null) { - state.lastLoadedUrl = currentUrl + + }) +} + +// can be used for more workarounds as well +internal fun String.applyWorkaroundsForHtmlString(): String { + val removeHexCharHtml = + "#(([0-9a-fA-F]{2}){3}|([0-9a-fA-F]){3})".toRegex().replace(this) { matchResult -> + matchResult.value.substring(1) } - } + return removeHexCharHtml +} + +internal fun String.toDataUri(): String { + return "data:text/html,${this.applyWorkaroundsForHtmlString()}" +} + +internal fun CefBrowser.loadHtml(html: String) { + this.loadURL(html.toDataUri()) } \ No newline at end of file diff --git a/webview/src/desktopMain/kotlin/com/multiplatform/webview/web/WebView.desktop.kt b/webview/src/desktopMain/kotlin/com/multiplatform/webview/web/WebView.desktop.kt index d810a0c4..a586287e 100644 --- a/webview/src/desktopMain/kotlin/com/multiplatform/webview/web/WebView.desktop.kt +++ b/webview/src/desktopMain/kotlin/com/multiplatform/webview/web/WebView.desktop.kt @@ -3,14 +3,15 @@ package com.multiplatform.webview.web import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.awt.SwingPanel -import javafx.application.Platform -import javafx.embed.swing.JFXPanel -import javafx.scene.Scene -import javafx.scene.layout.StackPane -import javafx.scene.web.WebView +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.cef.CefClient +import org.cef.browser.CefBrowser @Composable @@ -40,30 +41,45 @@ fun DesktopWebView( onDispose: () -> Unit ) { val currentOnDispose by rememberUpdatedState(onDispose) + val client by produceState(null) { + value = withContext(Dispatchers.IO) { + runCatching { Cef.newClient() }.getOrNull() + } + } + val browser: CefBrowser? = remember(client, state.webSettings.desktopWebSettings) { + val url = when (val current = state.content) { + is WebContent.Url -> current.url + is WebContent.Data -> current.data.toDataUri() + else -> null + } + + client?.createBrowser( + url, + state.webSettings.desktopWebSettings.offScreenRendering, + state.webSettings.desktopWebSettings.transparent + ) + } + + browser?.let { + state.webView = DesktopWebView(it) + + SwingPanel( + factory = { + browser.apply { + addDisplayHandler(state) + addLoadListener(state, navigator) + } + onCreated() + browser.uiComponent + }, + modifier = modifier, + ) + } DisposableEffect(Unit) { onDispose { + client?.dispose() currentOnDispose() } } - - SwingPanel( - factory = { - JFXPanel().apply { - Platform.runLater { - val webView = WebView().apply { - isVisible = true - engine.addLoadListener(state, navigator) - engine.isJavaScriptEnabled = state.webSettings.isJavaScriptEnabled - } - val root = StackPane() - root.children.add(webView) - this.scene = Scene(root) - state.webView = DesktopWebView(webView) - onCreated() - } - } - }, - modifier = modifier, - ) } \ No newline at end of file