KevinnZou / compose-webview-multiplatform

WebView for JetBrains Compose Multiplatform
https://kevinnzou.github.io/compose-webview-multiplatform/
Apache License 2.0
503 stars 65 forks source link

Local file viewing #15

Closed joejackson3 closed 1 year ago

joejackson3 commented 1 year ago

This looks like a very promising library. No problem viewing URLs. Is it possible to load local HTML files saved in the Resource directory? Thank you

KevinnZou commented 1 year ago

Thank you for your suggestion! Currently, this library can load HTML code using the rememberWebViewStateWithHTMLData. I understand that you would like a method to directly load a file, but implementing this feature may take some time. Handling the file system in Kotlin Multiplatform can be complex. I will conduct further research and work on supporting it as soon as possible. If you are familiar with this topic, feel free to submit a pull request directly. Thank you once again for your input!

joejackson3 commented 1 year ago

Thanks for the feedback. I have made some progress in my Kotlin Multiplatform project. On the Android app, I can now successfully load local files using this URL:

rememberWebViewState("file:///android_asset/filename.html

where the local files are saved in the [projectname]/shared/src/main/assets folder. Now I'm struggling still with the iOS app. It crashes when attempting to load a local file, however works fine by going to an external URL...

KevinnZou commented 1 year ago

Thanks for the feedback. I have made some progress in my Kotlin Multiplatform project. On the Android app, I can now successfully load local files using this URL:

rememberWebViewState("file:///android_asset/filename.html

where the local files are saved in the [projectname]/shared/src/main/assets folder. Now I'm struggling still with the iOS app. It crashes when attempting to load a local file, however works fine by going to an external URL...

Hi, I just found that Compose Multiplatform now supports reading files from the shared module. This means that I was able to implement the feature for loading local HTML files. However, During testing, I encountered a few issues, but I believe they can be resolved quickly. Once all the issues are solved, I will release the new version. Thank you for your patience!

KevinnZou commented 1 year ago

@DATL4G One issue related to the Desktop is that the loadHtml method of CefBrowser does not work if we do not have HTML code ready at createBrowser stage. Since I need to read the HTML code from the file, I set the URL empty first and call loadHtml later when the data is ready. However, it turns out that the browser will not refresh with the new data but stay with the initial URL. You can check this branch for details. The log is as follows:

Info: Create Browser: data:text/html,<html></html>
Info: DesktopWebView loadHtml
Info: CefBrowser.loadHtml
Info: Load Start
objc[18363]: Class WebSwapCGLLayer is implemented in both /System/Library/Frameworks/WebKit.framework/Versions/A/Frameworks/WebCore.framework/Versions/A/Frameworks/libANGLE-shared.dylib (0x7ffb4f2c8378) and /Users/kevinnzou/NetEase/Compose/compose-webview-multiplatform/sample/desktopApp/jcef-bundle/Chromium Embedded Framework.framework/Libraries/libGLESv2.dylib (0x111352220). One of the two will be used. Which one is undefined.
Info: Load End
Info: titleProperty: data:text/html,<html></html>

I set the initial URL with <html></html> and pass the latest HTML code to CefBrowser.loadHtml. But it still loads the initial URL. Is this a bug or did I miss something?

DatL4g commented 1 year ago

I already saw that and have an idea to fix it in KCEF. Since the loadHtml is basically loadUrl with some setup I can add a method to create a browser with html data

KevinnZou commented 1 year ago

I already saw that and have an idea to fix it in KCEF. Since the loadHtml is basically loadUrl with some setup I can add a method to create a browser with html data

I think you already support creating a browser with HTML data in the following code?

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()
            is WebContent.File -> "<html></html>".toDataUri()
            else -> "about:blank"
        }
        co.touchlab.kermit.Logger.i {
            "Create Browser: $url"
        }

        client?.createBrowser(
            url,
            state.webSettings.desktopWebSettings.offScreenRendering,
            state.webSettings.desktopWebSettings.transparent,
            createModifiedRequestContext(state.webSettings)
        )
    }

The problem is that it does not refresh with the later loadHtml call but stays with the initial data.

DatL4g commented 1 year ago

Well yes and no It works, but isn't recommended, the loadHtml method uses a better trick.

