Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bea0ed9b5 | ||
|
|
836d876783 | ||
|
|
7f493e0f89 | ||
|
|
867989e09b | ||
|
|
589937213f | ||
|
|
2745f2491b |
@@ -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,101 @@
|
||||
# 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/
|
||||
/output/playwright/
|
||||
|
||||
# Dependencies, build outputs and caches left by the web branch
|
||||
node_modules/
|
||||
.vite/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Locally compiled manager binary
|
||||
/bin/dsh-mgr
|
||||
|
||||
# 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,62 @@
|
||||
# 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" "platforms;android-37.0" > /dev/null
|
||||
|
||||
RUN flutter config --android-sdk "${ANDROID_HOME}" \
|
||||
&& flutter --disable-analytics \
|
||||
&& flutter precache --android \
|
||||
&& flutter doctor
|
||||
|
||||
# 预热默认模板的 Gradle/AGP/AndroidX 和 SDK;GeckoView 的固定版本
|
||||
# 在应用构建时从 Mozilla Maven 下载,不在这里预热旧 WebView 插件。
|
||||
|
||||
# Java/Gradle 不自动读取 http_proxy;仅构建时注入 JVM 代理参数,不固化到镜像 ENV。
|
||||
ARG JAVA_TOOL_OPTIONS
|
||||
RUN flutter create --org dev.dsh --project-name warmup /tmp/warmup \
|
||||
&& cd /tmp/warmup \
|
||||
&& flutter build apk --release \
|
||||
&& cd / && rm -rf /tmp/warmup \
|
||||
&& flutter doctor
|
||||
|
||||
WORKDIR /project
|
||||
@@ -0,0 +1,76 @@
|
||||
# DSH 工作台(Android)
|
||||
|
||||
DSH Web Manager 的 Android 客户端:使用随 APK 打包的 Mozilla GeckoView 内核,不依赖手机的系统 WebView。全部构建在 Docker 内完成,宿主机无需安装 Flutter/Android SDK。
|
||||
|
||||
## 特性
|
||||
|
||||
- 内置 GeckoView:Flutter 平台视图连接原生 GeckoSession,各设备使用同一发布版本的内核
|
||||
- 登录状态持久化:使用非隐私 Gecko profile,持久 Cookie + DOM 存储跨重启保留(会话有效期仍由站点控制)
|
||||
- 全面屏适配:edge-to-edge、刘海/挖孔 `shortEdges`、深色启动底色无白闪
|
||||
- 文件上传:`<input type="file">` 走系统文件选择器(工作区上传、备份导入)
|
||||
- 普通文件上传通过系统授权 URI 流式复制到应用私有临时目录后交给 Gecko,兼容下载目录和文档提供程序;需要足够的本地临时空间。已交给页面的临时文件保留至浏览会话关闭,避免提前删除中断上传。
|
||||
- 文件下载:直接保存 Gecko 已认证请求的响应流,由系统“另存为”选择位置,不通过系统 WebView 获取 Cookie
|
||||
- 下载状态通知:保存时显示进度和已下载大小,完成、失败或取消后更新状态;通知不提供打开文件或目录的操作。未知总大小时显示不定进度。Android 13+ 首次下载请求通知权限,拒绝不会阻止下载。
|
||||
- 返回键:优先回退页面历史,无历史时退出应用
|
||||
- 加载失败重试页、JS 原生对话框桥接
|
||||
|
||||
## 构建
|
||||
|
||||
前置:本机可用 Docker(构建通过代理访问 Google/Gradle 源时需在环境变量中提供 `http_proxy`/`https_proxy`)。
|
||||
|
||||
Java/Gradle 不自动使用上述代理变量。需要时另外设置 `JAVA_TOOL_OPTIONS`,例如 `-Dhttp.proxyHost=代理主机 -Dhttp.proxyPort=端口 -Dhttps.proxyHost=代理主机 -Dhttps.proxyPort=端口`;`build.sh` 会将其传入镜像构建和 APK 构建容器。代理必须能从容器访问,不要把宿主机的 `127.0.0.1` 直接当成容器代理地址。Java 会输出此变量,切勿在其中放入密码或其他秘密。
|
||||
|
||||
```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 构建上下文排除。密钥库和密码必须备份,覆盖升级需要保持包名与签名一致。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- **系统要求**:当前 GeckoView 158 最低支持 Android 8.0(API 26),构建使用 Android SDK 37.0;旧 Android 设备不能安装此版本。已有工具链镜像可用 `--no-cache` 重建以预装新 SDK。
|
||||
- **证书**:反代必须使用 Gecko 信任的有效证书(如 Let's Encrypt)。不提供忽略 TLS 错误或自签证书放行开关。
|
||||
- **签名**:release 必须提供外部密钥库;缺失配置或密码/alias 不正确时构建失败,不回退到 debug 签名。此前的 debug 签名 APK 无法被新签名直接覆盖安装。
|
||||
- **升级迁移**:旧版系统 WebView 的 Cookie/LocalStorage 不会迁移到 Gecko;首次升级需重新登录,服务器端数据不受影响。
|
||||
- **内核更新**:GeckoView 版本固定在 `android/app/build.gradle.kts`,来自 Mozilla 官方 Maven 仓库。内核随 APK 发布,不会自动下载替换;应定期检查稳定版安全更新,更新版本并完成回归后发布新 APK。
|
||||
- **开源许可**:GeckoView/Gecko 包含 MPL 2.0 等许可证下的组件。分发 APK 时需保留上游许可声明并履行相应源码提供义务;参见 [Mozilla 许可说明](https://www.mozilla.org/MPL/) 与 [GeckoView 项目](https://mozilla.github.io/geckoview/)。
|
||||
- **体积**:APK 包含 Gecko 原生库,体积会明显增加;继续按 ABI 分包,用户只安装匹配设备架构的 APK。
|
||||
- **下载生命周期**:保存过程中请保持应用运行;退出应用或进程被系统终止会中断下载,不提供系统 DownloadManager 的后台续传能力。
|
||||
- **图标**:使用仓库根目录的 `logo.webp`。API 26+ 使用浅冰蓝底色的自适应图标,旧系统使用同一 logo 的传统图标;各密度资源均已生成。
|
||||
|
||||
## 内核升级回归
|
||||
|
||||
发布前除 `flutter analyze`、`flutter test` 和 APK 构建外,应在真实 Android 设备上检查:
|
||||
|
||||
- 登录后重启应用,确认站点的持久登录状态及本地存储保留。
|
||||
- 工作区单文件/多文件上传、选择器取消、配置备份导入。
|
||||
- 从下载目录、内部存储及云盘提供程序选择文件;验证中文名、同名多选、空文件、大文件、复制失败及复制中退出。目录上传仍走 Gecko 原有目录 URI 处理路径,需单独验收。
|
||||
- 需要登录的文件、目录 ZIP、配置备份下载,检查保存内容及取消流程。
|
||||
- 下载通知:已知/未知总大小、多个下载、断网/保存失败、退出取消;Android 13+ 通知权限允许和拒绝,以及系统关闭通知的情况。
|
||||
- JS alert/confirm/prompt、输入法、页面滚动、SPA 返回键、横竖屏及前后台切换。
|
||||
- 断网重试与无效证书拒绝;确认没有绕过 TLS 校验。
|
||||
|
||||
Flutter 桥接单测不运行 Gecko 内核,不能替代上述真机测试。
|
||||
@@ -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,80 @@
|
||||
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 { version = release(37) { minorApiLevel = 0 } }
|
||||
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 = 26
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
// Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION
|
||||
// is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions)
|
||||
// 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 = "../.."
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
implementation("org.mozilla.geckoview:geckoview:158.0.20261007201254")
|
||||
}
|
||||
@@ -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,48 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- 面板通过 HTTPS 反代访问,必须声明网络权限(release 模板默认不含) -->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<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,55 @@
|
||||
package ren.misaka.dshapp
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import android.text.format.Formatter
|
||||
import java.util.UUID
|
||||
|
||||
internal class DownloadNotifications(context: Context, private val name: String, headers: Map<String, String>) {
|
||||
private val context = context.applicationContext
|
||||
private val tag = UUID.randomUUID().toString()
|
||||
private val progress = DownloadProgress(headers)
|
||||
|
||||
@Synchronized fun update(bytes: Int = 0) {
|
||||
if (progress.finished) return
|
||||
progress.add(bytes)
|
||||
if (!progress.shouldUpdate(SystemClock.elapsedRealtime())) return
|
||||
post(null)
|
||||
}
|
||||
|
||||
@Synchronized fun finish(status: String) {
|
||||
if (progress.finish()) post(status)
|
||||
}
|
||||
|
||||
private fun post(status: String?) {
|
||||
runCatching {
|
||||
val manager = context.getSystemService(NotificationManager::class.java)
|
||||
manager.createNotificationChannel(NotificationChannel(CHANNEL, "下载", NotificationManager.IMPORTANCE_LOW).apply {
|
||||
lockscreenVisibility = Notification.VISIBILITY_PRIVATE
|
||||
setShowBadge(false)
|
||||
})
|
||||
val percent = progress.percent
|
||||
val size = Formatter.formatShortFileSize(context, progress.written)
|
||||
val text = status ?: (if (percent == null) "正在下载 · 已写入 $size" else "正在下载 · $percent% · 已写入 $size")
|
||||
val notification = Notification.Builder(context, CHANNEL)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download)
|
||||
.setContentTitle(name).setContentText(text)
|
||||
.setVisibility(Notification.VISIBILITY_PRIVATE)
|
||||
.setOnlyAlertOnce(true).setOngoing(status == null)
|
||||
.setProgress(if (status == null) 100 else 0, if (status == null) percent ?: 0 else 0, status == null && percent == null)
|
||||
.build()
|
||||
manager.notify(tag, 0, notification)
|
||||
}.onFailure {
|
||||
if (status != null) runCatching {
|
||||
context.getSystemService(NotificationManager::class.java).cancel(tag, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL = "downloads"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package ren.misaka.dshapp
|
||||
|
||||
internal class DownloadProgress(headers: Map<String, String>) {
|
||||
val total: Long? = run {
|
||||
fun header(name: String) = headers.entries.firstOrNull { it.key.equals(name, true) }?.value
|
||||
if (header("content-encoding").let { it.isNullOrBlank() || it.equals("identity", true) }) {
|
||||
header("content-length")?.trim()?.toLongOrNull()?.takeIf { it > 0 }
|
||||
} else null
|
||||
}
|
||||
var written = 0L
|
||||
private set
|
||||
private var lastUpdate: Long? = null
|
||||
var finished = false
|
||||
private set
|
||||
val percent: Int? get() = total?.takeIf { written <= it }?.let {
|
||||
(written.toDouble() / it * 100).toInt().coerceIn(0, 100)
|
||||
}
|
||||
|
||||
fun add(bytes: Int) { written += bytes }
|
||||
fun shouldUpdate(now: Long): Boolean {
|
||||
if (finished || lastUpdate?.let { now - it < 500 } == true) return false
|
||||
lastUpdate = now
|
||||
return true
|
||||
}
|
||||
fun finish(): Boolean {
|
||||
if (finished) return false
|
||||
finished = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
package ren.misaka.dshapp
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.AlertDialog
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.CancellationSignal
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.OpenableColumns
|
||||
import android.view.View
|
||||
import android.widget.EditText
|
||||
import android.widget.Toast
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.StandardMessageCodec
|
||||
import io.flutter.plugin.platform.PlatformView
|
||||
import io.flutter.plugin.platform.PlatformViewFactory
|
||||
import org.mozilla.geckoview.*
|
||||
import org.mozilla.geckoview.GeckoSession.PromptDelegate.*
|
||||
import java.io.InputStream
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.UUID
|
||||
|
||||
private object BrowserUploads {
|
||||
val executor = Executors.newCachedThreadPool()
|
||||
private var cache: UploadCache? = null
|
||||
@Synchronized fun get(context: Context): UploadCache = cache ?: UploadCache(context.cacheDir).also { cache = it }
|
||||
}
|
||||
|
||||
private object BrowserRuntime {
|
||||
private var instance: GeckoRuntime? = null
|
||||
fun get(context: Context): GeckoRuntime = instance ?: GeckoRuntime.create(context.applicationContext).also { instance = it }
|
||||
}
|
||||
|
||||
internal class GeckoViewFactory(private val activity: MainActivity, private val messenger: BinaryMessenger) :
|
||||
PlatformViewFactory(StandardMessageCodec.INSTANCE) {
|
||||
override fun create(context: Context, viewId: Int, args: Any?): PlatformView {
|
||||
val url = (args as? Map<*, *>)?.get("url") as? String
|
||||
val uri = url?.let { Uri.parse(it) }
|
||||
require(uri != null && uri.scheme == "https" && !uri.host.isNullOrBlank() && uri.userInfo == null) {
|
||||
"GeckoView requires an HTTPS url without credentials"
|
||||
}
|
||||
return GeckoPlatformView(activity, context, messenger, viewId, url!!)
|
||||
}
|
||||
}
|
||||
|
||||
internal class GeckoPlatformView(
|
||||
private val activity: MainActivity, context: Context, messenger: BinaryMessenger, id: Int,
|
||||
private val initialUrl: String
|
||||
) : PlatformView {
|
||||
private val view = GeckoView(context).apply { setViewBackend(GeckoView.BACKEND_TEXTURE_VIEW) }
|
||||
private val session = GeckoSession(GeckoSessionSettings.Builder().usePrivateMode(false).build())
|
||||
private val channel = MethodChannel(messenger, "dsh.workbench/gecko/" + id)
|
||||
private val prompts = mutableMapOf<BasePrompt, () -> Unit>()
|
||||
private val streams = java.util.Collections.synchronizedSet(mutableSetOf<InputStream>())
|
||||
private val io = Executors.newSingleThreadExecutor()
|
||||
private var uploadSession = UUID.randomUUID().toString()
|
||||
private val downloads = java.util.Collections.synchronizedSet(mutableSetOf<DownloadNotifications>())
|
||||
@Volatile private var disposed = false
|
||||
private var loaded = false
|
||||
private var opened = false
|
||||
private var canGoBack = false
|
||||
private var loadFailed = false
|
||||
|
||||
init {
|
||||
val appContext = activity.applicationContext
|
||||
BrowserUploads.executor.execute { runCatching { BrowserUploads.get(appContext) } }
|
||||
session.progressDelegate = object : GeckoSession.ProgressDelegate {
|
||||
override fun onPageStart(session: GeckoSession, url: String) { loadFailed = false; emit("onPageStarted") }
|
||||
override fun onPageStop(session: GeckoSession, success: Boolean) {
|
||||
if (!loadFailed) emit("onPageFinished")
|
||||
}
|
||||
}
|
||||
session.navigationDelegate = object : GeckoSession.NavigationDelegate {
|
||||
override fun onCanGoBack(session: GeckoSession, value: Boolean) { canGoBack = value }
|
||||
override fun onLoadError(session: GeckoSession, uri: String?, error: WebRequestError): GeckoResult<String>? {
|
||||
loadFailed = true
|
||||
emit("onLoadError", "页面加载失败 (" + error.code + ")")
|
||||
return null
|
||||
}
|
||||
}
|
||||
session.contentDelegate = object : GeckoSession.ContentDelegate {
|
||||
override fun onExternalResponse(session: GeckoSession, response: WebResponse) { saveResponse(response) }
|
||||
override fun onCrash(session: GeckoSession) { processStopped() }
|
||||
override fun onKill(session: GeckoSession) { processStopped() }
|
||||
}
|
||||
session.promptDelegate = object : GeckoSession.PromptDelegate {
|
||||
override fun onAlertPrompt(session: GeckoSession, prompt: AlertPrompt) =
|
||||
dialog(prompt, prompt.message, positive = { prompt.dismiss() })
|
||||
override fun onButtonPrompt(session: GeckoSession, prompt: ButtonPrompt) =
|
||||
dialog(prompt, prompt.message, positive = { prompt.confirm(ButtonPrompt.Type.POSITIVE) }, negative = { prompt.confirm(ButtonPrompt.Type.NEGATIVE) })
|
||||
override fun onTextPrompt(session: GeckoSession, prompt: TextPrompt): GeckoResult<PromptResponse> {
|
||||
val input = EditText(activity).apply { setText(prompt.defaultValue) }
|
||||
return dialog(prompt, prompt.message, input, { prompt.confirm(input.text.toString()) }, { prompt.dismiss() })
|
||||
}
|
||||
override fun onFilePrompt(session: GeckoSession, prompt: FilePrompt) = chooseFiles(prompt)
|
||||
override fun onChoicePrompt(session: GeckoSession, prompt: ChoicePrompt) = chooseOptions(prompt)
|
||||
}
|
||||
activity.browserViews.add(this)
|
||||
channel.setMethodCallHandler { call, result ->
|
||||
try {
|
||||
when (call.method) {
|
||||
"load" -> { loadInitial(); result.success(null) }
|
||||
"goBack" -> { val back = canGoBack; if (back) session.goBack(); result.success(back) }
|
||||
"reload" -> { if (loaded) session.reload() else loadInitial(); result.success(null) }
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
} catch (error: Exception) { result.error("GECKO_ERROR", error.message, null) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun emit(method: String, value: String? = null) {
|
||||
activity.runOnUiThread { if (!disposed) channel.invokeMethod(method, value) }
|
||||
}
|
||||
|
||||
private fun loadInitial() {
|
||||
if (!opened) {
|
||||
session.open(BrowserRuntime.get(activity))
|
||||
opened = true
|
||||
view.setSession(session)
|
||||
setActive(activity.browserActive)
|
||||
}
|
||||
if (!loaded) { session.loadUri(initialUrl); loaded = true }
|
||||
}
|
||||
|
||||
private fun processStopped() {
|
||||
opened = false
|
||||
loaded = false
|
||||
canGoBack = false
|
||||
loadFailed = true
|
||||
view.releaseSession()
|
||||
activity.cancelDocuments(this)
|
||||
cancelDownloads()
|
||||
prompts.values.toList().forEach { it() }
|
||||
emit("onLoadError", "网页进程已停止,请重试")
|
||||
closeUploads()
|
||||
}
|
||||
|
||||
private fun closeUploads() {
|
||||
val previous = uploadSession
|
||||
uploadSession = UUID.randomUUID().toString()
|
||||
BrowserUploads.executor.execute { runCatching { BrowserUploads.get(activity.applicationContext).closeSession(previous) } }
|
||||
}
|
||||
|
||||
private fun chooseOptions(prompt: ChoicePrompt): GeckoResult<PromptResponse> {
|
||||
val result = GeckoResult<PromptResponse>()
|
||||
val choices = mutableListOf<ChoicePrompt.Choice>()
|
||||
fun flatten(items: Array<ChoicePrompt.Choice>) {
|
||||
items.filter { !it.disabled && !it.separator }.forEach { choice ->
|
||||
val children = choice.items
|
||||
if (children != null) flatten(children) else choices.add(choice)
|
||||
}
|
||||
}
|
||||
flatten(prompt.choices)
|
||||
var dialog: AlertDialog? = null
|
||||
fun finish(ids: Array<String>?) {
|
||||
if (!prompt.isComplete) result.complete(if (ids == null) prompt.dismiss() else prompt.confirm(ids))
|
||||
prompt.setDelegate(null)
|
||||
prompts.remove(prompt)
|
||||
dialog?.dismiss()
|
||||
}
|
||||
prompts[prompt] = { finish(null) }
|
||||
prompt.setDelegate(object : PromptInstanceDelegate {
|
||||
override fun onPromptDismiss(prompt: BasePrompt) { finish(null) }
|
||||
override fun onPromptUpdate(prompt: BasePrompt) { finish(null) }
|
||||
})
|
||||
val labels = choices.map { it.label }.toTypedArray()
|
||||
val builder = AlertDialog.Builder(activity).setTitle(prompt.title)
|
||||
.setNegativeButton(android.R.string.cancel) { _, _ -> finish(null) }
|
||||
.setOnCancelListener { finish(null) }
|
||||
if (prompt.type == ChoicePrompt.Type.MULTIPLE) {
|
||||
val checked = choices.map { it.selected }.toBooleanArray()
|
||||
builder.setMultiChoiceItems(labels, checked) { _, index, selected -> checked[index] = selected }
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
finish(choices.filterIndexed { index, _ -> checked[index] }.map { it.id }.toTypedArray())
|
||||
}
|
||||
} else {
|
||||
builder.setSingleChoiceItems(labels, choices.indexOfFirst { it.selected }) { _, index ->
|
||||
finish(arrayOf(choices[index].id))
|
||||
}
|
||||
}
|
||||
dialog = builder.create()
|
||||
dialog.show()
|
||||
return result
|
||||
}
|
||||
|
||||
private fun dialog(
|
||||
prompt: BasePrompt, message: String?, input: EditText? = null,
|
||||
positive: () -> PromptResponse, negative: (() -> PromptResponse)? = null
|
||||
): GeckoResult<PromptResponse> {
|
||||
val result = GeckoResult<PromptResponse>()
|
||||
var dialog: AlertDialog? = null
|
||||
fun finish(response: () -> PromptResponse) {
|
||||
if (!prompt.isComplete) result.complete(response())
|
||||
prompts.remove(prompt)
|
||||
prompt.setDelegate(null)
|
||||
dialog?.dismiss()
|
||||
}
|
||||
val cancel = { finish { prompt.dismiss() } }
|
||||
prompts[prompt] = cancel
|
||||
prompt.setDelegate(object : PromptInstanceDelegate {
|
||||
override fun onPromptDismiss(prompt: BasePrompt) { cancel() }
|
||||
})
|
||||
val builder = AlertDialog.Builder(activity).setTitle(prompt.title).setMessage(message)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ -> finish(positive) }
|
||||
.setOnCancelListener { cancel() }
|
||||
if (input != null) builder.setView(input)
|
||||
if (negative != null) builder.setNegativeButton(android.R.string.cancel) { _, _ -> finish(negative) }
|
||||
dialog = builder.create()
|
||||
dialog.show()
|
||||
return result
|
||||
}
|
||||
|
||||
private fun chooseFiles(prompt: FilePrompt): GeckoResult<PromptResponse> {
|
||||
val result = GeckoResult<PromptResponse>()
|
||||
val cancellation = UploadCancellation()
|
||||
val signal = CancellationSignal()
|
||||
val unregisterSignal = cancellation.onCancel { signal.cancel() }
|
||||
val selectionSession = uploadSession
|
||||
var finished = false
|
||||
fun discard(batch: UploadCache.Batch) {
|
||||
BrowserUploads.executor.execute { runCatching { BrowserUploads.get(activity.applicationContext).discard(batch) } }
|
||||
}
|
||||
fun finish(uris: Array<Uri>?, batch: UploadCache.Batch? = null) {
|
||||
if (finished) { batch?.let { discard(it) }; return }
|
||||
finished = true
|
||||
cancellation.cancel()
|
||||
var confirmed = false
|
||||
if (!prompt.isComplete) {
|
||||
try {
|
||||
result.complete(if (uris.isNullOrEmpty()) prompt.dismiss() else prompt.confirm(activity.applicationContext, uris))
|
||||
confirmed = !uris.isNullOrEmpty()
|
||||
} catch (error: Exception) {
|
||||
if (!prompt.isComplete) result.complete(prompt.dismiss())
|
||||
if (!disposed) Toast.makeText(activity, "无法读取所选文件:" + error.message, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
if (!confirmed) batch?.let { discard(it) }
|
||||
prompts.remove(prompt)
|
||||
prompt.setDelegate(null)
|
||||
}
|
||||
prompts[prompt] = { finish(null) }
|
||||
prompt.setDelegate(object : PromptInstanceDelegate {
|
||||
override fun onPromptDismiss(prompt: BasePrompt) { finish(null) }
|
||||
})
|
||||
val folder = prompt.type == FilePrompt.Type.FOLDER
|
||||
val intent = Intent(if (folder) Intent.ACTION_OPEN_DOCUMENT_TREE else Intent.ACTION_OPEN_DOCUMENT)
|
||||
if (!folder) {
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE)
|
||||
val types = prompt.mimeTypes?.filter { it.contains("/") }?.toTypedArray()
|
||||
intent.type = types?.singleOrNull() ?: "*/*"
|
||||
if (!types.isNullOrEmpty()) intent.putExtra(Intent.EXTRA_MIME_TYPES, types)
|
||||
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, prompt.type == FilePrompt.Type.MULTIPLE)
|
||||
}
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
try {
|
||||
activity.launchDocument(this, intent) { code, data ->
|
||||
if (finished || disposed || code != Activity.RESULT_OK || data == null) finish(null)
|
||||
else {
|
||||
val uris = mutableListOf<Uri>()
|
||||
data.clipData?.let { clip -> for (i in 0 until clip.itemCount) uris.add(clip.getItemAt(i).uri) }
|
||||
if (uris.isEmpty()) data.data?.let { uris.add(it) }
|
||||
val selected = if (prompt.type == FilePrompt.Type.MULTIPLE) uris else uris.take(1)
|
||||
// Gecko's FOLDER traversal is separate; copying files cannot preserve directory semantics.
|
||||
if (folder || selected.isEmpty()) finish(selected.toTypedArray())
|
||||
else {
|
||||
Toast.makeText(activity, "正在准备上传文件…", Toast.LENGTH_SHORT).show()
|
||||
val appContext = activity.applicationContext
|
||||
BrowserUploads.executor.execute {
|
||||
try {
|
||||
cancellation.check()
|
||||
val resolver = appContext.contentResolver
|
||||
val sources = selected.map { uri ->
|
||||
cancellation.check()
|
||||
val name = runCatching {
|
||||
resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null, signal)?.use { cursor ->
|
||||
val column = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
if (column >= 0 && cursor.moveToFirst()) cursor.getString(column) else null
|
||||
}
|
||||
}.getOrNull() ?: uri.lastPathSegment
|
||||
cancellation.check()
|
||||
UploadCache.Source(name) {
|
||||
cancellation.check()
|
||||
val descriptor = resolver.openAssetFileDescriptor(uri, "r", signal)
|
||||
?: throw java.io.IOException("无法打开所选文件")
|
||||
try { descriptor.createInputStream() } catch (error: Exception) {
|
||||
descriptor.close()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
val batch = BrowserUploads.get(appContext).copy(selectionSession, sources, cancellation)
|
||||
activity.runOnUiThread {
|
||||
if (disposed || finished || prompt.isComplete) {
|
||||
discard(batch)
|
||||
finish(null)
|
||||
} else finish(batch.files.map { Uri.fromFile(it) }.toTypedArray(), batch)
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
activity.runOnUiThread {
|
||||
if (!finished && !disposed) {
|
||||
Toast.makeText(activity, "无法准备上传文件,请检查可用空间或重新选择:" + error.message, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
finish(null)
|
||||
}
|
||||
} finally {
|
||||
unregisterSignal()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: Exception) { finish(null); Toast.makeText(activity, "无法打开文件选择器:" + error.message, Toast.LENGTH_LONG).show() }
|
||||
return result
|
||||
}
|
||||
|
||||
private fun saveResponse(response: WebResponse) {
|
||||
if (!loadFailed) emit("onPageFinished")
|
||||
val body = response.body
|
||||
if (body == null) { emit("onDownloadError", "下载响应没有内容"); return }
|
||||
if (disposed) { runCatching { body.close() }; return }
|
||||
streams.add(body)
|
||||
var notification: DownloadNotifications? = null
|
||||
try {
|
||||
val uri = Uri.parse(response.uri)
|
||||
val disposition = response.headers.entries.firstOrNull { it.key.equals("content-disposition", true) }?.value
|
||||
val encodedName = disposition?.let { Regex("(?i)filename\\*\\s*=\\s*UTF-8''([^;]+)").find(it)?.groupValues?.get(1) }
|
||||
val headerName = encodedName?.let { Uri.decode(it) } ?: disposition?.let {
|
||||
Regex("(?i)filename\\s*=\\s*(?:\"([^\"]*)\"|([^;]*))").find(it)?.let { match ->
|
||||
match.groupValues[1].ifEmpty { match.groupValues[2].trim() }
|
||||
}
|
||||
}
|
||||
val name = (headerName ?: (if (uri.isHierarchical) uri.getQueryParameter("path")?.substringAfterLast('/') else null) ?: uri.lastPathSegment ?: "download")
|
||||
.map { if (it.isISOControl() || it in "\\/:*?\"<>|") '_' else it }.joinToString("")
|
||||
.take(120).ifBlank { "download" }
|
||||
val mime = response.headers.entries.firstOrNull { it.key.equals("content-type", true) }?.value?.substringBefore(';') ?: "application/octet-stream"
|
||||
val download = DownloadNotifications(activity.applicationContext, name, response.headers)
|
||||
notification = download
|
||||
downloads.add(download)
|
||||
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE)
|
||||
.setType(mime).putExtra(Intent.EXTRA_TITLE, name)
|
||||
activity.launchDocument(this, intent) { code, data ->
|
||||
val destination = data?.data
|
||||
if (code != Activity.RESULT_OK || destination == null || disposed) {
|
||||
download.finish("下载已取消")
|
||||
downloads.remove(download)
|
||||
closeBody(body)
|
||||
} else {
|
||||
activity.withDownloadNotifications(this) { continueDownload ->
|
||||
try {
|
||||
check(continueDownload && !disposed && streams.contains(body)) { "下载已取消" }
|
||||
io.execute {
|
||||
try {
|
||||
check(!disposed && streams.contains(body)) { "下载已取消" }
|
||||
download.update()
|
||||
activity.contentResolver.openOutputStream(destination, "w").use { output ->
|
||||
checkNotNull(output) { "无法打开目标文件" }
|
||||
body.use { input ->
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
while (true) {
|
||||
check(!disposed && streams.contains(body)) { "下载已取消" }
|
||||
val count = input.read(buffer)
|
||||
if (count < 0) break
|
||||
output.write(buffer, 0, count)
|
||||
download.update(count)
|
||||
}
|
||||
}
|
||||
}
|
||||
download.finish(if (disposed || !streams.contains(body)) "下载已取消" else "下载完成")
|
||||
activity.runOnUiThread { if (!disposed) Toast.makeText(activity, "下载已保存", Toast.LENGTH_SHORT).show() }
|
||||
} catch (error: Exception) {
|
||||
download.finish(if (disposed || !streams.contains(body)) "下载已取消" else "下载失败")
|
||||
runCatching { DocumentsContract.deleteDocument(activity.contentResolver, destination) }
|
||||
emit("onDownloadError", "保存下载失败:" + error.message)
|
||||
} finally { downloads.remove(download); closeBody(body) }
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
download.finish(if (!continueDownload || disposed || !streams.contains(body)) "下载已取消" else "下载失败")
|
||||
downloads.remove(download)
|
||||
closeBody(body)
|
||||
runCatching { DocumentsContract.deleteDocument(activity.contentResolver, destination) }
|
||||
emit("onDownloadError", "保存下载失败:" + error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
notification?.let { it.finish("下载失败"); downloads.remove(it) }
|
||||
closeBody(body)
|
||||
emit("onDownloadError", "无法保存下载:" + error.message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelDownloads() {
|
||||
synchronized(downloads) { downloads.toList() }.forEach { it.finish("下载已取消") }
|
||||
downloads.clear()
|
||||
synchronized(streams) { streams.toList() }.forEach { closeBody(it) }
|
||||
}
|
||||
|
||||
private fun closeBody(body: InputStream) { streams.remove(body); runCatching { body.close() } }
|
||||
|
||||
internal fun setActive(active: Boolean) { if (!disposed && opened) { session.setActive(active); session.setFocused(active) } }
|
||||
override fun getView(): View = view
|
||||
override fun dispose() {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
channel.setMethodCallHandler(null)
|
||||
activity.cancelDocuments(this)
|
||||
prompts.values.toList().forEach { it() }
|
||||
cancelDownloads()
|
||||
io.shutdown()
|
||||
if (opened) {
|
||||
session.setActive(false)
|
||||
view.releaseSession()
|
||||
session.close()
|
||||
}
|
||||
closeUploads()
|
||||
session.progressDelegate = null
|
||||
session.navigationDelegate = null
|
||||
session.contentDelegate = null
|
||||
session.promptDelegate = null
|
||||
activity.browserViews.remove(this)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package ren.misaka.dshapp
|
||||
|
||||
import android.content.Intent
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
internal val browserViews = mutableSetOf<GeckoPlatformView>()
|
||||
private val requests = mutableMapOf<Int, Pair<GeckoPlatformView, (Int, Intent?) -> Unit>>()
|
||||
private var nextRequest = 2000
|
||||
private val documentQueue = ArrayDeque<Pair<Int, Intent>>()
|
||||
private var activeDocument: Int? = null
|
||||
private val permissionWaiters = mutableMapOf<GeckoPlatformView, MutableList<(Boolean) -> Unit>>()
|
||||
private var permissionPending = false
|
||||
internal var browserActive = false
|
||||
private set
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
flutterEngine.platformViewsController.registry.registerViewFactory(
|
||||
"dsh.workbench/gecko", GeckoViewFactory(this, flutterEngine.dartExecutor.binaryMessenger)
|
||||
)
|
||||
}
|
||||
|
||||
internal fun launchDocument(owner: GeckoPlatformView, intent: Intent, callback: (Int, Intent?) -> Unit) {
|
||||
val code = nextRequest++
|
||||
check(code <= 65535) { "Too many document requests" }
|
||||
requests[code] = owner to callback
|
||||
documentQueue.addLast(code to intent)
|
||||
drainDialogs()
|
||||
}
|
||||
|
||||
internal fun withDownloadNotifications(owner: GeckoPlatformView, callback: (Boolean) -> Unit) {
|
||||
permissionWaiters.getOrPut(owner) { mutableListOf() }.add(callback)
|
||||
drainDialogs()
|
||||
}
|
||||
|
||||
private fun drainDialogs() {
|
||||
if (activeDocument != null || permissionPending || isFinishing || isDestroyed) return
|
||||
if (permissionWaiters.isNotEmpty()) {
|
||||
val preferences = getSharedPreferences("download_notifications", MODE_PRIVATE)
|
||||
if (Build.VERSION.SDK_INT >= 33 &&
|
||||
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED &&
|
||||
!preferences.getBoolean("requested", false)) {
|
||||
preferences.edit().putBoolean("requested", true).apply()
|
||||
permissionPending = true
|
||||
try {
|
||||
requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1900)
|
||||
return
|
||||
} catch (_: Exception) {
|
||||
permissionPending = false
|
||||
notificationDenied()
|
||||
}
|
||||
}
|
||||
val callbacks = permissionWaiters.values.flatMap { it.toList() }
|
||||
permissionWaiters.clear()
|
||||
callbacks.forEach { it(true) }
|
||||
}
|
||||
while (documentQueue.isNotEmpty()) {
|
||||
val (code, intent) = documentQueue.removeFirst()
|
||||
if (!requests.containsKey(code)) continue
|
||||
activeDocument = code
|
||||
try { startActivityForResult(intent, code); return } catch (_: Exception) {
|
||||
activeDocument = null
|
||||
requests.remove(code)?.second?.invoke(RESULT_CANCELED, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun notificationDenied() {
|
||||
Toast.makeText(applicationContext, "未允许下载通知,下载仍会保存到所选位置", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
if (requestCode == 1900) {
|
||||
permissionPending = false
|
||||
if (grantResults.firstOrNull() != PackageManager.PERMISSION_GRANTED) notificationDenied()
|
||||
drainDialogs()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun cancelDocuments(owner: GeckoPlatformView) {
|
||||
permissionWaiters.remove(owner)?.forEach { it(false) }
|
||||
requests.entries.filter { it.value.first === owner }.map { it.key }.forEach { code ->
|
||||
requests.remove(code)?.second?.invoke(RESULT_CANCELED, null)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
if (activeDocument == requestCode) activeDocument = null
|
||||
val request = requests.remove(requestCode)
|
||||
if (request != null) request.second(resultCode, data)
|
||||
else super.onActivityResult(requestCode, resultCode, data)
|
||||
drainDialogs()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
browserActive = true
|
||||
browserViews.forEach { it.setActive(true) }
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
browserActive = false
|
||||
browserViews.forEach { it.setActive(false) }
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
browserViews.toList().forEach { it.dispose() }
|
||||
requests.clear()
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package ren.misaka.dshapp
|
||||
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.nio.file.Files
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.CancellationException
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
internal class UploadCancellation {
|
||||
private val cancelled = AtomicBoolean(false)
|
||||
private val actions = mutableSetOf<() -> Unit>()
|
||||
fun cancel() {
|
||||
val pending = synchronized(actions) {
|
||||
if (!cancelled.compareAndSet(false, true)) return
|
||||
actions.toList().also { actions.clear() }
|
||||
}
|
||||
pending.forEach { action -> closer.execute { runCatching(action) } }
|
||||
}
|
||||
fun onCancel(action: () -> Unit): () -> Unit {
|
||||
synchronized(actions) {
|
||||
if (cancelled.get()) closer.execute { runCatching(action) } else actions.add(action)
|
||||
}
|
||||
return { synchronized(actions) { actions.remove(action) }; Unit }
|
||||
}
|
||||
fun check() { if (cancelled.get()) throw CancellationException("Upload cancelled") }
|
||||
|
||||
companion object {
|
||||
// Provider cancellation and close can themselves block; neither may run on the UI thread.
|
||||
private val closer = Executors.newCachedThreadPool { task ->
|
||||
Thread(task, "upload-cancel").apply { isDaemon = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Never hold the cache monitor while calling a provider.
|
||||
internal class UploadCache(
|
||||
cacheDir: File,
|
||||
private val openOutput: (File) -> java.io.OutputStream = { it.outputStream() }
|
||||
) {
|
||||
private val root = File(cacheDir.canonicalFile, "gecko-upload-v1")
|
||||
private val active = mutableMapOf<File, UploadCancellation>()
|
||||
private val closedSessions = mutableSetOf<String>()
|
||||
|
||||
init {
|
||||
require(root.canonicalFile == root) { "Upload cache must not be a symlink" }
|
||||
remove(root)
|
||||
if (!root.mkdirs()) throw IOException("Cannot create upload cache")
|
||||
}
|
||||
|
||||
data class Source(val name: String?, val open: () -> InputStream)
|
||||
data class Batch(val directory: File, val files: List<File>)
|
||||
|
||||
fun copy(session: String, sources: List<Source>, cancellation: UploadCancellation): Batch {
|
||||
require(UUID.fromString(session).toString() == session)
|
||||
val directory = File(File(root, session), UUID.randomUUID().toString())
|
||||
synchronized(this) {
|
||||
cancellation.check()
|
||||
if (session in closedSessions) throw CancellationException("Upload session closed")
|
||||
if (!directory.mkdirs()) throw IOException("Cannot create upload directory")
|
||||
active[directory] = cancellation
|
||||
}
|
||||
var completed = false
|
||||
try {
|
||||
val files = sources.mapIndexed { index, source ->
|
||||
cancellation.check()
|
||||
val parent = File(directory, index.toString())
|
||||
if (!parent.mkdir()) throw IOException("Cannot create upload directory")
|
||||
val target = File(parent, safeName(source.name))
|
||||
source.open().use { input ->
|
||||
val unregister = cancellation.onCancel { input.close() }
|
||||
try {
|
||||
cancellation.check()
|
||||
openOutput(target).use { output ->
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
while (true) {
|
||||
cancellation.check()
|
||||
val count = input.read(buffer)
|
||||
if (count < 0) break
|
||||
cancellation.check()
|
||||
output.write(buffer, 0, count)
|
||||
}
|
||||
}
|
||||
} finally { unregister() }
|
||||
}
|
||||
target
|
||||
}
|
||||
return synchronized(this) {
|
||||
cancellation.check()
|
||||
completed = true
|
||||
Batch(directory, files)
|
||||
}
|
||||
} finally {
|
||||
synchronized(this) {
|
||||
active.remove(directory)
|
||||
if (!completed || session in closedSessions) remove(directory)
|
||||
if (session in closedSessions && active.keys.none { it.parentFile == directory.parentFile }) {
|
||||
deleteOwned(directory.parentFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized fun discard(batch: Batch) {
|
||||
require(batch.directory !in active)
|
||||
deleteOwned(batch.directory)
|
||||
}
|
||||
@Synchronized fun closeSession(session: String) {
|
||||
require(UUID.fromString(session).toString() == session)
|
||||
closedSessions.add(session)
|
||||
val directory = File(root, session)
|
||||
val live = active.filterKeys { it.parentFile == directory }
|
||||
live.values.forEach { it.cancel() }
|
||||
if (live.isEmpty()) deleteOwned(directory)
|
||||
else directory.listFiles()?.filter { it !in live }?.forEach { deleteOwned(it) }
|
||||
}
|
||||
|
||||
private fun deleteOwned(file: File) {
|
||||
require(file.toPath().normalize().startsWith(root.toPath()) && file != root)
|
||||
require(root.canonicalFile == root && file.canonicalFile == file)
|
||||
remove(file)
|
||||
}
|
||||
|
||||
private fun remove(file: File) {
|
||||
if (!Files.exists(file.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)) return
|
||||
Files.walk(file.toPath()).use { paths ->
|
||||
paths.sorted(Comparator.reverseOrder()).forEach { Files.delete(it) }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun safeName(name: String?): String {
|
||||
val cleaned = name.orEmpty().map { if (it == '/' || it.code == 92 || it.isISOControl()) '_' else it }.joinToString("")
|
||||
if (cleaned.isBlank() || cleaned == "." || cleaned == "..") return "upload"
|
||||
if (cleaned.toByteArray(Charsets.UTF_8).size <= 240) return cleaned
|
||||
val extension = cleaned.substringAfterLast('.', "").takeIf { it.length in 1..20 }?.let { ".$it" }.orEmpty()
|
||||
var stem = cleaned.removeSuffix(extension)
|
||||
while ((stem + extension).toByteArray(Charsets.UTF_8).size > 240) stem = stem.dropLast(1)
|
||||
return stem + extension
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,55 @@
|
||||
package ren.misaka.dshapp
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class DownloadProgressTest {
|
||||
@Test fun knownLengthUsesWrittenBytes() {
|
||||
val progress = DownloadProgress(mapOf("Content-Length" to "1000"))
|
||||
assertEquals(0, progress.percent)
|
||||
progress.add(250)
|
||||
assertEquals(25, progress.percent)
|
||||
assertEquals(250L, progress.written)
|
||||
progress.add(750)
|
||||
assertEquals(100, progress.percent)
|
||||
progress.add(1)
|
||||
assertNull(progress.percent)
|
||||
}
|
||||
|
||||
@Test fun invalidOrMissingLengthsAreIndeterminate() {
|
||||
for (length in listOf(null, "", "0", "-1", "abc", "9223372036854775808")) {
|
||||
val progress = DownloadProgress(length?.let { mapOf("content-length" to it) } ?: emptyMap())
|
||||
progress.add(1024)
|
||||
assertNull(progress.total)
|
||||
assertNull(progress.percent)
|
||||
assertEquals(1024L, progress.written)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun encodedResponsesIgnoreWireLength() {
|
||||
for (encoding in listOf("gzip", "br", "gzip, br")) {
|
||||
assertNull(DownloadProgress(mapOf("content-length" to "100", "Content-Encoding" to encoding)).total)
|
||||
}
|
||||
assertEquals(100L, DownloadProgress(mapOf("content-length" to "100", "Content-Encoding" to "IDENTITY")).total)
|
||||
}
|
||||
|
||||
@Test fun progressIsThrottledButTerminalStateIsImmediateAndFinal() {
|
||||
val progress = DownloadProgress(emptyMap())
|
||||
assertTrue(progress.shouldUpdate(0))
|
||||
assertFalse(progress.shouldUpdate(499))
|
||||
assertTrue(progress.shouldUpdate(500))
|
||||
assertFalse(progress.shouldUpdate(999))
|
||||
assertTrue(progress.shouldUpdate(1000))
|
||||
assertTrue(progress.finish())
|
||||
assertFalse(progress.finish())
|
||||
assertFalse(progress.shouldUpdate(2000))
|
||||
}
|
||||
|
||||
@Test fun percentageDoesNotOverflowLong() {
|
||||
val progress = DownloadProgress(mapOf("content-length" to Long.MAX_VALUE.toString()))
|
||||
progress.add(Int.MAX_VALUE)
|
||||
progress.add(Int.MAX_VALUE)
|
||||
assertEquals(4294967294L, progress.written)
|
||||
assertEquals(0, progress.percent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package ren.misaka.dshapp
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.nio.file.Files
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.CancellationException
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class UploadCacheTest {
|
||||
@get:Rule val temporary = TemporaryFolder()
|
||||
private fun session() = UUID.randomUUID().toString()
|
||||
private fun source(name: String = "报告.txt", value: String = "content") =
|
||||
UploadCache.Source(name) { value.byteInputStream() }
|
||||
|
||||
@Test fun preservesNamesAndSeparatesDuplicatesAndSelections() {
|
||||
val cache = UploadCache(temporary.root)
|
||||
val id = session()
|
||||
val first = cache.copy(id, listOf(source(), source(value = "second")), UploadCancellation())
|
||||
val second = cache.copy(id, listOf(source()), UploadCancellation())
|
||||
assertEquals(listOf("报告.txt", "报告.txt"), first.files.map { it.name })
|
||||
assertEquals(listOf("content", "second"), first.files.map { it.readText() })
|
||||
assertNotEquals(first.files[0], first.files[1])
|
||||
cache.discard(second)
|
||||
assertTrue(first.files.all { it.exists() })
|
||||
cache.closeSession(id)
|
||||
assertTrue(first.files.none { it.exists() })
|
||||
}
|
||||
|
||||
@Test fun sanitizesTraversalAndKeepsExtensions() {
|
||||
assertEquals(".._.._report.pdf", UploadCache.safeName("../../report.pdf"))
|
||||
assertEquals("a_b.txt", UploadCache.safeName("a" + 92.toChar() + "b.txt"))
|
||||
assertEquals("a_b.txt", UploadCache.safeName("a" + 0.toChar() + "b.txt"))
|
||||
listOf(null, "", " ", ".", "..").forEach { assertEquals("upload", UploadCache.safeName(it)) }
|
||||
val longName = UploadCache.safeName("文".repeat(300) + ".pdf")
|
||||
assertTrue(longName.endsWith(".pdf"))
|
||||
assertTrue(longName.toByteArray().size <= 240)
|
||||
}
|
||||
|
||||
@Test fun streamsLargeInputWithBoundedReadsAndClosesIt() {
|
||||
val cache = UploadCache(temporary.root)
|
||||
var remaining = 10 * 1024 * 1024
|
||||
var closed = false
|
||||
val input = object : InputStream() {
|
||||
override fun read(): Int = error("Must use buffered reads")
|
||||
override fun read(bytes: ByteArray, offset: Int, length: Int): Int {
|
||||
assertTrue(length <= 64 * 1024)
|
||||
if (remaining == 0) return -1
|
||||
val count = minOf(length, remaining)
|
||||
bytes.fill(42, offset, offset + count)
|
||||
remaining -= count
|
||||
return count
|
||||
}
|
||||
override fun close() { closed = true }
|
||||
}
|
||||
val batch = cache.copy(session(), listOf(UploadCache.Source("large.bin") { input }), UploadCancellation())
|
||||
assertEquals(10L * 1024 * 1024, batch.files.single().length())
|
||||
assertTrue(closed)
|
||||
}
|
||||
|
||||
@Test fun cancellationBeforeCopyDoesNotOpenInput() {
|
||||
val cache = UploadCache(temporary.root)
|
||||
val cancel = UploadCancellation().apply { cancel() }
|
||||
assertThrows(CancellationException::class.java) {
|
||||
cache.copy(session(), listOf(UploadCache.Source("file") { error("Opened after cancellation") }), cancel)
|
||||
}
|
||||
assertFalse(temporary.root.walkTopDown().any { it.isFile })
|
||||
}
|
||||
|
||||
@Test fun cancellationDuringCopyClosesInputAndRemovesWholeBatch() {
|
||||
val cache = UploadCache(temporary.root)
|
||||
val cancel = UploadCancellation()
|
||||
var closed = false
|
||||
val input = object : InputStream() {
|
||||
override fun read(): Int { cancel.cancel(); return 1 }
|
||||
override fun close() { closed = true }
|
||||
}
|
||||
assertThrows(CancellationException::class.java) {
|
||||
cache.copy(session(), listOf(source(), UploadCache.Source("cancel.bin") { input }), cancel)
|
||||
}
|
||||
assertTrue(closed)
|
||||
assertFalse(temporary.root.walkTopDown().any { it.isFile })
|
||||
}
|
||||
|
||||
@Test fun providerFailureRemovesEarlierFilesAndPartialFile() {
|
||||
val cache = UploadCache(temporary.root)
|
||||
val input = object : InputStream() {
|
||||
var reads = 0
|
||||
override fun read(): Int { if (++reads > 70000) throw IOException("Provider failed"); return 1 }
|
||||
}
|
||||
assertThrows(IOException::class.java) {
|
||||
cache.copy(session(), listOf(source(), UploadCache.Source("failed") { input }), UploadCancellation())
|
||||
}
|
||||
assertFalse(temporary.root.walkTopDown().any { it.isFile })
|
||||
}
|
||||
|
||||
@Test fun externalCancellationClosesBlockedReadOffCallerThread() {
|
||||
val cache = UploadCache(temporary.root)
|
||||
val cancellation = UploadCancellation()
|
||||
val reading = CountDownLatch(1)
|
||||
val closed = CountDownLatch(1)
|
||||
val caller = Thread.currentThread()
|
||||
val worker = Executors.newSingleThreadExecutor()
|
||||
val input = object : InputStream() {
|
||||
override fun read(): Int {
|
||||
reading.countDown()
|
||||
check(closed.await(5, TimeUnit.SECONDS))
|
||||
throw IOException("Closed")
|
||||
}
|
||||
override fun close() {
|
||||
assertNotEquals(caller, Thread.currentThread())
|
||||
closed.countDown()
|
||||
}
|
||||
}
|
||||
try {
|
||||
val copy = worker.submit {
|
||||
assertThrows(IOException::class.java) {
|
||||
cache.copy(session(), listOf(UploadCache.Source("blocked") { input }), cancellation)
|
||||
}
|
||||
}
|
||||
assertTrue(reading.await(5, TimeUnit.SECONDS))
|
||||
cancellation.cancel()
|
||||
assertTrue(closed.await(5, TimeUnit.SECONDS))
|
||||
copy.get(5, TimeUnit.SECONDS)
|
||||
assertFalse(temporary.root.walkTopDown().any { it.isFile })
|
||||
} finally { closed.countDown(); worker.shutdownNow() }
|
||||
}
|
||||
|
||||
@Test fun uncooperativeOpenDoesNotBlockOtherCopiesOrDeleteLiveBatch() {
|
||||
val cache = UploadCache(temporary.root)
|
||||
val id = session()
|
||||
val opening = CountDownLatch(1)
|
||||
val release = CountDownLatch(1)
|
||||
val workers = Executors.newCachedThreadPool()
|
||||
try {
|
||||
val blocked = workers.submit {
|
||||
assertThrows(CancellationException::class.java) {
|
||||
cache.copy(id, listOf(UploadCache.Source("blocked") {
|
||||
opening.countDown()
|
||||
check(release.await(5, TimeUnit.SECONDS))
|
||||
"late".byteInputStream()
|
||||
}), UploadCancellation())
|
||||
}
|
||||
}
|
||||
assertTrue(opening.await(5, TimeUnit.SECONDS))
|
||||
val directory = File(temporary.root, "gecko-upload-v1/$id")
|
||||
val live = directory.listFiles()!!.single()
|
||||
workers.submit { cache.closeSession(id) }.get(5, TimeUnit.SECONDS)
|
||||
assertTrue(live.exists())
|
||||
val other = workers.submit<UploadCache.Batch> {
|
||||
cache.copy(session(), listOf(source()), UploadCancellation())
|
||||
}.get(5, TimeUnit.SECONDS)
|
||||
assertEquals("content", other.files.single().readText())
|
||||
assertThrows(CancellationException::class.java) {
|
||||
cache.copy(id, listOf(source()), UploadCancellation())
|
||||
}
|
||||
release.countDown()
|
||||
blocked.get(5, TimeUnit.SECONDS)
|
||||
assertFalse(directory.exists())
|
||||
assertTrue(other.files.single().exists())
|
||||
cache.discard(other)
|
||||
cache.discard(other)
|
||||
} finally { release.countDown(); workers.shutdownNow() }
|
||||
}
|
||||
|
||||
@Test fun blockedCancellationCallbackDoesNotBlockCallerOrOtherCallbacks() {
|
||||
val cancellation = UploadCancellation()
|
||||
val release = CountDownLatch(1)
|
||||
val started = CountDownLatch(1)
|
||||
val other = CountDownLatch(1)
|
||||
cancellation.onCancel { started.countDown(); release.await(5, TimeUnit.SECONDS) }
|
||||
cancellation.onCancel { other.countDown() }
|
||||
try {
|
||||
cancellation.cancel()
|
||||
assertTrue(started.await(5, TimeUnit.SECONDS))
|
||||
assertTrue(other.await(5, TimeUnit.SECONDS))
|
||||
val late = CountDownLatch(1)
|
||||
cancellation.onCancel { late.countDown() }
|
||||
assertTrue(late.await(5, TimeUnit.SECONDS))
|
||||
} finally { release.countDown() }
|
||||
}
|
||||
|
||||
@Test fun diskFullClosesStreamsAndDeletesPartialBatch() {
|
||||
var inputClosed = false
|
||||
var outputClosed = false
|
||||
val cache = UploadCache(temporary.root) { file ->
|
||||
val output = file.outputStream()
|
||||
object : java.io.OutputStream() {
|
||||
override fun write(value: Int) { output.write(value); throw IOException("No space left on device") }
|
||||
override fun close() { outputClosed = true; output.close() }
|
||||
}
|
||||
}
|
||||
val input = object : java.io.ByteArrayInputStream(byteArrayOf(1, 2, 3)) {
|
||||
override fun close() { inputClosed = true; super.close() }
|
||||
}
|
||||
assertThrows(IOException::class.java) {
|
||||
cache.copy(session(), listOf(UploadCache.Source("full.bin") { input }), UploadCancellation())
|
||||
}
|
||||
assertTrue(inputClosed)
|
||||
assertTrue(outputClosed)
|
||||
assertFalse(temporary.root.walkTopDown().any { it.isFile })
|
||||
}
|
||||
|
||||
@Test fun restartCleansOnlyOwnedCacheAndSessionCleanupIsIsolated() {
|
||||
val other = temporary.newFile("unrelated")
|
||||
val cache = UploadCache(temporary.root)
|
||||
val firstId = session()
|
||||
val first = cache.copy(firstId, listOf(source()), UploadCancellation())
|
||||
val second = cache.copy(session(), listOf(source()), UploadCancellation())
|
||||
cache.closeSession(firstId)
|
||||
assertFalse(first.files.single().exists())
|
||||
assertTrue(second.files.single().exists())
|
||||
UploadCache(temporary.root)
|
||||
assertFalse(second.files.single().exists())
|
||||
assertTrue(other.exists())
|
||||
assertThrows(IllegalArgumentException::class.java) { cache.closeSession("../unrelated") }
|
||||
assertThrows(IllegalArgumentException::class.java) { cache.discard(UploadCache.Batch(other, emptyList())) }
|
||||
}
|
||||
|
||||
@Test fun rejectsSymlinkRootAndDoesNotFollowNestedSymlinks() {
|
||||
val outside = temporary.newFolder("outside")
|
||||
val retained = File(outside, "retained").apply { writeText("keep") }
|
||||
val parent = temporary.newFolder("cache")
|
||||
val root = File(parent, "gecko-upload-v1")
|
||||
Files.createSymbolicLink(root.toPath(), outside.toPath())
|
||||
assertThrows(IllegalArgumentException::class.java) { UploadCache(parent) }
|
||||
Files.delete(root.toPath())
|
||||
root.mkdir()
|
||||
Files.createSymbolicLink(File(root, "link").toPath(), outside.toPath())
|
||||
UploadCache(parent)
|
||||
assertTrue(retained.exists())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
maven { url = uri("https://maven.mozilla.org/maven2/") }
|
||||
}
|
||||
}
|
||||
|
||||
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,90 @@
|
||||
#!/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-arg JAVA_TOOL_OPTIONS "${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 JAVA_TOOL_OPTIONS \
|
||||
-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,209 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.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> {
|
||||
MethodChannel? _channel;
|
||||
bool _loading = true;
|
||||
String? _loadError;
|
||||
|
||||
Future<void> _onViewCreated(int id) async {
|
||||
final channel = MethodChannel('dsh.workbench/gecko/$id');
|
||||
_channel = channel;
|
||||
channel.setMethodCallHandler((call) async {
|
||||
if (!mounted) return;
|
||||
switch (call.method) {
|
||||
case 'onPageStarted':
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_loadError = null;
|
||||
});
|
||||
case 'onPageFinished':
|
||||
setState(() => _loading = false);
|
||||
case 'onLoadError':
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_loadError = call.arguments as String? ?? '页面加载失败';
|
||||
});
|
||||
case 'onDownloadError':
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text('下载失败:${call.arguments}')));
|
||||
}
|
||||
});
|
||||
await _invoke('load');
|
||||
}
|
||||
|
||||
Future<T?> _invoke<T>(String method) async {
|
||||
try {
|
||||
return await _channel?.invokeMethod<T>(method);
|
||||
} on PlatformException catch (error) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_loadError = error.message ?? '浏览器启动失败';
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onPopInvoked(bool didPop, Object? result) async {
|
||||
if (didPop) return;
|
||||
if (await _invoke<bool>('goBack') == false && mounted) {
|
||||
await SystemNavigator.pop();
|
||||
}
|
||||
}
|
||||
|
||||
void _retry() {
|
||||
setState(() {
|
||||
_loadError = null;
|
||||
_loading = true;
|
||||
});
|
||||
unawaited(_invoke<void>('reload'));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_channel?.setMethodCallHandler(null);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: _onPopInvoked,
|
||||
child: Scaffold(
|
||||
backgroundColor: const Color(kThemeColorValue),
|
||||
body: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
AndroidView(
|
||||
viewType: 'dsh.workbench/gecko',
|
||||
creationParams: const {'url': kSiteUrl},
|
||||
creationParamsCodec: const StandardMessageCodec(),
|
||||
onPlatformViewCreated: _onViewCreated,
|
||||
),
|
||||
if (_loadError != null)
|
||||
_ErrorView(message: _loadError!, onRetry: _retry)
|
||||
else if (_loading)
|
||||
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,205 @@
|
||||
# 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"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
sdks:
|
||||
dart: ">=3.13.5 <4.0.0"
|
||||
flutter: ">=3.18.0-18.0.pre.54"
|
||||
@@ -0,0 +1,87 @@
|
||||
name: dsh_workbench
|
||||
description: "DSH 工作台 Android 客户端:内置 GeckoView 内核。"
|
||||
# The following line prevents the package from being accidentally published to
|
||||
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
||||
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
|
||||
# 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.4+5
|
||||
|
||||
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
|
||||
|
||||
# GeckoView 由 Android 原生平台视图提供,版本固定于 android/app/build.gradle.kts。
|
||||
|
||||
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,97 @@
|
||||
import 'package:dsh_workbench/main.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
const channel = MethodChannel('dsh.workbench/gecko/42');
|
||||
final calls = <String>[];
|
||||
var canGoBack = true;
|
||||
final systemCalls = <String>[];
|
||||
|
||||
setUp(() {
|
||||
calls.clear();
|
||||
systemCalls.clear();
|
||||
canGoBack = true;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (call) async {
|
||||
calls.add(call.method);
|
||||
return call.method == 'goBack' ? canGoBack : null;
|
||||
});
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
|
||||
systemCalls.add(call.method);
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, null);
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.platform, null);
|
||||
});
|
||||
|
||||
Future<void> emit(String method, [Object? value]) async {
|
||||
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.handlePlatformMessage(
|
||||
channel.name,
|
||||
const StandardMethodCodec().encodeMethodCall(
|
||||
MethodCall(method, value),
|
||||
),
|
||||
(_) {},
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('返回键优先回退 Gecko 历史,之后退出应用', (tester) async {
|
||||
await tester.pumpWidget(const DshApp());
|
||||
tester.widget<AndroidView>(find.byType(AndroidView)).onPlatformViewCreated!(
|
||||
42,
|
||||
);
|
||||
await tester.pump();
|
||||
final pop = tester.widget<PopScope>(
|
||||
find.byWidgetPredicate((w) => w is PopScope),
|
||||
);
|
||||
pop.onPopInvokedWithResult!(false, null);
|
||||
await tester.pump();
|
||||
expect(calls.last, 'goBack');
|
||||
expect(systemCalls, isNot(contains('SystemNavigator.pop')));
|
||||
canGoBack = false;
|
||||
pop.onPopInvokedWithResult!(false, null);
|
||||
await tester.pump();
|
||||
expect(systemCalls, contains('SystemNavigator.pop'));
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
});
|
||||
|
||||
testWidgets('Gecko 页面事件、错误及重试通过独立通道连接', (tester) async {
|
||||
await tester.pumpWidget(const DshApp());
|
||||
final view = tester.widget<AndroidView>(find.byType(AndroidView));
|
||||
expect(view.viewType, 'dsh.workbench/gecko');
|
||||
view.onPlatformViewCreated!(42);
|
||||
await tester.pump();
|
||||
expect(calls, ['load']);
|
||||
expect(find.text('正在加载…'), findsOneWidget);
|
||||
|
||||
await emit('onPageFinished');
|
||||
await tester.pump();
|
||||
expect(find.text('正在加载…'), findsNothing);
|
||||
|
||||
await emit('onLoadError', '连接已断开');
|
||||
await tester.pump();
|
||||
expect(find.text('连接已断开'), findsOneWidget);
|
||||
await tester.tap(find.text('重试'));
|
||||
await tester.pump();
|
||||
expect(calls.last, 'reload');
|
||||
expect(find.text('正在加载…'), findsOneWidget);
|
||||
|
||||
await emit('onPageStarted');
|
||||
await emit('onPageFinished');
|
||||
await emit('onDownloadError', '空间不足');
|
||||
await tester.pump();
|
||||
expect(find.text('下载失败:空间不足'), findsOneWidget);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
await emit('onPageStarted');
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||