feat(android): add Flutter WebView app with Docker release builds
@@ -0,0 +1,17 @@
|
||||
.git
|
||||
.gitignore
|
||||
web/
|
||||
.mimocode/
|
||||
build/
|
||||
dist/
|
||||
*.apk
|
||||
*.aab
|
||||
signing/
|
||||
**/*.jks
|
||||
**/*.keystore
|
||||
**/*.p12
|
||||
**/*.pfx
|
||||
**/keystore.properties
|
||||
**/signing.properties
|
||||
.env
|
||||
.env.*
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -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 的传统图标;各密度资源均已生成。
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
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>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||