That it doesn't refresh immediately is expected behavior and not my design decision, I don't exactly know why, maybe you just have to call stopLoading on the browser or something

DatL4g commented 1 year ago

@KevinnZou I added the createBrowserWithHtml method and basically it's working. But we have a problem with the states or something, the method works as expected in my KCEF example, but fails in this repository. That may also cause your mentioned error that calling loadHtml doesn't work

Edit: found the error, it's the custom request context, investigating further

DatL4g commented 1 year ago

@KevinnZou alright I removed the custom request context It breaks the html thing and cookies, so the cookies are working on desktop as well now.

Cef already provides a mehtod to set a custom user-agent in the settings.

KevinnZou commented 1 year ago

@KevinnZou alright I removed the custom request context It breaks the html thing and cookies, so the cookies are working on desktop as well now.

Cef already provides a mehtod to set a custom user-agent in the settings.

@DatL4g I tested the latest code with local HTML loading and still failed. The problem is still the same. If we cannot provide an initial URL and start it with about:blank, the following loadHtml will not work. If you want to test it, you could give me push access to your forked repo. I already picked the commit about local HTML from the main repo to your repo and solved the conflict.

when (val current = state.content) {
            is WebContent.Url -> client?.createBrowser(
                current.url,
                rendering,
                state.webSettings.desktopWebSettings.transparent
            )
            is WebContent.Data -> client?.createBrowserWithHtml(
                current.data,
                current.baseUrl ?: KCEFBrowser.BLANK_URI,
                rendering,
                state.webSettings.desktopWebSettings.transparent
            )
            // this branch has the problem
            else -> {
                client?.createBrowser(
                    KCEFBrowser.BLANK_URI,
                    rendering,
                    state.webSettings.desktopWebSettings.transparent
                )
            }
        }
DatL4g commented 1 year ago

