diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..bd0222c
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,17 @@
+.git
+.gitignore
+web/
+.mimocode/
+build/
+dist/
+*.apk
+*.aab
+signing/
+**/*.jks
+**/*.keystore
+**/*.p12
+**/*.pfx
+**/keystore.properties
+**/signing.properties
+.env
+.env.*
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c5b76a1
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,95 @@
+# Miscellaneous
+*.class
+*.log
+*.pyc
+*.swp
+.DS_Store
+.atom/
+.build/
+.buildlog/
+.history/
+.svn/
+.swiftpm/
+migrate_working_dir/
+
+# IntelliJ related
+*.iml
+*.ipr
+*.iws
+.idea/
+
+# VS Code local state
+.vscode/
+
+# Flutter/Dart/Pub related
+**/doc/api/
+**/ios/Flutter/.last_build_id
+.dart_tool/
+.flutter-plugins-dependencies
+.pub-cache/
+.pub/
+/build/
+/coverage/
+
+# Symbolication related
+app.*.symbols
+
+# Obfuscation related
+app.*.map.json
+
+# Android Studio will place build artifacts here
+/android/app/debug
+/android/app/profile
+/android/app/release
+
+# Widget Preview related
+.widget_preview/
+
+# ---------------------------------------------------------------------------
+# 以下为 DSH 工作台项目补充规则
+
+# Gradle caches and build outputs (all modules)
+.gradle/
+.kotlin/
+build/
+out/
+
+# Android SDK paths and native build intermediates
+local.properties
+.cxx/
+.externalNativeBuild/
+captures/
+
+# Generated Android packages
+*.apk
+*.aab
+*.ap_
+*.dex
+
+# Signing keys and local credentials
+*.jks
+*.keystore
+*.p12
+*.pfx
+keystore.properties
+signing.properties
+.env
+.env.*
+!.env.example
+!.env.sample
+
+# OS metadata (already listed above, kept for clarity)
+Thumbs.db
+Desktop.ini
+
+# Local agent/browser tooling
+.mimocode/
+.playwright-cli/
+
+# Dependencies and caches left by the web branch
+node_modules/
+.vite/
+
+# Keep the Gradle Wrapper in version control
+!gradle/wrapper/gradle-wrapper.jar
+!gradle/wrapper/gradle-wrapper.properties
diff --git a/.metadata b/.metadata
new file mode 100644
index 0000000..c71cb9a
--- /dev/null
+++ b/.metadata
@@ -0,0 +1,30 @@
+# This file tracks properties of this Flutter project.
+# Used by Flutter tool to assess capabilities and perform upgrades etc.
+#
+# This file should be version controlled and should not be manually edited.
+
+version:
+ revision: "abaf9c523780a608bd46686fd5e53740a07077f8"
+ channel: "stable"
+
+project_type: app
+
+# Tracks metadata for the flutter migrate command
+migration:
+ platforms:
+ - platform: root
+ create_revision: abaf9c523780a608bd46686fd5e53740a07077f8
+ base_revision: abaf9c523780a608bd46686fd5e53740a07077f8
+ - platform: android
+ create_revision: abaf9c523780a608bd46686fd5e53740a07077f8
+ base_revision: abaf9c523780a608bd46686fd5e53740a07077f8
+
+ # User provided section
+
+ # List of Local paths (relative to this file) that should be
+ # ignored by the migrate tool.
+ #
+ # Files that are not part of the templates will be ignored by default.
+ unmanaged_files:
+ - 'lib/main.dart'
+ - 'ios/Runner.xcodeproj/project.pbxproj'
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..de761f7
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,60 @@
+# DSH 工作台 Android 构建环境
+# 用法:
+# docker build -t dsh-appbuilder \
+# --build-arg http_proxy=$http_proxy --build-arg https_proxy=$https_proxy \
+# --build-arg no_proxy=$no_proxy .
+# ./build.sh
+#
+# 产出:build/app/outputs/flutter-apk/app-*.apk
+FROM eclipse-temurin:17-jdk-jammy
+
+ARG http_proxy
+ARG https_proxy
+ARG no_proxy
+ENV http_proxy=$http_proxy \
+ https_proxy=$https_proxy \
+ no_proxy=$no_proxy \
+ DEBIAN_FRONTEND=noninteractive \
+ ANDROID_HOME=/opt/android-sdk \
+ PUB_CACHE=/opt/pub-cache \
+ PATH=/opt/flutter/bin:/opt/android-sdk/cmdline-tools/latest/bin:/opt/android-sdk/platform-tools:$PATH
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends xz-utils unzip ca-certificates curl git \
+ && rm -rf /var/lib/apt/lists/*
+
+ARG FLUTTER_VERSION=3.47.7
+ARG CMDLINE_TOOLS_VERSION=13114758
+
+# Flutter SDK(固定 stable 版本,来自官方 release 归档)
+RUN curl -fsSL -o /tmp/flutter.tar.xz \
+ "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${FLUTTER_VERSION}-stable.tar.xz" \
+ && tar -xJf /tmp/flutter.tar.xz -C /opt \
+ && rm /tmp/flutter.tar.xz \
+ && git config --global --add safe.directory /opt/flutter
+
+# Android SDK cmdline-tools + 许可 + platform-tools
+RUN curl -fsSL -o /tmp/tools.zip \
+ "https://dl.google.com/android/repository/commandlinetools-linux-${CMDLINE_TOOLS_VERSION}_latest.zip" \
+ && mkdir -p "${ANDROID_HOME}/cmdline-tools" \
+ && unzip -q /tmp/tools.zip -d "${ANDROID_HOME}/cmdline-tools" \
+ && 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
+
+RUN flutter config --android-sdk "${ANDROID_HOME}" \
+ && flutter --disable-analytics \
+ && flutter precache --android \
+ && flutter doctor
+
+# 预热:跑一次与应用相同依赖的默认模板构建,把 Gradle 发行版/AGP/AndroidX
+# 组件、插件 AAR 和 SDK 平台下载进镜像,之后挂载源码的正式构建不再重复下载
+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
+
+WORKDIR /project
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..b9f29d5
--- /dev/null
+++ b/README.md
@@ -0,0 +1,53 @@
+# DSH 工作台(Android)
+
+DSH Web Manager 的 Android 客户端:基于系统 WebView 的轻量壳应用,全部构建在 Docker 内完成,宿主机无需安装 Flutter/Android SDK。
+
+## 特性
+
+- 系统 WebView(`webview_flutter`),不打包浏览器内核,体积小
+- 登录状态持久化:系统 WebView 的 Cookie + DOM 存储跨重启保留
+- 全面屏适配:edge-to-edge、刘海/挖孔 `shortEdges`、深色启动底色无白闪
+- 文件上传:`` 走系统文件选择器(工作区上传、备份导入)
+- 文件下载:拦截面板下载导航,交给系统 DownloadManager(携带登录 Cookie)
+- 返回键:优先回退页面历史,无历史时退出应用
+- 加载失败重试页、JS 原生对话框(alert/confirm)桥接
+
+## 构建
+
+前置:本机可用 Docker(构建通过代理访问 Google/Gradle 源时需在环境变量中提供 `http_proxy`/`https_proxy`)。
+
+```sh
+./build.sh https://panel.example.com --keystore /安全路径/dsh-release.jks --alias dsh-release
+```
+
+脚本做两件事:
+
+1. 若本地没有 `dsh-appbuilder:3.47.7` 镜像,先构建它(Flutter 3.47.7 + Android SDK + 预热 Gradle/NDK 缓存,首次约 20–40 分钟)。
+2. 在容器内执行 `flutter build apk --release --split-per-abi --obfuscate`。
+
+产物在 `build/app/outputs/flutter-apk/`:
+
+- `app-arm64-v8a-release.apk`:主流 64 位手机(一般装这个)
+- `app-armeabi-v7a-release.apk`:老设备
+- `app-x86_64-release.apk`:模拟器/ChromeOS
+
+## 配置
+
+站点地址由 `build.sh` 的第一个参数传入,通过 `--dart-define=DSH_SITE_URL=...` 编译进 APK,无需修改源码,应用启动时也无需输入。必须使用完整 HTTPS URL,可带端口和路径,不能带账号密码、空白、查询参数或片段。脚本在编译前验证地址并运行测试;地址不会写入 Docker 镜像。示例域名仅供说明,请替换为真实站点。
+
+需要强制重建工具链镜像时,在上述命令后加 `--no-cache`。更换地址必须重新打包,已有 APK 不会自动更新。
+
+## Release 签名
+
+`--keystore` 指向包含私钥的 JKS/PKCS12 密钥库(不是网站 TLS 证书,也不是仅含公钥的 .cer);`--alias` 指定私钥条目。相对路径基于运行命令时的目录。脚本需要 Bash,密码默认隐藏输入,私钥密码回车时沿用密钥库密码。
+
+CI 可通过 secret 管理器注入 `DSH_STORE_PASSWORD` 和 `DSH_KEY_PASSWORD` 环境变量;后者省略时沿用前者。不支持把密码作为命令参数。密码会传入临时容器环境,Docker 管理员可查看,因此仅在可信的 Docker 主机上构建。不要使用 shell 跟踪(`bash -x`)或把密码打印到日志。
+
+密钥库以只读 bind mount 提供给容器,不复制进镜像。推荐把密钥库放在仓库外;常见密钥扩展名及 signing/ 已从 Docker 构建上下文排除。密钥库和密码必须备份,覆盖升级需要保持包名与签名一致。
+
+## 注意事项
+
+- **证书**:反代必须使用系统 WebView 信任的证书(如 Let's Encrypt)。自签证书会被拒绝,WebView 无法像浏览器那样手动放行。
+- **签名**:release 必须提供外部密钥库;缺失配置或密码/alias 不正确时构建失败,不回退到 debug 签名。此前的 debug 签名 APK 无法被新签名直接覆盖安装。
+- **Android 9 及以下**:下载保存到应用外部目录(免存储权限),不进公共下载目录。
+- **图标**:使用仓库根目录的 `logo.webp`。API 26+ 使用浅冰蓝底色的自适应图标,旧系统使用同一 logo 的传统图标;各密度资源均已生成。
diff --git a/analysis_options.yaml b/analysis_options.yaml
new file mode 100644
index 0000000..ae2e3ea
--- /dev/null
+++ b/analysis_options.yaml
@@ -0,0 +1,33 @@
+# This file configures the analyzer, which statically analyzes Dart code to
+# check for errors, warnings, and lints.
+#
+# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
+# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
+# invoked from the command line by running `flutter analyze`.
+
+# The following line activates a set of recommended lints for Flutter apps,
+# packages, and plugins designed to encourage good coding practices.
+include: package:flutter_lints/flutter.yaml
+
+analyzer:
+ exclude:
+ - build/**
+ - android/**
+
+linter:
+ # The lint rules applied to this project can be customized in the
+ # section below to disable rules from the `package:flutter_lints/flutter.yaml`
+ # included above or to enable additional rules. A list of all available lints
+ # and their documentation is published at https://dart.dev/lints.
+ #
+ # Instead of disabling a lint rule for the entire project in the
+ # section below, it can also be suppressed for a single line of code
+ # or a specific dart file by using the `// ignore: name_of_lint` and
+ # `// ignore_for_file: name_of_lint` syntax on the line or in the file
+ # producing the lint.
+ rules:
+ # avoid_print: false # Uncomment to disable the `avoid_print` rule
+ # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
+
+# Additional information about this file can be found at
+# https://dart.dev/guides/language/analysis-options
diff --git a/android/.gitignore b/android/.gitignore
new file mode 100644
index 0000000..be3943c
--- /dev/null
+++ b/android/.gitignore
@@ -0,0 +1,14 @@
+gradle-wrapper.jar
+/.gradle
+/captures/
+/gradlew
+/gradlew.bat
+/local.properties
+GeneratedPluginRegistrant.java
+.cxx/
+
+# Remember to never publicly share your keystore.
+# See https://flutter.dev/to/reference-keystore
+key.properties
+**/*.keystore
+**/*.jks
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
new file mode 100644
index 0000000..7b122f9
--- /dev/null
+++ b/android/app/build.gradle.kts
@@ -0,0 +1,75 @@
+plugins {
+ id("com.android.application")
+ // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
+ id("dev.flutter.flutter-gradle-plugin")
+}
+
+// 在任务创建前校验,避免缺配置时 AGP 仅报签名任务不存在。
+if (gradle.startParameter.taskNames.any { it.contains("release", ignoreCase = true) }) {
+ for (name in listOf("DSH_KEYSTORE", "DSH_STORE_PASSWORD", "DSH_KEY_ALIAS", "DSH_KEY_PASSWORD")) {
+ check(!System.getenv(name).isNullOrBlank()) { "Release signing requires $name" }
+ }
+}
+
+android {
+ namespace = "ren.misaka.dshapp"
+ compileSdk = flutter.compileSdkVersion
+ ndkVersion = flutter.ndkVersion
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ defaultConfig {
+ // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
+ 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
+ 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)
+ // You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true`
+ // flag during build.
+ versionCode = flutter.versionCode
+ versionName = flutter.versionName
+ }
+
+ signingConfigs {
+ create("release") {
+ storeFile = System.getenv("DSH_KEYSTORE")?.let { file(it) }
+ storePassword = System.getenv("DSH_STORE_PASSWORD")
+ keyAlias = System.getenv("DSH_KEY_ALIAS")
+ keyPassword = System.getenv("DSH_KEY_PASSWORD")
+ }
+ }
+
+ buildTypes {
+ release {
+ signingConfig = signingConfigs.getByName("release")
+ }
+ }
+}
+
+// 直接运行 Gradle/Flutter 也不能绕过 release 签名要求。debug 开发不受影响。
+gradle.taskGraph.whenReady {
+ if (allTasks.any { it.project == project && it.name.contains("Release") }) {
+ for (name in listOf("DSH_KEYSTORE", "DSH_STORE_PASSWORD", "DSH_KEY_ALIAS", "DSH_KEY_PASSWORD")) {
+ check(!System.getenv(name).isNullOrBlank()) {
+ "Release signing requires $name; use build.sh --keystore PATH --alias NAME"
+ }
+ }
+ check(file(System.getenv("DSH_KEYSTORE")).isFile) { "Signing keystore does not exist" }
+ }
+}
+
+kotlin {
+ compilerOptions {
+ jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
+ }
+}
+
+flutter {
+ source = "../.."
+}
diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml
new file mode 100644
index 0000000..399f698
--- /dev/null
+++ b/android/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..20bc1db
--- /dev/null
+++ b/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/kotlin/ren/misaka/dshapp/MainActivity.kt b/android/app/src/main/kotlin/ren/misaka/dshapp/MainActivity.kt
new file mode 100644
index 0000000..2266bdd
--- /dev/null
+++ b/android/app/src/main/kotlin/ren/misaka/dshapp/MainActivity.kt
@@ -0,0 +1,92 @@
+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 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"
+ }
+
+ 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()
+ }
+ }
+ }
+
+ /// 把下载交给系统 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)
+ }
+
+ 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()}"
+ }
+
+ private fun sanitize(name: String): String =
+ name.replace(Regex("[\\\\/:*?\"<>|]"), "_").take(120).ifBlank { "file" }
+}
diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml
new file mode 100644
index 0000000..2ffd547
--- /dev/null
+++ b/android/app/src/main/res/drawable-v21/launch_background.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..1b4dffc
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml
new file mode 100644
index 0000000..8961257
--- /dev/null
+++ b/android/app/src/main/res/drawable/launch_background.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..95ed239
--- /dev/null
+++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..0c04c7d
Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..e112acd
Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..0e5c060
Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..337394e
Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..1ebebeb
Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..c82af7c
Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..9aff8d1
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..35add33
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..b1b3d11
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..56210f0
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml
new file mode 100644
index 0000000..2bf52b9
--- /dev/null
+++ b/android/app/src/main/res/values-night/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values-v27/styles.xml b/android/app/src/main/res/values-v27/styles.xml
new file mode 100644
index 0000000..432b61a
--- /dev/null
+++ b/android/app/src/main/res/values-v27/styles.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..aea578f
--- /dev/null
+++ b/android/app/src/main/res/values/colors.xml
@@ -0,0 +1,5 @@
+
+
+
+ #FF152B36
+
diff --git a/android/app/src/main/res/values/launcher_colors.xml b/android/app/src/main/res/values/launcher_colors.xml
new file mode 100644
index 0000000..06ec84b
--- /dev/null
+++ b/android/app/src/main/res/values/launcher_colors.xml
@@ -0,0 +1,4 @@
+
+
+ #E3EEF6
+
diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..dcbf9a7
--- /dev/null
+++ b/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml
new file mode 100644
index 0000000..399f698
--- /dev/null
+++ b/android/app/src/profile/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
new file mode 100644
index 0000000..dbee657
--- /dev/null
+++ b/android/build.gradle.kts
@@ -0,0 +1,24 @@
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+val newBuildDir: Directory =
+ rootProject.layout.buildDirectory
+ .dir("../../build")
+ .get()
+rootProject.layout.buildDirectory.value(newBuildDir)
+
+subprojects {
+ val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
+ project.layout.buildDirectory.value(newSubprojectBuildDir)
+}
+subprojects {
+ project.evaluationDependsOn(":app")
+}
+
+tasks.register("clean") {
+ delete(rootProject.layout.buildDirectory)
+}
diff --git a/android/gradle.properties b/android/gradle.properties
new file mode 100644
index 0000000..e96108c
--- /dev/null
+++ b/android/gradle.properties
@@ -0,0 +1,6 @@
+org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
+android.useAndroidX=true
+# This newDsl flag was added by the Flutter template
+android.newDsl=false
+# This builtInKotlin flag was added by the Flutter template
+android.builtInKotlin=false
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..a20f2c4
--- /dev/null
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
new file mode 100644
index 0000000..b28021a
--- /dev/null
+++ b/android/settings.gradle.kts
@@ -0,0 +1,26 @@
+pluginManagement {
+ val flutterSdkPath =
+ run {
+ val properties = java.util.Properties()
+ file("local.properties").inputStream().use { properties.load(it) }
+ val flutterSdkPath = properties.getProperty("flutter.sdk")
+ require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
+ flutterSdkPath
+ }
+
+ includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
+
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+plugins {
+ id("dev.flutter.flutter-plugin-loader") version "1.0.0"
+ id("com.android.application") version "9.1.0" apply false
+ id("org.jetbrains.kotlin.android") version "2.4.0" apply false
+}
+
+include(":app")
diff --git a/build.sh b/build.sh
new file mode 100755
index 0000000..8cffb06
--- /dev/null
+++ b/build.sh
@@ -0,0 +1,89 @@
+#!/usr/bin/env bash
+# DSH 工作台 APK 构建:Docker 内完成,宿主机无需安装 Flutter/Android SDK
+# 用法:./build.sh URL --keystore PATH --alias NAME [--no-cache]
+# 产出:build/app/outputs/flutter-apk/app-*.apk
+set -euo pipefail
+
+if [ "$#" -lt 1 ]; then
+ echo '用法:./build.sh https://你的域名 --keystore PATH --alias NAME [--no-cache]' >&2
+ exit 64
+fi
+DSH_SITE_URL=$1
+shift
+case "$DSH_SITE_URL" in
+ https://?*) ;;
+ *) echo '必须传入完整的 HTTPS 站点地址' >&2; exit 64 ;;
+esac
+case "$DSH_SITE_URL" in
+ *[[:space:]]*) echo '站点地址不能包含空白字符' >&2; exit 64 ;;
+esac
+REBUILD=false
+KEYSTORE=''
+DSH_KEY_ALIAS=''
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ --keystore|--alias)
+ if [ "$#" -lt 2 ] || [ -z "$2" ]; then
+ echo "$1 缺少参数值" >&2; exit 64
+ fi
+ if [ "$1" = '--keystore' ]; then KEYSTORE=$2; else DSH_KEY_ALIAS=$2; fi
+ shift 2 ;;
+ --no-cache) REBUILD=true; shift ;;
+ *) echo "未知参数:$1" >&2; exit 64 ;;
+ esac
+done
+if [ -z "$KEYSTORE" ] || [ -z "$DSH_KEY_ALIAS" ]; then
+ echo '必须提供 --keystore PATH 和 --alias NAME;release 不使用 debug 签名' >&2
+ exit 64
+fi
+if [ ! -f "$KEYSTORE" ] || [ ! -r "$KEYSTORE" ]; then
+ echo '密钥库文件不存在或不可读' >&2; exit 64
+fi
+# 相对路径基于调用者的工作目录,不基于脚本目录。
+KEYSTORE="$(cd -- "$(dirname -- "$KEYSTORE")" && pwd)/$(basename -- "$KEYSTORE")"
+case "$KEYSTORE" in
+ *,*) echo '密钥库路径不能含逗号(Docker mount 分隔符)' >&2; exit 64 ;;
+esac
+if [ -z "${DSH_STORE_PASSWORD:-}" ]; then
+ if [ ! -t 0 ]; then
+ echo '非交互构建请通过受保护的环境变量提供 DSH_STORE_PASSWORD' >&2; exit 64
+ fi
+ read -r -s -p '密钥库密码:' DSH_STORE_PASSWORD
+ printf '\n'
+fi
+if [ -z "$DSH_STORE_PASSWORD" ]; then echo '密码不能为空' >&2; exit 64; fi
+if [ -z "${DSH_KEY_PASSWORD:-}" ]; then
+ if [ -t 0 ]; then
+ read -r -s -p '私钥密码(回车使用密钥库密码):' DSH_KEY_PASSWORD
+ printf '\n'
+ fi
+ DSH_KEY_PASSWORD=${DSH_KEY_PASSWORD:-$DSH_STORE_PASSWORD}
+fi
+export DSH_STORE_PASSWORD DSH_KEY_PASSWORD DSH_KEY_ALIAS
+
+cd "$(dirname "$0")"
+
+IMAGE=dsh-appbuilder:3.47.7
+
+if [ "$REBUILD" = true ] || ! docker image inspect "$IMAGE" > /dev/null 2>&1; then
+ echo "==> 构建镜像 $IMAGE(首次较慢,需下载 Flutter/Android SDK)"
+ BUILD_ARGS=()
+ if [ "$REBUILD" = true ]; then BUILD_ARGS+=(--no-cache); fi
+ docker build --build-arg http_proxy --build-arg https_proxy --build-arg no_proxy "${BUILD_ARGS[@]}" -t "$IMAGE" .
+fi
+
+echo "==> 在容器内构建 release APK"
+docker run --rm \
+ -v "$PWD":/project \
+ -w /project \
+ --mount "type=bind,source=$KEYSTORE,target=/run/dsh-signing/release.keystore,readonly" \
+ -e DSH_KEYSTORE=/run/dsh-signing/release.keystore \
+ -e DSH_KEY_ALIAS -e DSH_STORE_PASSWORD -e DSH_KEY_PASSWORD \
+ -e http_proxy -e https_proxy -e no_proxy \
+ -e PUB_CACHE=/opt/pub-cache \
+ -e DSH_SITE_URL="$DSH_SITE_URL" \
+ "$IMAGE" \
+ sh -ec 'dart tool/validate_site_url.dart "$DSH_SITE_URL"; flutter pub get; flutter test --dart-define="DSH_SITE_URL=$DSH_SITE_URL"; flutter build apk --dart-define="DSH_SITE_URL=$DSH_SITE_URL" --release --split-per-abi --obfuscate --split-debug-info=build/symbols'
+
+echo "==> 完成,产物:"
+ls -lh build/app/outputs/flutter-apk/*.apk
diff --git a/lib/config.dart b/lib/config.dart
new file mode 100644
index 0000000..6bff1a7
--- /dev/null
+++ b/lib/config.dart
@@ -0,0 +1,26 @@
+/// 全局配置。
+///
+/// [kSiteUrl] 是本应用唯一要加载的站点地址(DSH Web Manager 的 HTTPS 反代域名)。
+/// 由 build.sh 通过 --dart-define 编译进 APK,不提供占位地址。
+const String kSiteUrl = String.fromEnvironment('DSH_SITE_URL');
+
+bool isValidSiteUrl(String value) {
+ if (value.isEmpty || RegExp(r'\s').hasMatch(value)) return false;
+ final uri = Uri.tryParse(value);
+ if (uri == null) return false;
+ try {
+ return uri.scheme == 'https' &&
+ uri.host.isNotEmpty &&
+ uri.userInfo.isEmpty &&
+ !uri.hasQuery &&
+ !uri.hasFragment &&
+ uri.port > 0 &&
+ uri.port <= 65535;
+ } on FormatException {
+ return false;
+ }
+}
+
+/// 站点主题色,与 index.html 的 theme-color 保持一致(深蓝),
+/// 用于启动背景、状态栏/导航栏衬底与加载/错误页。
+const int kThemeColorValue = 0xFF152B36;
diff --git a/lib/main.dart b/lib/main.dart
new file mode 100644
index 0000000..34c47cb
--- /dev/null
+++ b/lib/main.dart
@@ -0,0 +1,324 @@
+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';
+
+Future main() async {
+ WidgetsFlutterBinding.ensureInitialized();
+ // 全面屏:内容延伸到状态栏/导航栏之下,刘海与手势区交给 SafeArea 处理
+ await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
+ runApp(const DshApp());
+}
+
+class DshApp extends StatelessWidget {
+ const DshApp({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return AnnotatedRegion(
+ // 站点为深色主题:状态栏透明、图标反白
+ value: const SystemUiOverlayStyle(
+ statusBarColor: Colors.transparent,
+ statusBarIconBrightness: Brightness.light,
+ systemNavigationBarColor: Colors.transparent,
+ systemNavigationBarIconBrightness: Brightness.light,
+ ),
+ child: MaterialApp(
+ title: 'DSH 工作台',
+ debugShowCheckedModeBanner: false,
+ theme: ThemeData(
+ useMaterial3: true,
+ colorScheme: ColorScheme.fromSeed(
+ seedColor: const Color(kThemeColorValue),
+ brightness: Brightness.dark,
+ ),
+ scaffoldBackgroundColor: const Color(kThemeColorValue),
+ ),
+ home: const HomePage(),
+ ),
+ );
+ }
+}
+
+class HomePage extends StatefulWidget {
+ const HomePage({super.key});
+
+ @override
+ State createState() => _HomePageState();
+}
+
+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;
+ 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 ?? '未知错误'}')),
+ );
+ }
+ }
+ }
+
+ /// 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));
+ }
+
+ 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 {
+ await SystemNavigator.pop();
+ }
+ }
+
+ void _retry() {
+ setState(() {
+ _loadError = null;
+ _loading = true;
+ });
+ unawaited(_controller.reload());
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return PopScope(
+ canPop: false,
+ onPopInvokedWithResult: _onPopInvoked,
+ child: Scaffold(
+ backgroundColor: const Color(kThemeColorValue),
+ body: SafeArea(
+ child: Stack(
+ children: [
+ WebViewWidget(controller: _controller),
+ if (_loadError != null)
+ _ErrorView(message: _loadError!, onRetry: _retry)
+ else if (_loading)
+ const _LoadingView(),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _LoadingView extends StatelessWidget {
+ const _LoadingView();
+
+ @override
+ Widget build(BuildContext context) {
+ return const ColoredBox(
+ color: Color(kThemeColorValue),
+ child: Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ CircularProgressIndicator(color: Colors.white70),
+ SizedBox(height: 16),
+ Text('正在加载…', style: TextStyle(color: Colors.white70)),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _ErrorView extends StatelessWidget {
+ const _ErrorView({required this.message, required this.onRetry});
+
+ final String message;
+ final VoidCallback onRetry;
+
+ @override
+ Widget build(BuildContext context) {
+ return ColoredBox(
+ color: const Color(kThemeColorValue),
+ child: Center(
+ child: Padding(
+ padding: const EdgeInsets.all(32),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Icon(Icons.cloud_off, size: 56, color: Colors.white54),
+ const SizedBox(height: 16),
+ const Text(
+ '无法连接到服务器',
+ style: TextStyle(color: Colors.white, fontSize: 16),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ message,
+ textAlign: TextAlign.center,
+ maxLines: 3,
+ overflow: TextOverflow.ellipsis,
+ style: const TextStyle(color: Colors.white38, fontSize: 12),
+ ),
+ const SizedBox(height: 20),
+ FilledButton.icon(
+ onPressed: onRetry,
+ icon: const Icon(Icons.refresh),
+ label: const Text('重试'),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/logo.webp b/logo.webp
new file mode 100644
index 0000000..f14dc7f
Binary files /dev/null and b/logo.webp differ
diff --git a/pubspec.lock b/pubspec.lock
new file mode 100644
index 0000000..e21ac76
--- /dev/null
+++ b/pubspec.lock
@@ -0,0 +1,354 @@
+# Generated by pub
+# See https://dart.dev/tools/pub/glossary#lockfile
+packages:
+ async:
+ dependency: transitive
+ description:
+ name: async
+ sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.13.1"
+ boolean_selector:
+ dependency: transitive
+ description:
+ name: boolean_selector
+ sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.2"
+ characters:
+ dependency: transitive
+ description:
+ name: characters
+ sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.4.1"
+ clock:
+ dependency: transitive
+ description:
+ name: clock
+ sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.3"
+ collection:
+ dependency: transitive
+ description:
+ name: collection
+ sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
+ 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:
+ name: fake_async
+ sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
+ 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
+ source: sdk
+ version: "0.0.0"
+ flutter_lints:
+ dependency: "direct dev"
+ description:
+ name: flutter_lints
+ sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.0.0"
+ flutter_test:
+ dependency: "direct dev"
+ 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:
+ name: leak_tracker
+ sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
+ url: "https://pub.dev"
+ source: hosted
+ version: "11.0.2"
+ leak_tracker_flutter_testing:
+ dependency: transitive
+ description:
+ name: leak_tracker_flutter_testing
+ sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.0.10"
+ leak_tracker_testing:
+ dependency: transitive
+ description:
+ name: leak_tracker_testing
+ sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.0.2"
+ lints:
+ dependency: transitive
+ description:
+ name: lints
+ sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.1.0"
+ matcher:
+ dependency: transitive
+ description:
+ name: matcher
+ sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.12.20"
+ material_color_utilities:
+ dependency: transitive
+ description:
+ name: material_color_utilities
+ sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.13.0"
+ meta:
+ dependency: transitive
+ description:
+ name: meta
+ sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.18.3"
+ path:
+ dependency: transitive
+ description:
+ name: path
+ sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
+ 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
+ source: sdk
+ version: "0.0.0"
+ source_span:
+ dependency: transitive
+ description:
+ name: source_span
+ sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.10.2"
+ stack_trace:
+ dependency: transitive
+ description:
+ name: stack_trace
+ sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.12.2"
+ stream_channel:
+ dependency: transitive
+ description:
+ name: stream_channel
+ sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.4"
+ string_scanner:
+ dependency: transitive
+ description:
+ name: string_scanner
+ sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.4.1"
+ term_glyph:
+ dependency: transitive
+ description:
+ name: term_glyph
+ sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.2.2"
+ test_api:
+ dependency: transitive
+ description:
+ name: test_api
+ sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
+ 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:
+ name: vector_math
+ sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.0"
+ vm_service:
+ dependency: transitive
+ description:
+ name: vm_service
+ sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0"
+ 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"
diff --git a/pubspec.yaml b/pubspec.yaml
new file mode 100644
index 0000000..ee4934b
--- /dev/null
+++ b/pubspec.yaml
@@ -0,0 +1,90 @@
+name: dsh_workbench
+description: "DSH 工作台 Android 客户端:系统 WebView 壳。"
+# 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
+
+# The following defines the version and build number for your application.
+# A version number is three numbers separated by dots, like 1.2.43
+# followed by an optional build number separated by a +.
+# Both the version and the builder number may be overridden in flutter
+# build by specifying --build-name and --build-number, respectively.
+# In Android, build-name is used as versionName while build-number used as versionCode.
+# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
+# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
+# Read more about iOS versioning at
+# 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
+
+environment:
+ sdk: ^3.13.5
+
+# Dependencies specify other packages that your package needs in order to work.
+# To automatically upgrade your package dependencies to the latest versions
+# consider running `flutter pub upgrade --major-versions`. Alternatively,
+# dependencies can be manually updated by changing the version numbers below to
+# the latest version available on pub.dev. To see which dependencies have newer
+# versions available, run `flutter pub outdated`.
+dependencies:
+ flutter:
+ sdk: flutter
+
+ # DSH 面板 WebView 壳所需
+ webview_flutter: ^4.14.1
+ webview_flutter_android: ^4.14.1
+ file_selector: ^1.1.0
+
+dev_dependencies:
+ flutter_test:
+ sdk: flutter
+
+ # The "flutter_lints" package below contains a set of recommended lints to
+ # encourage good coding practices. The lint set provided by the package is
+ # activated in the `analysis_options.yaml` file located at the root of your
+ # package. See that file for information about deactivating specific lint
+ # rules and activating additional ones.
+ flutter_lints: ^6.0.0
+
+# For information on the generic Dart part of this file, see the
+# following page: https://dart.dev/tools/pub/pubspec
+
+# The following section is specific to Flutter packages.
+flutter:
+
+ # The following line ensures that the Material Icons font is
+ # included with your application, so that you can use the icons in
+ # the material Icons class.
+ uses-material-design: true
+
+ # To add assets to your application, add an assets section, like this:
+ # assets:
+ # - images/a_dot_burr.jpeg
+ # - images/a_dot_ham.jpeg
+
+ # An image asset can refer to one or more resolution-specific "variants", see
+ # https://flutter.dev/to/resolution-aware-images
+
+ # For details regarding adding assets from package dependencies, see
+ # https://flutter.dev/to/asset-from-package
+
+ # To add custom fonts to your application, add a fonts section here,
+ # in this "flutter" section. Each entry in this list should have a
+ # "family" key with the font family name, and a "fonts" key with a
+ # list giving the asset and other descriptors for the font. For
+ # example:
+ # fonts:
+ # - family: Schyler
+ # fonts:
+ # - asset: fonts/Schyler-Regular.ttf
+ # - asset: fonts/Schyler-Italic.ttf
+ # style: italic
+ # - family: Trajan Pro
+ # fonts:
+ # - asset: fonts/TrajanPro.ttf
+ # - asset: fonts/TrajanPro_Bold.ttf
+ # weight: 700
+ #
+ # For details regarding fonts from package dependencies,
+ # see https://flutter.dev/to/font-from-package
diff --git a/test/config_test.dart b/test/config_test.dart
new file mode 100644
index 0000000..dbeeb84
--- /dev/null
+++ b/test/config_test.dart
@@ -0,0 +1,32 @@
+import 'package:dsh_workbench/config.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ test('接受 HTTPS 站点及端口、路径', () {
+ for (final url in [
+ 'https://panel.example.com',
+ 'https://host:8443/panel/',
+ ]) {
+ expect(isValidSiteUrl(url), isTrue);
+ }
+ });
+ test('拒绝无效站点地址', () {
+ for (final url in [
+ '',
+ 'http://host',
+ 'host',
+ 'https://',
+ 'https://user:pass@host',
+ 'https://host?q=1',
+ 'https://host/#x',
+ 'https://host:99999',
+ 'https://ho st',
+ 'https://host\n',
+ ]) {
+ expect(isValidSiteUrl(url), isFalse, reason: url);
+ }
+ });
+ test('构建注入的地址有效(普通单测允许未配置)', () {
+ if (kSiteUrl.isNotEmpty) expect(isValidSiteUrl(kSiteUrl), isTrue);
+ });
+}
diff --git a/tool/validate_site_url.dart b/tool/validate_site_url.dart
new file mode 100644
index 0000000..950eb95
--- /dev/null
+++ b/tool/validate_site_url.dart
@@ -0,0 +1,12 @@
+import 'dart:io';
+
+// 在 flutter pub get 前运行,不能依赖生成的 package_config.json。
+// ignore: avoid_relative_lib_imports
+import '../lib/config.dart';
+
+void main(List args) {
+ if (args.length != 1 || !isValidSiteUrl(args.single)) {
+ stderr.writeln('站点地址必须是完整 HTTPS URL,不能包含账号密码、空白、查询参数或片段。');
+ exitCode = 64;
+ }
+}