feat(android): add Flutter WebView app with Docker release builds

This commit is contained in:
2026-10-15 16:38:19 +08:00
parent 2745f2491b
commit 589937213f
43 changed files with 1608 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
.git
.gitignore
web/
.mimocode/
build/
dist/
*.apk
*.aab
signing/
**/*.jks
**/*.keystore
**/*.p12
**/*.pfx
**/keystore.properties
**/signing.properties
.env
.env.*
+95
View File
@@ -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
+30
View File
@@ -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'
+60
View File
@@ -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
+53
View File
@@ -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`、深色启动底色无白闪
- 文件上传:`<input type="file">` 走系统文件选择器(工作区上传、备份导入)
- 文件下载:拦截面板下载导航,交给系统 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 的传统图标;各密度资源均已生成。
+33
View File
@@ -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
+14
View File
@@ -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
+75
View File
@@ -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 = "../.."
}
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+47
View File
@@ -0,0 +1,47 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 面板通过 HTTPS 反代访问,必须声明网络权限(release 模板默认不含) -->
<uses-permission android:name="android.permission.INTERNET"/>
<application
android:label="DSH 工作台"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -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<String>("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" }
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 启动底色:与站点主题色一致,避免启动白闪(v21 同款) -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/dsh_theme" />
</layer-list>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 前景图形:终端提示符 “>_”,代表 DSH 工作台(工作区/终端面板)。
自适应图标画布 108dp,可见安全区约为中心 66dp,图形保持在其中。 -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- “>” 折线箭头 -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M36,38 L52,54 L36,70 L42,70 L58,54 L42,38 Z"/>
<!-- “_” 下划线光标 -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M66,50 h18 v8 h-18 Z"/>
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 启动底色:与站点主题色一致,避免启动白闪 -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/dsh_theme" />
</layer-list>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
</resources>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 全面屏:允许窗口延伸到刘海/挖孔区域(含横屏短边),
剩余安全区由 Flutter SafeArea 处理 -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 站点主题色(与 index.html theme-color 一致) -->
<color name="dsh_theme">#FF152B36</color>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="launcher_background">#E3EEF6</color>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+24
View File
@@ -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<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+6
View File
@@ -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
+5
View File
@@ -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
+26
View File
@@ -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")
Executable
+89
View File
@@ -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
+26
View File
@@ -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;
+324
View File
@@ -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<void> 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<SystemUiOverlayStyle>(
// 站点为深色主题:状态栏透明、图标反白
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<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
/// 原生下载通道(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<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 {
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('重试'),
),
],
),
),
),
);
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+354
View File
@@ -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"
+90
View File
@@ -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
+32
View File
@@ -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);
});
}
+12
View File
@@ -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<String> args) {
if (args.length != 1 || !isValidSiteUrl(args.single)) {
stderr.writeln('站点地址必须是完整 HTTPS URL,不能包含账号密码、空白、查询参数或片段。');
exitCode = 64;
}
}