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