@KevinnZou this is done by choice in jcef internally. You could either provide the correct url in the first place or wait for the previous request to finish (https://github.com/KevinnZou/compose-webview-multiplatform/blob/main/webview/src/desktopMain/kotlin/com/multiplatform/webview/web/WebEngineExt.kt#L52)

Another thing you could do as well is something like this, but this is more of a hacky way to do it

LaunchedEffect(Unit) {
    withContext(Dispatchers.IO) {
        delay(100)
        state.webView?.stopLoading()
        delay(100)
        when (val current = state.content) {
            is WebContent.Url -> state.webView?.loadUrl(current.url)
            else -> { }
        }
    }
}

File URLs are working fine in CEF btw, so you could either call

client?.createBrowser(
    "file://the_file_loaction",
    rendering,
    state.webSettings.desktopWebSettings.transparent
)

Or read the file content yourself and call

client?.createBrowserWithHtml(
    fileContent,
    rendering,
    state.webSettings.desktopWebSettings.transparent
)
KevinnZou commented 1 year ago

Or read the file content yourself and call

client?.createBrowserWithHtml(
  fileContent,
  rendering,
  state.webSettings.desktopWebSettings.transparent
)

Yes, this will be a solution. But this will force me to read the file at the time of Webview's creation. In my current plan, it will init the browser with an empty URL and then read the file and call loadHtml to load the latest HTML code. I already pushed the code to https://github.com/DatL4g/compose-webview-multiplatform/pull/new/feature/test_local_html. Could you review it and give some suggestions?

DatL4g commented 1 year ago

You could do something like this for example:

val fileContent = produceState(null, state.content) {
        value = if (state.content is WebContent.File) {
            withContext(Dispatchers.IO) {
                loadFileContent(state.content)
            }
        } else {
            null
        }
    }

val browser: KCEFBrowser? = remember(client, state.webSettings.desktopWebSettings, fileContent) {
        val rendering = if (state.webSettings.desktopWebSettings.offScreenRendering) {
            CefRendering.OFFSCREEN
        } else {
            CefRendering.DEFAULT
        }

        when (val current = state.content) {
            // ...
            is WebContent.File -> client?.createBrowserWithHtml(
                fileContent ?: String(),
                KCEFBrowser.BLANK_URI,
                rendering,
                state.webSettings.desktopWebSettings.transparent
            )
        }
    }

Your commits looking good so far, or you add a load listener to the client and wait for about:blank to finish, then you can call loadHtml

KevinnZou commented 1 year ago

I am reading the resource in the common code thus I prefer to do that:

@OptIn(ExperimentalResourceApi::class)
suspend fun loadHtmlFile(fileName: String) {
    val res = resource(fileName)
    val html = res.readBytes().decodeToString().trimIndent()
    stopLoading()
    loadHtml(html, encoding = "utf-8")
}

However, it seems that stopLoading() does not work. Is that because the browser is still loading and stopLoading is only workable when loading is finished?

DatL4g commented 1 year ago

I don't know 100% the implemntation details, but as far as my testing went, the stopLoading method is async, means you have to add a delay if you want to do it like this

KevinnZou commented 1 year ago

You could do something like this for example:

val fileContent = produceState(null, state.content) {
        value = if (state.content is WebContent.File) {
            withContext(Dispatchers.IO) {
                loadFileContent(state.content)
            }
        } else {
            null
        }
    }

val browser: KCEFBrowser? = remember(client, state.webSettings.desktopWebSettings, fileContent) {
        val rendering = if (state.webSettings.desktopWebSettings.offScreenRendering) {
            CefRendering.OFFSCREEN
        } else {
            CefRendering.DEFAULT
        }

        when (val current = state.content) {
            // ...
            is WebContent.File -> client?.createBrowserWithHtml(
                fileContent ?: String(),
                KCEFBrowser.BLANK_URI,
                rendering,
                state.webSettings.desktopWebSettings.transparent
            )
        }
    }

@DatL4g I finally choose to go with this plan. It works well except that we will read the HTML file twice and I think it is acceptable. Thank you for your help!

KevinnZou commented 1 year ago

@joejackson3 Basically, I have solved all the issues and it should support loading HTML files like that:

val webViewState = rememberWebViewStateWithHTMLFile(
        fileName = "index.html",
    )

There is one remaining issue with resource reading on the Android platform, which appears to be a bug with Compose Multiplatform. I have already reported the issue here. However, this bug should not affect iOS and Desktop targets. Please feel free to perform some tests on this branch. I will merge it once the previous issue has been resolved. Thank you for your patience!

joejackson3 commented 1 year ago

Thank you. I have now tried version 1.5 api("io.github.kevinnzou:compose-webview-multiplatform:1.5.0") but I don't think I can use rememberWebViewStateWithHTMLFile. Error: Unresolved reference

I note your repo now has 8 branches. How can I implement the latest branch where you have added the rememberWebViewStateWithHTMLFile functions?

KevinnZou commented 1 year ago

@joejackson3 It is on feature/local_html_support and not merged into the main branch yet. Thus, you cannot use it in version 1.5.0. You can either test on that branch or wait for the upcoming version release. I intend to merge it and release the new version within the next two days.

joejackson3 commented 1 year ago

hello Kevinn. I've got high hopes in your repo. At the moment I'm still quite inexperienced so I don't even know how I can test a branch and implement it into my Kotlin KMP project. I managed to use the rememberWebViewStateWithHTMLData(FileResource1(MR.files.LV_all)) function, after using an expect/actual construct to buffer read the file resource. It actually displays the HTML well, but the problem I noticed then, the HTML does not "see" external files, like my CSS or JS file. In a way, this was so easy in plain Android without multiplatform - you can just dump all files into an assets folder, and webview happily reads all local resources, like images, CSS, etc. I will be keen to test your rememberWebViewStateWithHTMLFile function soon. Will this potentially see other local files in the resource folder

KevinnZou commented 1 year ago

@joejackson3 Hi, I really appreciate your interest in this library. Since it has not been merged into the main branch and released, you can not integrate it into your KMP project. You can only clone my project and checkout to feature/local_html_support to test HTML File reading.

Regarding external files, unfortunately, I think my implementation also cannot support it since I am just reading the file and passing it to load the HTML code method. Thus, it should behave like rememberWebViewStateWithHTMLData(FileResource1(MR.files.LV_all)).

However, I also think it is a problem that needs to be fixed. I will look into it and try to find a way to support reading external files. Thanks for your suggestions!

KevinnZou commented 1 year ago

@joejackson3 I have found the solution to support external files and pushed it to feature/local_html_support. You just need to put your resource under commonMain/resources/assets and it should work. However, it just supports Android and iOS now. Desktop should also support it but I plan to implement it after we switch to KCEF. Feel free to test it on that branch and please let me know if you encounter any problems.

joejackson3 commented 1 year ago

thanks. I will wait for your new bundle to try this. I think there is a way of having my own local module from a branch of a repo, but I don't know how to do that yet. So far, it does what I need for the Android side. Actual function call in androidMain:

 val webViewState =
        rememberWebViewState("file:///android_asset/"+t+(if (a) "?mode=0&" else "?") + "s="+gameUiState.htmlsize.toString())

On the iOS side, what I've done is using MOKO resources and then use readText to convert the file resource into plain text and then use rememberWebViewStateWithHTMLData(MR.files.LV_all) I'm more keen to get this project out and tested, so I'm compromising elegance for practicality. I have merged the CSS and JS into the HTML files for the iOS side, but the only problem I still have is, with the rememberWebViewStateWithHTMLData method I cannot pass any URL parameters. I use those for changing text size and background (depending on DarkMode settings). This works like a breeze on the Android side, with the function call and URL parameters quoted above. I have also tried using the official Android Developer suggestions for using in-app content and asset handlers described here, which describes how to call local files with a regular URL, but this fails in both Android and iOS (blank browser shown, no errors).

KevinnZou commented 1 year ago

@joejackson3 I have published the latest version which supports loading local HTML files. You can refer to this post for instructions https://github.com/KevinnZou/compose-webview-multiplatform/releases/tag/1.6.0. Please let me know if you encounter any issues.

joejackson3 commented 1 year ago

Thank you very much for merging the local file into the repo. I have now tried it and this is my experience so far: On the Android side, it works well. When I use this call: rememberWebViewStateWithHTMLFile(t+(if (a) "?mode=0&" else "?") + "s="+gameUiState.htmlsize.toString()) where t is the file name of the HTML, it displays no problem. It even reads PNGs within the same directory and displays them correctly. The URL parameters are read correctly by the integrated JS. The files are in [project]/src/androidMain/resources/assets

When I use the same function call in the actual iOS function, a blank page is shown on the iOS app. The X-Code log shows this error:

2023-10-28 09:25:24.015154+0100 CMR4[1489:25072] [Process] 0x7f8f9b862220 - [pageProxyID=6, webPageID=7, PID=1536] WebPageProxy::didFailProvisionalLoadForFrame: frameID=3, isMainFrame=1, domain=NSURLErrorDomain, code=-1100, isMainFrame=1
2023-10-28 09:25:24.015735+0100 CMR4[1489:25072] 🔴 (ComposeWebView) WebView Loading Failed with error: The requested URL was not found on this server.

I suspected that I had to put a copy of the HTML files somewhere within the iOS project, so I put a copy here: [project]/src/iosMain/resources/assets but this has not made a difference. Where does the iOS rememberWebViewStateWithHTMLFile function look for the files?? I probably am not putting them into the right directory.

KevinnZou commented 1 year ago

Hi, you don't need to handle them separately. You just need to put the HTML files under commonMain/resources/assets folder and use them in the shared code like that

val webViewState = rememberWebViewStateWithHTMLFile(
        fileName = "index.html",
    )

Please refer to this sample for details.

joejackson3 commented 1 year ago

Thank you. Now trying to understand how the sample and webview modules work together. One thing I noted when I put distinguishing text into copies of my HTML files in [project]/src/androidMain/resources/assets and [project]/src/commonMain/resources/assets, rememberWebViewStateWithHTMLFile was definitely reading the androidMain and not commonMain resource! I was missing this statement in the shared build.gradle file: sourceSets["main"].resources.srcDirs("src/commonMain/resources") Now it is reading the commonMain resource. One step forward. I could build the sample that you linked OK, and it runs fine on iOS and Android simulator. I hope at the end of the day I will have understood how to integrate it and what I'm missing still.

joejackson3 commented 1 year ago

I'm still running into a brickwall - on iOS navigation failed - URL not found. If you can help me fix the remaining problem with my repository, I'd be happy to pay you for your time - you can contact me on haspohr at yahoo dot com.