diff --git a/Dockerfile b/Dockerfile
index de761f7..7c2e82f 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -41,18 +41,17 @@ RUN curl -fsSL -o /tmp/tools.zip \
&& mv "${ANDROID_HOME}/cmdline-tools/cmdline-tools" "${ANDROID_HOME}/cmdline-tools/latest" \
&& rm /tmp/tools.zip \
&& (yes | sdkmanager --licenses > /dev/null || true) \
- && sdkmanager "platform-tools" > /dev/null
+ && sdkmanager "platform-tools" "platforms;android-37.0" > /dev/null
RUN flutter config --android-sdk "${ANDROID_HOME}" \
&& flutter --disable-analytics \
&& flutter precache --android \
&& flutter doctor
-# 预热:跑一次与应用相同依赖的默认模板构建,把 Gradle 发行版/AGP/AndroidX
-# 组件、插件 AAR 和 SDK 平台下载进镜像,之后挂载源码的正式构建不再重复下载
+# 预热默认模板的 Gradle/AGP/AndroidX 和 SDK;GeckoView 的固定版本
+# 在应用构建时从 Mozilla Maven 下载,不在这里预热旧 WebView 插件。
RUN flutter create --org dev.dsh --project-name warmup /tmp/warmup \
&& cd /tmp/warmup \
- && flutter pub add webview_flutter webview_flutter_android file_selector \
&& flutter build apk --release \
&& cd / && rm -rf /tmp/warmup \
&& flutter doctor
diff --git a/README.md b/README.md
index b9f29d5..d1b37b9 100644
--- a/README.md
+++ b/README.md
@@ -1,16 +1,16 @@
# DSH 工作台(Android)
-DSH Web Manager 的 Android 客户端:基于系统 WebView 的轻量壳应用,全部构建在 Docker 内完成,宿主机无需安装 Flutter/Android SDK。
+DSH Web Manager 的 Android 客户端:使用随 APK 打包的 Mozilla GeckoView 内核,不依赖手机的系统 WebView。全部构建在 Docker 内完成,宿主机无需安装 Flutter/Android SDK。
## 特性
-- 系统 WebView(`webview_flutter`),不打包浏览器内核,体积小
-- 登录状态持久化:系统 WebView 的 Cookie + DOM 存储跨重启保留
+- 内置 GeckoView:Flutter 平台视图连接原生 GeckoSession,各设备使用同一发布版本的内核
+- 登录状态持久化:使用非隐私 Gecko profile,持久 Cookie + DOM 存储跨重启保留(会话有效期仍由站点控制)
- 全面屏适配:edge-to-edge、刘海/挖孔 `shortEdges`、深色启动底色无白闪
- 文件上传:`` 走系统文件选择器(工作区上传、备份导入)
-- 文件下载:拦截面板下载导航,交给系统 DownloadManager(携带登录 Cookie)
+- 文件下载:直接保存 Gecko 已认证请求的响应流,由系统“另存为”选择位置,不通过系统 WebView 获取 Cookie
- 返回键:优先回退页面历史,无历史时退出应用
-- 加载失败重试页、JS 原生对话框(alert/confirm)桥接
+- 加载失败重试页、JS 原生对话框桥接
## 构建
@@ -47,7 +47,24 @@ CI 可通过 secret 管理器注入 `DSH_STORE_PASSWORD` 和 `DSH_KEY_PASSWORD`
## 注意事项
-- **证书**:反代必须使用系统 WebView 信任的证书(如 Let's Encrypt)。自签证书会被拒绝,WebView 无法像浏览器那样手动放行。
+- **系统要求**:当前 GeckoView 158 最低支持 Android 8.0(API 26),构建使用 Android SDK 37.0;旧 Android 设备不能安装此版本。已有工具链镜像可用 `--no-cache` 重建以预装新 SDK。
+- **证书**:反代必须使用 Gecko 信任的有效证书(如 Let's Encrypt)。不提供忽略 TLS 错误或自签证书放行开关。
- **签名**:release 必须提供外部密钥库;缺失配置或密码/alias 不正确时构建失败,不回退到 debug 签名。此前的 debug 签名 APK 无法被新签名直接覆盖安装。
-- **Android 9 及以下**:下载保存到应用外部目录(免存储权限),不进公共下载目录。
+- **升级迁移**:旧版系统 WebView 的 Cookie/LocalStorage 不会迁移到 Gecko;首次升级需重新登录,服务器端数据不受影响。
+- **内核更新**:GeckoView 版本固定在 `android/app/build.gradle.kts`,来自 Mozilla 官方 Maven 仓库。内核随 APK 发布,不会自动下载替换;应定期检查稳定版安全更新,更新版本并完成回归后发布新 APK。
+- **开源许可**:GeckoView/Gecko 包含 MPL 2.0 等许可证下的组件。分发 APK 时需保留上游许可声明并履行相应源码提供义务;参见 [Mozilla 许可说明](https://www.mozilla.org/MPL/) 与 [GeckoView 项目](https://mozilla.github.io/geckoview/)。
+- **体积**:APK 包含 Gecko 原生库,体积会明显增加;继续按 ABI 分包,用户只安装匹配设备架构的 APK。
+- **下载生命周期**:保存过程中请保持应用运行;退出应用或进程被系统终止会中断下载,不提供系统 DownloadManager 的后台续传能力。
- **图标**:使用仓库根目录的 `logo.webp`。API 26+ 使用浅冰蓝底色的自适应图标,旧系统使用同一 logo 的传统图标;各密度资源均已生成。
+
+## 内核升级回归
+
+发布前除 `flutter analyze`、`flutter test` 和 APK 构建外,应在真实 Android 设备上检查:
+
+- 登录后重启应用,确认站点的持久登录状态及本地存储保留。
+- 工作区单文件/多文件上传、选择器取消、配置备份导入。
+- 需要登录的文件、目录 ZIP、配置备份下载,检查保存内容及取消流程。
+- JS alert/confirm/prompt、输入法、页面滚动、SPA 返回键、横竖屏及前后台切换。
+- 断网重试与无效证书拒绝;确认没有绕过 TLS 校验。
+
+Flutter 桥接单测不运行 Gecko 内核,不能替代上述真机测试。
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 7b122f9..3cf0722 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -13,7 +13,7 @@ if (gradle.startParameter.taskNames.any { it.contains("release", ignoreCase = tr
android {
namespace = "ren.misaka.dshapp"
- compileSdk = flutter.compileSdkVersion
+ compileSdk { version = release(37) { minorApiLevel = 0 } }
ndkVersion = flutter.ndkVersion
compileOptions {
@@ -26,7 +26,7 @@ android {
applicationId = "ren.misaka.dshapp"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
- minSdk = flutter.minSdkVersion
+ minSdk = 26
targetSdk = flutter.targetSdkVersion
// Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION
// is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions)
@@ -73,3 +73,7 @@ kotlin {
flutter {
source = "../.."
}
+
+dependencies {
+ implementation("org.mozilla.geckoview:geckoview:158.0.20261007201254")
+}
diff --git a/android/app/src/main/kotlin/ren/misaka/dshapp/GeckoPlatformView.kt b/android/app/src/main/kotlin/ren/misaka/dshapp/GeckoPlatformView.kt
new file mode 100644
index 0000000..bad78d5
--- /dev/null
+++ b/android/app/src/main/kotlin/ren/misaka/dshapp/GeckoPlatformView.kt
@@ -0,0 +1,303 @@
+package ren.misaka.dshapp
+
+import android.app.Activity
+import android.app.AlertDialog
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.provider.DocumentsContract
+import android.view.View
+import android.widget.EditText
+import android.widget.Toast
+import io.flutter.plugin.common.BinaryMessenger
+import io.flutter.plugin.common.MethodChannel
+import io.flutter.plugin.common.StandardMessageCodec
+import io.flutter.plugin.platform.PlatformView
+import io.flutter.plugin.platform.PlatformViewFactory
+import org.mozilla.geckoview.*
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.*
+import java.io.InputStream
+import java.util.concurrent.Executors
+
+private object BrowserRuntime {
+ private var instance: GeckoRuntime? = null
+ fun get(context: Context): GeckoRuntime = instance ?: GeckoRuntime.create(context.applicationContext).also { instance = it }
+}
+
+internal class GeckoViewFactory(private val activity: MainActivity, private val messenger: BinaryMessenger) :
+ PlatformViewFactory(StandardMessageCodec.INSTANCE) {
+ override fun create(context: Context, viewId: Int, args: Any?): PlatformView {
+ val url = (args as? Map<*, *>)?.get("url") as? String
+ val uri = url?.let { Uri.parse(it) }
+ require(uri != null && uri.scheme == "https" && !uri.host.isNullOrBlank() && uri.userInfo == null) {
+ "GeckoView requires an HTTPS url without credentials"
+ }
+ return GeckoPlatformView(activity, context, messenger, viewId, url!!)
+ }
+}
+
+internal class GeckoPlatformView(
+ private val activity: MainActivity, context: Context, messenger: BinaryMessenger, id: Int,
+ private val initialUrl: String
+) : PlatformView {
+ private val view = GeckoView(context).apply { setViewBackend(GeckoView.BACKEND_TEXTURE_VIEW) }
+ private val session = GeckoSession(GeckoSessionSettings.Builder().usePrivateMode(false).build())
+ private val channel = MethodChannel(messenger, "dsh.workbench/gecko/" + id)
+ private val prompts = mutableMapOf Unit>()
+ private val streams = java.util.Collections.synchronizedSet(mutableSetOf())
+ private val io = Executors.newSingleThreadExecutor()
+ @Volatile private var disposed = false
+ private var loaded = false
+ private var opened = false
+ private var canGoBack = false
+ private var loadFailed = false
+
+ init {
+ session.progressDelegate = object : GeckoSession.ProgressDelegate {
+ override fun onPageStart(session: GeckoSession, url: String) { loadFailed = false; emit("onPageStarted") }
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ if (!loadFailed) emit("onPageFinished")
+ }
+ }
+ session.navigationDelegate = object : GeckoSession.NavigationDelegate {
+ override fun onCanGoBack(session: GeckoSession, value: Boolean) { canGoBack = value }
+ override fun onLoadError(session: GeckoSession, uri: String?, error: WebRequestError): GeckoResult? {
+ loadFailed = true
+ emit("onLoadError", "页面加载失败 (" + error.code + ")")
+ return null
+ }
+ }
+ session.contentDelegate = object : GeckoSession.ContentDelegate {
+ override fun onExternalResponse(session: GeckoSession, response: WebResponse) { saveResponse(response) }
+ override fun onCrash(session: GeckoSession) { processStopped() }
+ override fun onKill(session: GeckoSession) { processStopped() }
+ }
+ session.promptDelegate = object : GeckoSession.PromptDelegate {
+ override fun onAlertPrompt(session: GeckoSession, prompt: AlertPrompt) =
+ dialog(prompt, prompt.message, positive = { prompt.dismiss() })
+ override fun onButtonPrompt(session: GeckoSession, prompt: ButtonPrompt) =
+ dialog(prompt, prompt.message, positive = { prompt.confirm(ButtonPrompt.Type.POSITIVE) }, negative = { prompt.confirm(ButtonPrompt.Type.NEGATIVE) })
+ override fun onTextPrompt(session: GeckoSession, prompt: TextPrompt): GeckoResult {
+ val input = EditText(activity).apply { setText(prompt.defaultValue) }
+ return dialog(prompt, prompt.message, input, { prompt.confirm(input.text.toString()) }, { prompt.dismiss() })
+ }
+ override fun onFilePrompt(session: GeckoSession, prompt: FilePrompt) = chooseFiles(prompt)
+ override fun onChoicePrompt(session: GeckoSession, prompt: ChoicePrompt) = chooseOptions(prompt)
+ }
+ activity.browserViews.add(this)
+ channel.setMethodCallHandler { call, result ->
+ try {
+ when (call.method) {
+ "load" -> { loadInitial(); result.success(null) }
+ "goBack" -> { val back = canGoBack; if (back) session.goBack(); result.success(back) }
+ "reload" -> { if (loaded) session.reload() else loadInitial(); result.success(null) }
+ else -> result.notImplemented()
+ }
+ } catch (error: Exception) { result.error("GECKO_ERROR", error.message, null) }
+ }
+ }
+
+ private fun emit(method: String, value: String? = null) {
+ activity.runOnUiThread { if (!disposed) channel.invokeMethod(method, value) }
+ }
+
+ private fun loadInitial() {
+ if (!opened) {
+ session.open(BrowserRuntime.get(activity))
+ opened = true
+ view.setSession(session)
+ setActive(activity.browserActive)
+ }
+ if (!loaded) { session.loadUri(initialUrl); loaded = true }
+ }
+
+ private fun processStopped() {
+ opened = false
+ loaded = false
+ canGoBack = false
+ loadFailed = true
+ view.releaseSession()
+ activity.cancelDocuments(this)
+ prompts.values.toList().forEach { it() }
+ emit("onLoadError", "网页进程已停止,请重试")
+ }
+
+ private fun chooseOptions(prompt: ChoicePrompt): GeckoResult {
+ val result = GeckoResult()
+ val choices = mutableListOf()
+ fun flatten(items: Array) {
+ items.filter { !it.disabled && !it.separator }.forEach { choice ->
+ val children = choice.items
+ if (children != null) flatten(children) else choices.add(choice)
+ }
+ }
+ flatten(prompt.choices)
+ var dialog: AlertDialog? = null
+ fun finish(ids: Array?) {
+ if (!prompt.isComplete) result.complete(if (ids == null) prompt.dismiss() else prompt.confirm(ids))
+ prompt.setDelegate(null)
+ prompts.remove(prompt)
+ dialog?.dismiss()
+ }
+ prompts[prompt] = { finish(null) }
+ prompt.setDelegate(object : PromptInstanceDelegate {
+ override fun onPromptDismiss(prompt: BasePrompt) { finish(null) }
+ override fun onPromptUpdate(prompt: BasePrompt) { finish(null) }
+ })
+ val labels = choices.map { it.label }.toTypedArray()
+ val builder = AlertDialog.Builder(activity).setTitle(prompt.title)
+ .setNegativeButton(android.R.string.cancel) { _, _ -> finish(null) }
+ .setOnCancelListener { finish(null) }
+ if (prompt.type == ChoicePrompt.Type.MULTIPLE) {
+ val checked = choices.map { it.selected }.toBooleanArray()
+ builder.setMultiChoiceItems(labels, checked) { _, index, selected -> checked[index] = selected }
+ .setPositiveButton(android.R.string.ok) { _, _ ->
+ finish(choices.filterIndexed { index, _ -> checked[index] }.map { it.id }.toTypedArray())
+ }
+ } else {
+ builder.setSingleChoiceItems(labels, choices.indexOfFirst { it.selected }) { _, index ->
+ finish(arrayOf(choices[index].id))
+ }
+ }
+ dialog = builder.create()
+ dialog.show()
+ return result
+ }
+
+ private fun dialog(
+ prompt: BasePrompt, message: String?, input: EditText? = null,
+ positive: () -> PromptResponse, negative: (() -> PromptResponse)? = null
+ ): GeckoResult {
+ val result = GeckoResult()
+ var dialog: AlertDialog? = null
+ fun finish(response: () -> PromptResponse) {
+ if (!prompt.isComplete) result.complete(response())
+ prompts.remove(prompt)
+ prompt.setDelegate(null)
+ dialog?.dismiss()
+ }
+ val cancel = { finish { prompt.dismiss() } }
+ prompts[prompt] = cancel
+ prompt.setDelegate(object : PromptInstanceDelegate {
+ override fun onPromptDismiss(prompt: BasePrompt) { cancel() }
+ })
+ val builder = AlertDialog.Builder(activity).setTitle(prompt.title).setMessage(message)
+ .setPositiveButton(android.R.string.ok) { _, _ -> finish(positive) }
+ .setOnCancelListener { cancel() }
+ if (input != null) builder.setView(input)
+ if (negative != null) builder.setNegativeButton(android.R.string.cancel) { _, _ -> finish(negative) }
+ dialog = builder.create()
+ dialog.show()
+ return result
+ }
+
+ private fun chooseFiles(prompt: FilePrompt): GeckoResult {
+ val result = GeckoResult()
+ fun finish(uris: Array?) {
+ if (!prompt.isComplete) {
+ try {
+ result.complete(if (uris.isNullOrEmpty()) prompt.dismiss() else prompt.confirm(activity.applicationContext, uris))
+ } catch (error: Exception) {
+ if (!prompt.isComplete) result.complete(prompt.dismiss())
+ Toast.makeText(activity, "无法读取所选文件:" + error.message, Toast.LENGTH_LONG).show()
+ }
+ }
+ prompts.remove(prompt)
+ prompt.setDelegate(null)
+ }
+ prompts[prompt] = { finish(null) }
+ prompt.setDelegate(object : PromptInstanceDelegate {
+ override fun onPromptDismiss(prompt: BasePrompt) { finish(null) }
+ })
+ val folder = prompt.type == FilePrompt.Type.FOLDER
+ val intent = Intent(if (folder) Intent.ACTION_OPEN_DOCUMENT_TREE else Intent.ACTION_OPEN_DOCUMENT)
+ if (!folder) {
+ intent.addCategory(Intent.CATEGORY_OPENABLE)
+ val types = prompt.mimeTypes?.filter { it.contains("/") }?.toTypedArray()
+ intent.type = types?.singleOrNull() ?: "*/*"
+ if (!types.isNullOrEmpty()) intent.putExtra(Intent.EXTRA_MIME_TYPES, types)
+ intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, prompt.type == FilePrompt.Type.MULTIPLE)
+ }
+ intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ try {
+ activity.launchDocument(this, intent) { code, data ->
+ if (code != Activity.RESULT_OK || data == null) finish(null)
+ else {
+ val uris = mutableListOf()
+ data.clipData?.let { clip -> for (i in 0 until clip.itemCount) uris.add(clip.getItemAt(i).uri) }
+ if (uris.isEmpty()) data.data?.let { uris.add(it) }
+ finish((if (prompt.type == FilePrompt.Type.MULTIPLE) uris else uris.take(1)).toTypedArray())
+ }
+ }
+ } catch (error: Exception) { finish(null); Toast.makeText(activity, "无法打开文件选择器:" + error.message, Toast.LENGTH_LONG).show() }
+ return result
+ }
+
+ private fun saveResponse(response: WebResponse) {
+ if (!loadFailed) emit("onPageFinished")
+ val body = response.body
+ if (body == null) { emit("onDownloadError", "下载响应没有内容"); return }
+ if (disposed) { runCatching { body.close() }; return }
+ streams.add(body)
+ try {
+ val uri = Uri.parse(response.uri)
+ val disposition = response.headers.entries.firstOrNull { it.key.equals("content-disposition", true) }?.value
+ val encodedName = disposition?.let { Regex("(?i)filename\\*\\s*=\\s*UTF-8''([^;]+)").find(it)?.groupValues?.get(1) }
+ val headerName = encodedName?.let { Uri.decode(it) } ?: disposition?.let {
+ Regex("(?i)filename\\s*=\\s*(?:\"([^\"]*)\"|([^;]*))").find(it)?.let { match ->
+ match.groupValues[1].ifEmpty { match.groupValues[2].trim() }
+ }
+ }
+ val name = (headerName ?: (if (uri.isHierarchical) uri.getQueryParameter("path")?.substringAfterLast('/') else null) ?: uri.lastPathSegment ?: "download")
+ .map { if (it.isISOControl() || it in "\\/:*?\"<>|") '_' else it }.joinToString("")
+ .take(120).ifBlank { "download" }
+ val mime = response.headers.entries.firstOrNull { it.key.equals("content-type", true) }?.value?.substringBefore(';') ?: "application/octet-stream"
+ val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE)
+ .setType(mime).putExtra(Intent.EXTRA_TITLE, name)
+ activity.launchDocument(this, intent) { code, data ->
+ val destination = data?.data
+ if (code != Activity.RESULT_OK || destination == null || disposed) {
+ closeBody(body)
+ } else {
+ io.execute {
+ try {
+ check(!disposed) { "下载已取消" }
+ activity.contentResolver.openOutputStream(destination, "w").use { output ->
+ checkNotNull(output) { "无法打开目标文件" }
+ body.use { it.copyTo(output, 64 * 1024) }
+ }
+ activity.runOnUiThread { if (!disposed) Toast.makeText(activity, "下载已保存", Toast.LENGTH_SHORT).show() }
+ } catch (error: Exception) {
+ runCatching { DocumentsContract.deleteDocument(activity.contentResolver, destination) }
+ emit("onDownloadError", "保存下载失败:" + error.message)
+ } finally { closeBody(body) }
+ }
+ }
+ }
+ } catch (error: Exception) { closeBody(body); emit("onDownloadError", "无法保存下载:" + error.message) }
+ }
+
+ private fun closeBody(body: InputStream) { streams.remove(body); runCatching { body.close() } }
+
+ internal fun setActive(active: Boolean) { if (!disposed && opened) { session.setActive(active); session.setFocused(active) } }
+ override fun getView(): View = view
+ override fun dispose() {
+ if (disposed) return
+ disposed = true
+ channel.setMethodCallHandler(null)
+ activity.cancelDocuments(this)
+ prompts.values.toList().forEach { it() }
+ synchronized(streams) { streams.toList() }.forEach { closeBody(it) }
+ io.shutdown()
+ if (opened) {
+ session.setActive(false)
+ view.releaseSession()
+ session.close()
+ }
+ session.progressDelegate = null
+ session.navigationDelegate = null
+ session.contentDelegate = null
+ session.promptDelegate = null
+ activity.browserViews.remove(this)
+ }
+}
diff --git a/android/app/src/main/kotlin/ren/misaka/dshapp/MainActivity.kt b/android/app/src/main/kotlin/ren/misaka/dshapp/MainActivity.kt
index 2266bdd..5819e07 100644
--- a/android/app/src/main/kotlin/ren/misaka/dshapp/MainActivity.kt
+++ b/android/app/src/main/kotlin/ren/misaka/dshapp/MainActivity.kt
@@ -1,92 +1,62 @@
package ren.misaka.dshapp
-import android.app.DownloadManager
-import android.net.Uri
-import android.os.Build
-import android.os.Environment
-import android.webkit.CookieManager
-import android.widget.Toast
+import android.content.Intent
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
-import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() {
-
- companion object {
- private const val CHANNEL = "dsh.workbench/downloads"
- }
+ internal val browserViews = mutableSetOf()
+ private val requests = mutableMapOf Unit>>()
+ private var nextRequest = 2000
+ internal var browserActive = false
+ private set
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
- MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
- .setMethodCallHandler { call, result ->
- when (call.method) {
- "download" -> {
- val url = call.argument("url")
- if (url == null) {
- result.error("ARG_MISSING", "url is required", null)
- return@setMethodCallHandler
- }
- try {
- startDownload(url)
- result.success(true)
- } catch (e: Exception) {
- result.error("DOWNLOAD_FAILED", e.message, null)
- }
- }
- else -> result.notImplemented()
- }
- }
+ flutterEngine.platformViewsController.registry.registerViewFactory(
+ "dsh.workbench/gecko", GeckoViewFactory(this, flutterEngine.dartExecutor.binaryMessenger)
+ )
}
- /// 把下载交给系统 DownloadManager,并携带面板登录 Cookie
- /// (下载接口与站点同源、需要 JWT 会话,不能走无 Cookie 的外部浏览器)。
- private fun startDownload(url: String) {
- val request = DownloadManager.Request(Uri.parse(url))
- .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
- .setAllowedOverMetered(true)
- .setAllowedOverRoaming(true)
-
- CookieManager.getInstance().getCookie(url)?.takeIf { it.isNotEmpty() }?.let { cookie ->
- request.addRequestHeader("Cookie", cookie)
+ internal fun launchDocument(owner: GeckoPlatformView, intent: Intent, callback: (Int, Intent?) -> Unit) {
+ val code = nextRequest++
+ check(code <= 65535) { "Too many document requests" }
+ requests[code] = owner to callback
+ try {
+ startActivityForResult(intent, code)
+ } catch (error: Exception) {
+ requests.remove(code)
+ throw error
}
-
- val fileName = deriveFileName(url)
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- // DownloadManager 不接受 MediaStore 的 content:// URI。
- // targetSdk >= 29 时,系统下载器写公共 Downloads 不需要存储权限。
- request.setDestinationInExternalPublicDir(
- Environment.DIRECTORY_DOWNLOADS, fileName
- )
- } else {
- // Android 9-:公共目录写入需运行时权限;退到应用外部目录(免权限)
- @Suppress("DEPRECATION")
- request.setDestinationInExternalFilesDir(
- this, Environment.DIRECTORY_DOWNLOADS, fileName
- )
- }
-
- val manager = getSystemService(DownloadManager::class.java)
- manager.enqueue(request)
- val destination = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- "系统下载目录"
- } else {
- "应用下载目录"
- }
- Toast.makeText(this, "已开始下载到$destination:$fileName", Toast.LENGTH_SHORT).show()
}
- /// 从下载 URL 推断文件名,优先取 path 参数的 basename(即真实文件名)。
- private fun deriveFileName(url: String): String {
- val uri = Uri.parse(url)
- uri.getQueryParameter("path")?.substringAfterLast('/')?.takeIf { it.isNotBlank() }
- ?.let { return sanitize(it) }
- uri.lastPathSegment?.takeIf { it.isNotBlank() && it != "download" }
- ?.let { return sanitize(it) }
- return "dsh-download-${System.currentTimeMillis()}"
+ internal fun cancelDocuments(owner: GeckoPlatformView) {
+ requests.entries.filter { it.value.first === owner }.map { it.key }.forEach { code ->
+ requests.remove(code)?.second?.invoke(RESULT_CANCELED, null)
+ }
}
- private fun sanitize(name: String): String =
- name.replace(Regex("[\\\\/:*?\"<>|]"), "_").take(120).ifBlank { "file" }
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+ val request = requests.remove(requestCode)
+ if (request != null) request.second(resultCode, data)
+ else super.onActivityResult(requestCode, resultCode, data)
+ }
+
+ override fun onResume() {
+ super.onResume()
+ browserActive = true
+ browserViews.forEach { it.setActive(true) }
+ }
+
+ override fun onPause() {
+ browserActive = false
+ browserViews.forEach { it.setActive(false) }
+ super.onPause()
+ }
+
+ override fun onDestroy() {
+ browserViews.toList().forEach { it.dispose() }
+ requests.clear()
+ super.onDestroy()
+ }
}
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
index dbee657..6ff2cc5 100644
--- a/android/build.gradle.kts
+++ b/android/build.gradle.kts
@@ -2,6 +2,7 @@ allprojects {
repositories {
google()
mavenCentral()
+ maven { url = uri("https://maven.mozilla.org/maven2/") }
}
}
diff --git a/lib/main.dart b/lib/main.dart
index 34c47cb..d30e40b 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -1,10 +1,7 @@
import 'dart:async';
-import 'package:file_selector/file_selector.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
-import 'package:webview_flutter/webview_flutter.dart';
-import 'package:webview_flutter_android/webview_flutter_android.dart';
import 'config.dart';
@@ -53,176 +50,53 @@ class HomePage extends StatefulWidget {
}
class _HomePageState extends State {
- /// 原生下载通道(MainActivity 中用系统 DownloadManager 实现)
- static const MethodChannel _downloadsChannel = MethodChannel(
- 'dsh.workbench/downloads',
- );
-
- static final Uri _siteUri = Uri.parse(kSiteUrl);
-
- late final WebViewController _controller;
+ MethodChannel? _channel;
bool _loading = true;
String? _loadError;
- @override
- void initState() {
- super.initState();
- _controller = _buildController();
- }
-
- WebViewController _buildController() {
- final controller = WebViewController()
- ..setJavaScriptMode(JavaScriptMode.unrestricted)
- ..setBackgroundColor(const Color(kThemeColorValue))
- ..setNavigationDelegate(
- NavigationDelegate(
- onPageStarted: (_) {
- if (mounted) {
- setState(() {
- _loading = true;
- _loadError = null;
- });
- }
- },
- onPageFinished: (_) {
- if (mounted) setState(() => _loading = false);
- },
- onWebResourceError: (error) {
- // 只把主文档加载失败视作页面错误;子资源错误(图片等)
- // 以及被新导航打断(ERR_ABORTED)的情况交给页面自行处理
- if (!(error.isForMainFrame ?? false)) return;
- if (error.description.contains('ERR_ABORTED')) return;
- if (mounted) {
- setState(() {
- _loading = false;
- _loadError = error.description;
- });
- }
- },
- onNavigationRequest: _onNavigationRequest,
- ),
- )
- ..loadRequest(Uri.parse(kSiteUrl));
-
- // Android 专属能力:文件选择器(上传)、原生 JS 对话框(alert/confirm)
- final platform = controller.platform;
- if (platform is AndroidWebViewController) {
- platform
- ..setOnShowFileSelector(_onShowFileSelector)
- ..setOnJavaScriptAlertDialog(_onJavaScriptAlert)
- ..setOnJavaScriptConfirmDialog(_onJavaScriptConfirm);
- }
- return controller;
- }
-
- /// 面板用 window.location.assign 触发下载(文件/目录 ZIP/备份包),
- /// 这里拦下来交给原生 DownloadManager(携带登录 Cookie),WebView 停留原页。
- NavigationDecision _onNavigationRequest(NavigationRequest request) {
- final uri = Uri.tryParse(request.url);
- if (uri != null && _isPanelDownload(uri)) {
- _startDownload(uri);
- return NavigationDecision.prevent;
- }
- return NavigationDecision.navigate;
- }
-
- bool _isPanelDownload(Uri uri) {
- if (uri.host != _siteUri.host) return false;
- // 工作区文件/目录 ZIP 下载
- if (uri.path.endsWith('/api/ws/download')) return true;
- // 配置备份包下载
- if (uri.path.endsWith('/backups') &&
- uri.queryParameters.containsKey('download')) {
- return true;
- }
- return false;
- }
-
- Future _startDownload(Uri uri) async {
- try {
- await _downloadsChannel.invokeMethod('download', {
- 'url': uri.toString(),
- });
- } on PlatformException catch (e) {
- if (mounted) {
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text('下载失败:${e.message ?? '未知错误'}')),
- );
+ Future _onViewCreated(int id) async {
+ final channel = MethodChannel('dsh.workbench/gecko/$id');
+ _channel = channel;
+ channel.setMethodCallHandler((call) async {
+ if (!mounted) return;
+ switch (call.method) {
+ case 'onPageStarted':
+ setState(() {
+ _loading = true;
+ _loadError = null;
+ });
+ case 'onPageFinished':
+ setState(() => _loading = false);
+ case 'onLoadError':
+ setState(() {
+ _loading = false;
+ _loadError = call.arguments as String? ?? '页面加载失败';
+ });
+ case 'onDownloadError':
+ ScaffoldMessenger.of(context)
+ .showSnackBar(SnackBar(content: Text('下载失败:${call.arguments}')));
}
- }
+ });
+ await _invoke('load');
}
- /// WebView 里的 上传(工作区上传/备份导入)。
- /// Android 的类型过滤一次只支持单一 MIME 或单一扩展名,
- /// 多约束时退化为不限类型,避免选择器异常。
- Future> _onShowFileSelector(FileSelectorParams params) async {
- final typeGroups = [];
- final mimes = params.acceptTypes.where((t) => t.contains('/')).toList();
- if (mimes.length == 1) {
- typeGroups.add(XTypeGroup(mimeTypes: mimes));
- } else {
- final exts = params.acceptTypes
- .where((t) => t.startsWith('.'))
- .map((t) => t.toLowerCase())
- .toList();
- if (exts.length == 1) typeGroups.add(XTypeGroup(extensions: exts));
+ Future _invoke(String method) async {
+ try {
+ return await _channel?.invokeMethod(method);
+ } on PlatformException catch (error) {
+ if (mounted) {
+ setState(() {
+ _loading = false;
+ _loadError = error.message ?? '浏览器启动失败';
+ });
+ }
+ return null;
}
-
- if (params.mode == FileSelectorMode.openMultiple) {
- final files = await openFiles(acceptedTypeGroups: typeGroups);
- return files.map((f) => f.path).toList();
- }
- final file = await openFile(acceptedTypeGroups: typeGroups);
- return file == null ? const [] : [file.path];
- }
-
- /// 面板少量路径仍使用 window.alert / window.confirm,
- /// 未注册回调时 WebView 会静默返回 false,必须在这里补上。
- Future _onJavaScriptAlert(JavaScriptAlertDialogRequest request) async {
- if (!mounted) return;
- await showDialog(
- context: context,
- builder: (context) => AlertDialog(
- content: Text(request.message),
- actions: [
- TextButton(
- onPressed: () => Navigator.of(context).pop(),
- child: const Text('确定'),
- ),
- ],
- ),
- );
- }
-
- Future _onJavaScriptConfirm(
- JavaScriptConfirmDialogRequest request,
- ) async {
- if (!mounted) return false;
- final confirmed = await showDialog(
- context: context,
- builder: (context) => AlertDialog(
- content: Text(request.message),
- actions: [
- TextButton(
- onPressed: () => Navigator.of(context).pop(false),
- child: const Text('取消'),
- ),
- FilledButton(
- onPressed: () => Navigator.of(context).pop(true),
- child: const Text('确定'),
- ),
- ],
- ),
- );
- return confirmed ?? false;
}
Future _onPopInvoked(bool didPop, Object? result) async {
if (didPop) return;
- // 先回退 WebView 历史(SPA 内部导航),没有历史再退出应用
- if (await _controller.canGoBack()) {
- await _controller.goBack();
- } else {
+ if (await _invoke('goBack') == false && mounted) {
await SystemNavigator.pop();
}
}
@@ -232,7 +106,13 @@ class _HomePageState extends State {
_loadError = null;
_loading = true;
});
- unawaited(_controller.reload());
+ unawaited(_invoke('reload'));
+ }
+
+ @override
+ void dispose() {
+ _channel?.setMethodCallHandler(null);
+ super.dispose();
}
@override
@@ -245,7 +125,12 @@ class _HomePageState extends State {
body: SafeArea(
child: Stack(
children: [
- WebViewWidget(controller: _controller),
+ AndroidView(
+ viewType: 'dsh.workbench/gecko',
+ creationParams: const {'url': kSiteUrl},
+ creationParamsCodec: const StandardMessageCodec(),
+ onPlatformViewCreated: _onViewCreated,
+ ),
if (_loadError != null)
_ErrorView(message: _loadError!, onRetry: _retry)
else if (_loading)
diff --git a/pubspec.lock b/pubspec.lock
index e21ac76..f84f27b 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -41,14 +41,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
- cross_file:
- dependency: transitive
- description:
- name: cross_file
- sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6
- url: "https://pub.dev"
- source: hosted
- version: "0.3.5+5"
fake_async:
dependency: transitive
description:
@@ -57,70 +49,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.3"
- file_selector:
- dependency: "direct main"
- description:
- name: file_selector
- sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a
- url: "https://pub.dev"
- source: hosted
- version: "1.1.0"
- file_selector_android:
- dependency: transitive
- description:
- name: file_selector_android
- sha256: d670cd0ce77a2e785b18d8b4d0a8d6a222d6a813ec9b7ddf2790a1b4fb6fa92c
- url: "https://pub.dev"
- source: hosted
- version: "0.5.2+11"
- file_selector_ios:
- dependency: transitive
- description:
- name: file_selector_ios
- sha256: "97269e5307a0ab813b1fa2430bada0a96e0afb74848417f8676f64ba5de0051c"
- url: "https://pub.dev"
- source: hosted
- version: "0.5.3+6"
- file_selector_linux:
- dependency: transitive
- description:
- name: file_selector_linux
- sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab
- url: "https://pub.dev"
- source: hosted
- version: "0.9.4+1"
- file_selector_macos:
- dependency: transitive
- description:
- name: file_selector_macos
- sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4
- url: "https://pub.dev"
- source: hosted
- version: "0.9.5+1"
- file_selector_platform_interface:
- dependency: transitive
- description:
- name: file_selector_platform_interface
- sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
- url: "https://pub.dev"
- source: hosted
- version: "2.7.0"
- file_selector_web:
- dependency: transitive
- description:
- name: file_selector_web
- sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3"
- url: "https://pub.dev"
- source: hosted
- version: "0.9.5"
- file_selector_windows:
- dependency: transitive
- description:
- name: file_selector_windows
- sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec
- url: "https://pub.dev"
- source: hosted
- version: "0.9.3+6"
flutter:
dependency: "direct main"
description: flutter
@@ -139,27 +67,6 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
- flutter_web_plugins:
- dependency: transitive
- description: flutter
- source: sdk
- version: "0.0.0"
- http:
- dependency: transitive
- description:
- name: http
- sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
- url: "https://pub.dev"
- source: hosted
- version: "1.6.0"
- http_parser:
- dependency: transitive
- description:
- name: http_parser
- sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
- url: "https://pub.dev"
- source: hosted
- version: "4.1.2"
leak_tracker:
dependency: transitive
description:
@@ -224,14 +131,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
- plugin_platform_interface:
- dependency: transitive
- description:
- name: plugin_platform_interface
- sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
- url: "https://pub.dev"
- source: hosted
- version: "2.1.8"
sky_engine:
dependency: transitive
description: flutter
@@ -285,14 +184,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.12"
- typed_data:
- dependency: transitive
- description:
- name: typed_data
- sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
- url: "https://pub.dev"
- source: hosted
- version: "1.4.0"
vector_math:
dependency: transitive
description:
@@ -309,46 +200,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "15.3.0"
- web:
- dependency: transitive
- description:
- name: web
- sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
- url: "https://pub.dev"
- source: hosted
- version: "1.1.1"
- webview_flutter:
- dependency: "direct main"
- description:
- name: webview_flutter
- sha256: d53e1ccf5516f25017e3c9d44c39034db352d20fa34fe200674270242c2c5111
- url: "https://pub.dev"
- source: hosted
- version: "4.14.1"
- webview_flutter_android:
- dependency: "direct main"
- description:
- name: webview_flutter_android
- sha256: "4de8b3d1ff4ebe1bdb42e68a5e4f809194a3cb0117a8f495f590004f00da3964"
- url: "https://pub.dev"
- source: hosted
- version: "4.14.1"
- webview_flutter_platform_interface:
- dependency: transitive
- description:
- name: webview_flutter_platform_interface
- sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04"
- url: "https://pub.dev"
- source: hosted
- version: "2.15.1"
- webview_flutter_wkwebview:
- dependency: transitive
- description:
- name: webview_flutter_wkwebview
- sha256: a9cc3f507ee40127ed0812ca54d00701a27833f51ea0b5820f4b3e7f42518bf3
- url: "https://pub.dev"
- source: hosted
- version: "3.27.0"
sdks:
dart: ">=3.13.5 <4.0.0"
- flutter: ">=3.47.0"
+ flutter: ">=3.18.0-18.0.pre.54"
diff --git a/pubspec.yaml b/pubspec.yaml
index ee4934b..af108f2 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
name: dsh_workbench
-description: "DSH 工作台 Android 客户端:系统 WebView 壳。"
+description: "DSH 工作台 Android 客户端:内置 GeckoView 内核。"
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
-version: 1.0.1+2
+version: 1.0.2+3
environment:
sdk: ^3.13.5
@@ -31,10 +31,7 @@ dependencies:
flutter:
sdk: flutter
- # DSH 面板 WebView 壳所需
- webview_flutter: ^4.14.1
- webview_flutter_android: ^4.14.1
- file_selector: ^1.1.0
+ # GeckoView 由 Android 原生平台视图提供,版本固定于 android/app/build.gradle.kts。
dev_dependencies:
flutter_test:
diff --git a/test/gecko_bridge_test.dart b/test/gecko_bridge_test.dart
new file mode 100644
index 0000000..da5aaed
--- /dev/null
+++ b/test/gecko_bridge_test.dart
@@ -0,0 +1,97 @@
+import 'package:dsh_workbench/main.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ TestWidgetsFlutterBinding.ensureInitialized();
+ const channel = MethodChannel('dsh.workbench/gecko/42');
+ final calls = [];
+ var canGoBack = true;
+ final systemCalls = [];
+
+ setUp(() {
+ calls.clear();
+ systemCalls.clear();
+ canGoBack = true;
+ TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
+ .setMockMethodCallHandler(channel, (call) async {
+ calls.add(call.method);
+ return call.method == 'goBack' ? canGoBack : null;
+ });
+ TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
+ .setMockMethodCallHandler(SystemChannels.platform, (call) async {
+ systemCalls.add(call.method);
+ return null;
+ });
+ });
+
+ tearDown(() {
+ TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
+ .setMockMethodCallHandler(channel, null);
+ TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
+ .setMockMethodCallHandler(SystemChannels.platform, null);
+ });
+
+ Future emit(String method, [Object? value]) async {
+ await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
+ .handlePlatformMessage(
+ channel.name,
+ const StandardMethodCodec().encodeMethodCall(
+ MethodCall(method, value),
+ ),
+ (_) {},
+ );
+ }
+
+ testWidgets('返回键优先回退 Gecko 历史,之后退出应用', (tester) async {
+ await tester.pumpWidget(const DshApp());
+ tester.widget(find.byType(AndroidView)).onPlatformViewCreated!(
+ 42,
+ );
+ await tester.pump();
+ final pop = tester.widget(
+ find.byWidgetPredicate((w) => w is PopScope),
+ );
+ pop.onPopInvokedWithResult!(false, null);
+ await tester.pump();
+ expect(calls.last, 'goBack');
+ expect(systemCalls, isNot(contains('SystemNavigator.pop')));
+ canGoBack = false;
+ pop.onPopInvokedWithResult!(false, null);
+ await tester.pump();
+ expect(systemCalls, contains('SystemNavigator.pop'));
+ await tester.pumpWidget(const SizedBox());
+ });
+
+ testWidgets('Gecko 页面事件、错误及重试通过独立通道连接', (tester) async {
+ await tester.pumpWidget(const DshApp());
+ final view = tester.widget(find.byType(AndroidView));
+ expect(view.viewType, 'dsh.workbench/gecko');
+ view.onPlatformViewCreated!(42);
+ await tester.pump();
+ expect(calls, ['load']);
+ expect(find.text('正在加载…'), findsOneWidget);
+
+ await emit('onPageFinished');
+ await tester.pump();
+ expect(find.text('正在加载…'), findsNothing);
+
+ await emit('onLoadError', '连接已断开');
+ await tester.pump();
+ expect(find.text('连接已断开'), findsOneWidget);
+ await tester.tap(find.text('重试'));
+ await tester.pump();
+ expect(calls.last, 'reload');
+ expect(find.text('正在加载…'), findsOneWidget);
+
+ await emit('onPageStarted');
+ await emit('onPageFinished');
+ await emit('onDownloadError', '空间不足');
+ await tester.pump();
+ expect(find.text('下载失败:空间不足'), findsOneWidget);
+ await tester.pumpWidget(const SizedBox());
+ await emit('onPageStarted');
+ expect(tester.takeException(), isNull);
+ });
+}