feat(android): bundle GeckoView and bump version to 1.0.2

This commit is contained in:
2026-10-18 19:19:58 +08:00
parent 867989e09b
commit 7f493e0f89
10 changed files with 532 additions and 408 deletions
+3 -4
View File
@@ -41,18 +41,17 @@ RUN curl -fsSL -o /tmp/tools.zip \
&& mv "${ANDROID_HOME}/cmdline-tools/cmdline-tools" "${ANDROID_HOME}/cmdline-tools/latest" \ && mv "${ANDROID_HOME}/cmdline-tools/cmdline-tools" "${ANDROID_HOME}/cmdline-tools/latest" \
&& rm /tmp/tools.zip \ && rm /tmp/tools.zip \
&& (yes | sdkmanager --licenses > /dev/null || true) \ && (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}" \ RUN flutter config --android-sdk "${ANDROID_HOME}" \
&& flutter --disable-analytics \ && flutter --disable-analytics \
&& flutter precache --android \ && flutter precache --android \
&& flutter doctor && flutter doctor
# 预热:跑一次与应用相同依赖的默认模板构建,把 Gradle 发行版/AGP/AndroidX # 预热默认模板的 Gradle/AGP/AndroidX 和 SDK;GeckoView 的固定版本
# 组件、插件 AAR 和 SDK 平台下载进镜像,之后挂载源码的正式构建不再重复下载 # 在应用构建时从 Mozilla Maven 下载,不在这里预热旧 WebView 插件。
RUN flutter create --org dev.dsh --project-name warmup /tmp/warmup \ RUN flutter create --org dev.dsh --project-name warmup /tmp/warmup \
&& cd /tmp/warmup \ && cd /tmp/warmup \
&& flutter pub add webview_flutter webview_flutter_android file_selector \
&& flutter build apk --release \ && flutter build apk --release \
&& cd / && rm -rf /tmp/warmup \ && cd / && rm -rf /tmp/warmup \
&& flutter doctor && flutter doctor
+24 -7
View File
@@ -1,16 +1,16 @@
# DSH 工作台(Android) # 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`),不打包浏览器内核,体积小 - 内置 GeckoView:Flutter 平台视图连接原生 GeckoSession,各设备使用同一发布版本的内核
- 登录状态持久化:系统 WebView 的 Cookie + DOM 存储跨重启保留 - 登录状态持久化:使用非隐私 Gecko profile,持久 Cookie + DOM 存储跨重启保留(会话有效期仍由站点控制)
- 全面屏适配:edge-to-edge、刘海/挖孔 `shortEdges`、深色启动底色无白闪 - 全面屏适配:edge-to-edge、刘海/挖孔 `shortEdges`、深色启动底色无白闪
- 文件上传:`<input type="file">` 走系统文件选择器(工作区上传、备份导入) - 文件上传:`<input type="file">` 走系统文件选择器(工作区上传、备份导入)
- 文件下载:拦截面板下载导航,交给系统 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 无法被新签名直接覆盖安装。 - **签名**: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 的传统图标;各密度资源均已生成。 - **图标**:使用仓库根目录的 `logo.webp`。API 26+ 使用浅冰蓝底色的自适应图标,旧系统使用同一 logo 的传统图标;各密度资源均已生成。
## 内核升级回归
发布前除 `flutter analyze`、`flutter test` 和 APK 构建外,应在真实 Android 设备上检查:
- 登录后重启应用,确认站点的持久登录状态及本地存储保留。
- 工作区单文件/多文件上传、选择器取消、配置备份导入。
- 需要登录的文件、目录 ZIP、配置备份下载,检查保存内容及取消流程。
- JS alert/confirm/prompt、输入法、页面滚动、SPA 返回键、横竖屏及前后台切换。
- 断网重试与无效证书拒绝;确认没有绕过 TLS 校验。
Flutter 桥接单测不运行 Gecko 内核,不能替代上述真机测试。
+6 -2
View File
@@ -13,7 +13,7 @@ if (gradle.startParameter.taskNames.any { it.contains("release", ignoreCase = tr
android { android {
namespace = "ren.misaka.dshapp" namespace = "ren.misaka.dshapp"
compileSdk = flutter.compileSdkVersion compileSdk { version = release(37) { minorApiLevel = 0 } }
ndkVersion = flutter.ndkVersion ndkVersion = flutter.ndkVersion
compileOptions { compileOptions {
@@ -26,7 +26,7 @@ android {
applicationId = "ren.misaka.dshapp" applicationId = "ren.misaka.dshapp"
// You can update the following values to match your application needs. // You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config. // For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion minSdk = 26
targetSdk = flutter.targetSdkVersion targetSdk = flutter.targetSdkVersion
// Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION // 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) // is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions)
@@ -73,3 +73,7 @@ kotlin {
flutter { flutter {
source = "../.." source = "../.."
} }
dependencies {
implementation("org.mozilla.geckoview:geckoview:158.0.20261007201254")
}
@@ -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<BasePrompt, () -> Unit>()
private val streams = java.util.Collections.synchronizedSet(mutableSetOf<InputStream>())
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<String>? {
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<PromptResponse> {
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<PromptResponse> {
val result = GeckoResult<PromptResponse>()
val choices = mutableListOf<ChoicePrompt.Choice>()
fun flatten(items: Array<ChoicePrompt.Choice>) {
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<String>?) {
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<PromptResponse> {
val result = GeckoResult<PromptResponse>()
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<PromptResponse> {
val result = GeckoResult<PromptResponse>()
fun finish(uris: Array<Uri>?) {
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<Uri>()
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)
}
}
@@ -1,92 +1,62 @@
package ren.misaka.dshapp package ren.misaka.dshapp
import android.app.DownloadManager import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.webkit.CookieManager
import android.widget.Toast
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() { class MainActivity : FlutterActivity() {
internal val browserViews = mutableSetOf<GeckoPlatformView>()
companion object { private val requests = mutableMapOf<Int, Pair<GeckoPlatformView, (Int, Intent?) -> Unit>>()
private const val CHANNEL = "dsh.workbench/downloads" private var nextRequest = 2000
} internal var browserActive = false
private set
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL) flutterEngine.platformViewsController.registry.registerViewFactory(
.setMethodCallHandler { call, result -> "dsh.workbench/gecko", GeckoViewFactory(this, flutterEngine.dartExecutor.binaryMessenger)
when (call.method) { )
"download" -> {
val url = call.argument<String>("url")
if (url == null) {
result.error("ARG_MISSING", "url is required", null)
return@setMethodCallHandler
} }
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 { try {
startDownload(url) startActivityForResult(intent, code)
result.success(true) } catch (error: Exception) {
} catch (e: Exception) { requests.remove(code)
result.error("DOWNLOAD_FAILED", e.message, null) throw error
}
}
else -> result.notImplemented()
}
} }
} }
/// 把下载交给系统 DownloadManager,并携带面板登录 Cookie internal fun cancelDocuments(owner: GeckoPlatformView) {
/// (下载接口与站点同源、需要 JWT 会话,不能走无 Cookie 的外部浏览器)。 requests.entries.filter { it.value.first === owner }.map { it.key }.forEach { code ->
private fun startDownload(url: String) { requests.remove(code)?.second?.invoke(RESULT_CANCELED, null)
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)
} }
val fileName = deriveFileName(url) override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
val request = requests.remove(requestCode)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (request != null) request.second(resultCode, data)
// DownloadManager 不接受 MediaStore 的 content:// URI。 else super.onActivityResult(requestCode, resultCode, data)
// 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) override fun onResume() {
manager.enqueue(request) super.onResume()
val destination = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { browserActive = true
"系统下载目录" browserViews.forEach { it.setActive(true) }
} else {
"应用下载目录"
}
Toast.makeText(this, "已开始下载到$destination:$fileName", Toast.LENGTH_SHORT).show()
} }
/// 从下载 URL 推断文件名,优先取 path 参数的 basename(即真实文件名)。 override fun onPause() {
private fun deriveFileName(url: String): String { browserActive = false
val uri = Uri.parse(url) browserViews.forEach { it.setActive(false) }
uri.getQueryParameter("path")?.substringAfterLast('/')?.takeIf { it.isNotBlank() } super.onPause()
?.let { return sanitize(it) }
uri.lastPathSegment?.takeIf { it.isNotBlank() && it != "download" }
?.let { return sanitize(it) }
return "dsh-download-${System.currentTimeMillis()}"
} }
private fun sanitize(name: String): String = override fun onDestroy() {
name.replace(Regex("[\\\\/:*?\"<>|]"), "_").take(120).ifBlank { "file" } browserViews.toList().forEach { it.dispose() }
requests.clear()
super.onDestroy()
}
} }
+1
View File
@@ -2,6 +2,7 @@ allprojects {
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
maven { url = uri("https://maven.mozilla.org/maven2/") }
} }
} }
+42 -157
View File
@@ -1,10 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:file_selector/file_selector.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.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'; import 'config.dart';
@@ -53,176 +50,53 @@ class HomePage extends StatefulWidget {
} }
class _HomePageState extends State<HomePage> { class _HomePageState extends State<HomePage> {
/// 原生下载通道(MainActivity 中用系统 DownloadManager 实现) MethodChannel? _channel;
static const MethodChannel _downloadsChannel = MethodChannel(
'dsh.workbench/downloads',
);
static final Uri _siteUri = Uri.parse(kSiteUrl);
late final WebViewController _controller;
bool _loading = true; bool _loading = true;
String? _loadError; String? _loadError;
@override Future<void> _onViewCreated(int id) async {
void initState() { final channel = MethodChannel('dsh.workbench/gecko/$id');
super.initState(); _channel = channel;
_controller = _buildController(); channel.setMethodCallHandler((call) async {
} if (!mounted) return;
switch (call.method) {
WebViewController _buildController() { case 'onPageStarted':
final controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(const Color(kThemeColorValue))
..setNavigationDelegate(
NavigationDelegate(
onPageStarted: (_) {
if (mounted) {
setState(() { setState(() {
_loading = true; _loading = true;
_loadError = null; _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}')));
} }
}, });
onPageFinished: (_) { await _invoke('load');
if (mounted) setState(() => _loading = false); }
},
onWebResourceError: (error) { Future<T?> _invoke<T>(String method) async {
// 只把主文档加载失败视作页面错误;子资源错误(图片等) try {
// 以及被新导航打断(ERR_ABORTED)的情况交给页面自行处理 return await _channel?.invokeMethod<T>(method);
if (!(error.isForMainFrame ?? false)) return; } on PlatformException catch (error) {
if (error.description.contains('ERR_ABORTED')) return;
if (mounted) { if (mounted) {
setState(() { setState(() {
_loading = false; _loading = false;
_loadError = error.description; _loadError = error.message ?? '浏览器启动失败';
}); });
} }
}, return null;
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<void> _startDownload(Uri uri) async {
try {
await _downloadsChannel.invokeMethod<bool>('download', {
'url': uri.toString(),
});
} on PlatformException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('下载失败:${e.message ?? '未知错误'}')),
);
}
}
}
/// WebView 里的 <input type="file"> 上传(工作区上传/备份导入)。
/// Android 的类型过滤一次只支持单一 MIME 或单一扩展名,
/// 多约束时退化为不限类型,避免选择器异常。
Future<List<String>> _onShowFileSelector(FileSelectorParams params) async {
final typeGroups = <XTypeGroup>[];
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));
}
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 <String>[] : [file.path];
}
/// 面板少量路径仍使用 window.alert / window.confirm,
/// 未注册回调时 WebView 会静默返回 false,必须在这里补上。
Future<void> _onJavaScriptAlert(JavaScriptAlertDialogRequest request) async {
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (context) => AlertDialog(
content: Text(request.message),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('确定'),
),
],
),
);
}
Future<bool> _onJavaScriptConfirm(
JavaScriptConfirmDialogRequest request,
) async {
if (!mounted) return false;
final confirmed = await showDialog<bool>(
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<void> _onPopInvoked(bool didPop, Object? result) async { Future<void> _onPopInvoked(bool didPop, Object? result) async {
if (didPop) return; if (didPop) return;
// 先回退 WebView 历史(SPA 内部导航),没有历史再退出应用 if (await _invoke<bool>('goBack') == false && mounted) {
if (await _controller.canGoBack()) {
await _controller.goBack();
} else {
await SystemNavigator.pop(); await SystemNavigator.pop();
} }
} }
@@ -232,7 +106,13 @@ class _HomePageState extends State<HomePage> {
_loadError = null; _loadError = null;
_loading = true; _loading = true;
}); });
unawaited(_controller.reload()); unawaited(_invoke<void>('reload'));
}
@override
void dispose() {
_channel?.setMethodCallHandler(null);
super.dispose();
} }
@override @override
@@ -245,7 +125,12 @@ class _HomePageState extends State<HomePage> {
body: SafeArea( body: SafeArea(
child: Stack( child: Stack(
children: [ children: [
WebViewWidget(controller: _controller), AndroidView(
viewType: 'dsh.workbench/gecko',
creationParams: const {'url': kSiteUrl},
creationParamsCodec: const StandardMessageCodec(),
onPlatformViewCreated: _onViewCreated,
),
if (_loadError != null) if (_loadError != null)
_ErrorView(message: _loadError!, onRetry: _retry) _ErrorView(message: _loadError!, onRetry: _retry)
else if (_loading) else if (_loading)
+1 -150
View File
@@ -41,14 +41,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.1" 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: fake_async:
dependency: transitive dependency: transitive
description: description:
@@ -57,70 +49,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.3" 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: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@@ -139,27 +67,6 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" 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: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@@ -224,14 +131,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.1" 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: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@@ -285,14 +184,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.12" 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: vector_math:
dependency: transitive dependency: transitive
description: description:
@@ -309,46 +200,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "15.3.0" 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: sdks:
dart: ">=3.13.5 <4.0.0" dart: ">=3.13.5 <4.0.0"
flutter: ">=3.47.0" flutter: ">=3.18.0-18.0.pre.54"
+3 -6
View File
@@ -1,5 +1,5 @@
name: dsh_workbench name: dsh_workbench
description: "DSH 工作台 Android 客户端:系统 WebView 壳。" description: "DSH 工作台 Android 客户端:内置 GeckoView 内核。"
# The following line prevents the package from being accidentally published to # The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages. # 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 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 # 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 # 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. # 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: environment:
sdk: ^3.13.5 sdk: ^3.13.5
@@ -31,10 +31,7 @@ dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
# DSH 面板 WebView 壳所需 # GeckoView 由 Android 原生平台视图提供,版本固定于 android/app/build.gradle.kts。
webview_flutter: ^4.14.1
webview_flutter_android: ^4.14.1
file_selector: ^1.1.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
+97
View File
@@ -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 = <String>[];
var canGoBack = true;
final systemCalls = <String>[];
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<void> 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<AndroidView>(find.byType(AndroidView)).onPlatformViewCreated!(
42,
);
await tester.pump();
final pop = tester.widget<PopScope>(
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<AndroidView>(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);
});
}