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.*
|
||||||
@@ -1,48 +1,101 @@
|
|||||||
# Local tooling and browser artifacts
|
# Miscellaneous
|
||||||
/.mimocode/
|
*.class
|
||||||
.playwright-cli/
|
|
||||||
|
|
||||||
# Dependencies and generated files, including leftovers when switching branches
|
|
||||||
node_modules/
|
|
||||||
.vite/
|
|
||||||
/web/dist/
|
|
||||||
*.tsbuildinfo
|
|
||||||
/bin/
|
|
||||||
output/
|
|
||||||
|
|
||||||
# Local runtime data and credentials
|
|
||||||
/data/
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
*.sqlite*
|
|
||||||
*.log
|
*.log
|
||||||
|
*.pyc
|
||||||
|
*.swp
|
||||||
|
.DS_Store
|
||||||
|
.atom/
|
||||||
|
.build/
|
||||||
|
.buildlog/
|
||||||
|
.history/
|
||||||
|
.svn/
|
||||||
|
.swiftpm/
|
||||||
|
migrate_working_dir/
|
||||||
|
|
||||||
# Local IDE files
|
# IntelliJ related
|
||||||
.idea/
|
|
||||||
*.iml
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
*.iws
|
||||||
|
.idea/
|
||||||
|
|
||||||
# Generated files left when switching from android-app
|
# VS Code local state
|
||||||
/.dart_tool/
|
.vscode/
|
||||||
/.flutter-plugins-dependencies
|
|
||||||
|
# Flutter/Dart/Pub related
|
||||||
|
**/doc/api/
|
||||||
|
**/ios/Flutter/.last_build_id
|
||||||
|
.dart_tool/
|
||||||
|
.flutter-plugins-dependencies
|
||||||
|
.pub-cache/
|
||||||
|
.pub/
|
||||||
/build/
|
/build/
|
||||||
/android/.gradle/
|
/coverage/
|
||||||
/android/.kotlin/
|
|
||||||
/android/build/
|
|
||||||
/android/app/build/
|
|
||||||
/android/local.properties
|
|
||||||
/android/gradlew
|
|
||||||
/android/gradlew.bat
|
|
||||||
/android/gradle/wrapper/gradle-wrapper.jar
|
|
||||||
/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java
|
|
||||||
|
|
||||||
# Android packages and signing credentials
|
# 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
|
*.apk
|
||||||
*.aab
|
*.aab
|
||||||
|
*.ap_
|
||||||
|
*.dex
|
||||||
|
|
||||||
|
# Signing keys and local credentials
|
||||||
*.jks
|
*.jks
|
||||||
*.keystore
|
*.keystore
|
||||||
*.p12
|
*.p12
|
||||||
*.pfx
|
*.pfx
|
||||||
keystore.properties
|
keystore.properties
|
||||||
signing.properties
|
signing.properties
|
||||||
/android/key.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
|
||||||
@@ -1,171 +1,76 @@
|
|||||||
# DSH Runtime Release
|
# DSH 工作台(Android)
|
||||||
|
|
||||||
## runtime-7.11:DSH rc.5 与内置插件更新(待运行验收)
|
DSH Web Manager 的 Android 客户端:使用随 APK 打包的 Mozilla GeckoView 内核,不依赖手机的系统 WebView。全部构建在 Docker 内完成,宿主机无需安装 Flutter/Android SDK。
|
||||||
|
|
||||||
DSH 从 `0.2.1-rc.3` 更新为 `0.2.1-rc.5`(查询时 npm `latest` 和 `next` 均为此版本,不切换 alpha 渠道)。free-search 从 `0.9.15` 更新为 `0.9.21`,Chat Manager 从 `1.5.18-dsh-mgr.1` 更新为 `1.5.20-dsh-mgr.1`。mobile `3.2.1` 已是最新;自有 workspace `1.0.0`、preview-download `1.0.1` 及运行布局 7 不变。
|
## 特性
|
||||||
|
|
||||||
同步安装归档 SHA-512、新 profile、已有内置归档迁移目标及精确宿主版本门禁,保留自定义来源、启用选择和备份机制。真实发布包的 Chat Manager 客户端补丁、原生设置接口及 Office converter 导入检查通过。48 项单元测试通过,新增 runtime-7.10 插件引用迁移、备份和幂等回归。完整镜像与运行验收结果另行记录;未提交、推送或修改线上实例。
|
- 内置 GeckoView:Flutter 平台视图连接原生 GeckoSession,各设备使用同一发布版本的内核
|
||||||
|
- 登录状态持久化:使用非隐私 Gecko profile,持久 Cookie + DOM 存储跨重启保留(会话有效期仍由站点控制)
|
||||||
|
- 全面屏适配:edge-to-edge、刘海/挖孔 `shortEdges`、深色启动底色无白闪
|
||||||
|
- 文件上传:`<input type="file">` 走系统文件选择器(工作区上传、备份导入)
|
||||||
|
- 普通文件上传通过系统授权 URI 流式复制到应用私有临时目录后交给 Gecko,兼容下载目录和文档提供程序;需要足够的本地临时空间。已交给页面的临时文件保留至浏览会话关闭,避免提前删除中断上传。
|
||||||
|
- 文件下载:直接保存 Gecko 已认证请求的响应流,由系统“另存为”选择位置,不通过系统 WebView 获取 Cookie
|
||||||
|
- 下载状态通知:保存时显示进度和已下载大小,完成、失败或取消后更新状态;通知不提供打开文件或目录的操作。未知总大小时显示不定进度。Android 13+ 首次下载请求通知权限,拒绝不会阻止下载。
|
||||||
|
- 返回键:优先回退页面历史,无历史时退出应用
|
||||||
|
- 加载失败重试页、JS 原生对话框桥接
|
||||||
|
|
||||||
AMD64 镜像 `dsh-web:runtime-7.11-amd64` 已成功构建;新版本真实依赖、插件 SHA-512 和安装期兼容性检查全部通过。禁网 UID 1000 新 profile 离线安装、重复执行启动入口(`--version`)及锁文件 SHA-256 不变通过;默认 Web 服务启动后,3080/8080 未认证请求均为 HTTP 401。测试容器已清理,未挂载现有工作区。尚未验收浏览器交互、真实搜索、设置保存、Office 实际转换、终端原生模块及 ARM64;本机没有 runtime-7.10 镜像,旧镜像锁文件迁移集成测试未执行(配置迁移单元回归已通过)。
|
## 构建
|
||||||
|
|
||||||
## runtime-7.10:DSH rc.3 与内置插件更新(待运行验收)
|
前置:本机可用 Docker(构建通过代理访问 Google/Gradle 源时需在环境变量中提供 `http_proxy`/`https_proxy`)。
|
||||||
|
|
||||||
沿用此前的 npm `next` 渠道,DSH 精确固定为 `0.2.1-rc.3`;查询时 `latest` 为 `0.2.1-rc.2`。mobile 更新为 `3.2.1`,free-search 为 `0.9.15`,Chat Manager 为 `1.5.18-dsh-mgr.1`(基于上游 `1.5.18`)。workspace `1.0.0`、preview-download `1.0.1` 和运行布局 7 保持不变。三个上游插件归档的 SHA-512 已核对并更新。
|
Java/Gradle 不自动使用上述代理变量。需要时另外设置 `JAVA_TOOL_OPTIONS`,例如 `-Dhttp.proxyHost=代理主机 -Dhttp.proxyPort=端口 -Dhttps.proxyHost=代理主机 -Dhttps.proxyPort=端口`;`build.sh` 会将其传入镜像构建和 APK 构建容器。代理必须能从容器访问,不要把宿主机的 `127.0.0.1` 直接当成容器代理地址。Java 会输出此变量,切勿在其中放入密码或其他秘密。
|
||||||
|
|
||||||
同步新 profile 和已有内置归档引用的迁移目标,保留自定义来源、插件启用选择、备份及锁文件同步机制。设置和 Office 补丁版本门禁对齐 rc.3,真实发布包的原生设置接口与 Office converter 导入入口检查通过。Chat Manager 的三处客户端适配仍精确匹配,新版真实源码补丁及语法检查通过;没有修改认证机制。mobile/free-search 的预发布 peer 例外仍只允许精确 rc.3 且满足 includePrerelease 范围。
|
```sh
|
||||||
|
./build.sh https://panel.example.com --keystore /安全路径/dsh-release.jks --alias dsh-release
|
||||||
|
```
|
||||||
|
|
||||||
本次 `node --test image/*.test.mjs` 共 46 项通过,包含新增的 runtime-7.9 实际插件版本引用迁移、备份及幂等回归。单元测试和源码核查不等同于完整运行兼容性证明;浏览器交互、真实搜索、设置保存、Office 实际转换、终端原生模块、容器离线启动及 ARM64 仍需本版本验收。未提交、推送或修改线上实例。
|
脚本做两件事:
|
||||||
|
|
||||||
锁文件集成脚本默认验证 `dsh-web:runtime-7.9-amd64` → `dsh-web:runtime-7.10-amd64`,也可使用 `DSH_OLD_IMAGE` / `DSH_NEW_IMAGE` 指定已构建的镜像:`node image/lockfile-upgrade-smoke.mjs`。须先准备两个镜像;本次尚未完成该集成检查,不沿用旧发布的通过结论。
|
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`。
|
||||||
|
|
||||||
后续运行验收发现 preview-download 安装器仍限定 rc.2,已核对 rc.3 的操作插槽及 readBytes 接口并修正精确宿主门禁。新增安装器回归直接校验发布清单中的宿主版本可以完成打包调用、未经验证版本在写入前被拒绝;目前全部 47 项回归通过。
|
产物在 `build/app/outputs/flutter-apk/`:
|
||||||
|
|
||||||
AMD64 镜像 `dsh-web:runtime-7.10-amd64` 已成功构建(ID `sha256:57d99cad32380dfc837fd1365814943b53f96abc07e84df154d02b7baa95f0bf`)。MiSans 下载首次超时,原来源重试后校验通过。禁网 UID 1000 新 profile 离线依赖安装、默认启动、未认证 HTTP 401、重启及锁文件哈希不变均通过。Office 实际 DOCX/PPTX/DOC/PPT 并发转换、已安装 DSH convertBytes 集成、输出/ZIP 限制、超时和清理通过。以上结果更新此前待验收状态;浏览器完整交互、真实搜索、设置保存、终端原生模块、旧镜像锁文件迁移和 ARM64 尚未验收,不代表可直接发布。
|
- `app-arm64-v8a-release.apk`:主流 64 位手机(一般装这个)
|
||||||
|
- `app-armeabi-v7a-release.apk`:老设备
|
||||||
|
- `app-x86_64-release.apk`:模拟器/ChromeOS
|
||||||
|
|
||||||
## runtime-7.9:DSH next 与启动配置迁移
|
## 配置
|
||||||
|
|
||||||
锁文件同步修复:内置依赖迁移或补建 workspace 配置前,先持久化 `.dsh-lockfile-pending` 标记。仅带标记的启动安装显式传入 `--no-frozen-lockfile`,同步 `pnpm-lock.yaml`;成功后清除标记,后续启动恢复 CI 默认冻结检查。安装失败或超时保留标记并阻止应用启动,下次启动继续同步,不自动恢复旧 manifest。已有 manifest 备份机制保持不变。若旧版失败迁移已经留下“新 manifest+旧锁文件”且没有标记,应先恢复匹配的旧 profile 备份再升级;不要直接删除锁文件。
|
站点地址由 `build.sh` 的第一个参数传入,通过 `--dart-define=DSH_SITE_URL=...` 编译进 APK,无需修改源码,应用启动时也无需输入。必须使用完整 HTTPS URL,可带端口和路径,不能带账号密码、空白、查询参数或片段。脚本在编译前验证地址并运行测试;地址不会写入 Docker 镜像。示例域名仅供说明,请替换为真实站点。
|
||||||
|
|
||||||
本次修复验证:45 项自动化测试通过;`node image/lockfile-upgrade-smoke.mjs` 使用本地 `dsh-upgrade-test:runtime-7.8` 与 `dsh-web:runtime-7.9-amd64` 镜像、独立临时卷、禁网非 root 容器,挂载当前修复脚本,验证真实旧锁文件迁移、随后冻结安装成功及重启锁文件哈希不变。脚本结束删除测试卷。本次未重跑面板 HTTP 升级接口或首页验收,未发布新镜像。
|
需要强制重建工具链镜像时,在上述命令后加 `--no-cache`。更换地址必须重新打包,已有 APK 不会自动更新。
|
||||||
|
|
||||||
DSH 固定为 `0.2.1-rc.2`(本次明确选择 npm `next`,查询时 `latest` 仍为 `0.2.0-rc.2`),mobile 升级为 `3.1.0`,Chat Manager 升级为明确声明支持新宿主的 `1.5.16-dsh-mgr.1`。free-search 升级为 `0.9.2`,workspace `1.0.0`、preview-download `1.0.1` 保持不变。
|
## Release 签名
|
||||||
|
|
||||||
现有 runtime 每次启动时检查 `profiles/web/package.json`,不另加容器、不联网追踪最新版。仅将 dependencies/devDependencies/optionalDependencies 中同名 `file:/usr/local/lib/dsh-mgr/<包名>-<版本>.tgz` 引用对齐镜像内置清单,并补齐相关缺失的 pnpm overrides。保留自定义安装来源、自定义 overrides、bundles、已删除的插件和其他配置。目标归档缺失、无效配置或不安全的符号链接会阻止启动。
|
`--keystore` 指向包含私钥的 JKS/PKCS12 密钥库(不是网站 TLS 证书,也不是仅含公钥的 .cer);`--alias` 指定私钥条目。相对路径基于运行命令时的目录。脚本需要 Bash,密码默认隐藏输入,私钥密码回车时沿用密钥库密码。
|
||||||
|
|
||||||
只有配置变化才写入,原文保存在同目录 `package.json.backup-<UUID>`,新文件通过原子替换写入,权限 `0600`;重复启动不会反复改写。恢复时先停止实例并备份现状,再恢复对应备份;要回退旧内置版本必须同时使用匹配的旧镜像,否则下一次启动会再次对齐当前镜像。迁移不提供针对同 UID 恶意并发修改目录的沙箱保护。
|
CI 可通过 secret 管理器注入 `DSH_STORE_PASSWORD` 和 `DSH_KEY_PASSWORD` 环境变量;后者省略时沿用前者。不支持把密码作为命令参数。密码会传入临时容器环境,Docker 管理员可查看,因此仅在可信的 Docker 主机上构建。不要使用 shell 跟踪(`bash -x`)或把密码打印到日志。
|
||||||
|
|
||||||
新版设置 UI 已原生使用 host settings describe/mutate,构建只验证接口、不再应用旧 persistence 替换。Office 转换补丁仍限定精确宿主及插件版本;下载 slot/readBytes 和 workspaceRegistry 接口已核对上游源码。mobile/free-search 的预发布 peer 例外仅允许精确 `0.2.1-rc.2` 的 DSH 包且仍要求满足原版本范围(includePrerelease),其他依赖照常严格校验。
|
密钥库以只读 bind mount 提供给容器,不复制进镜像。推荐把密钥库放在仓库外;常见密钥扩展名及 signing/ 已从 Docker 构建上下文排除。密钥库和密码必须备份,覆盖升级需要保持包名与签名一致。
|
||||||
|
|
||||||
当前自动化回归 40 项通过;AMD64 镜像已构建,禁网 UID 1000 新 profile 安装、HTTP 未认证 401、旧 mobile/free-search/chat-manager 引用迁移、备份与幂等检查通过。Office 的已安装 DSH convertBytes 集成及 DOCX/PPTX/DOC/PPT 并发、限制、超时、清理测试通过。pnpm 会提示由宿主提供的 peer 缺失/范围冲突;这不是浏览器交互兼容性证明。浏览器完整交互、真实搜索结果、设置保存、终端原生模块和 ARM64 尚未验收。以下旧发布记录中的“已有 profile 不自动改写”仅适用于旧版本。
|
## 注意事项
|
||||||
|
|
||||||
## runtime-7.8:修复下载按钮服务依赖
|
- **系统要求**:当前 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 的传统图标;各密度资源均已生成。
|
||||||
|
|
||||||
下载插件升级为 dsh-preview-download@1.0.1,补齐 remote 服务声明,修复点击时在发送请求前抛出 cannot get property "remote" without inject 的问题。同步更新内置包和新 profile 引用,新增注册回调依赖回归测试。此修复不处理面板 401、Office 504 或 plugins/events HTTP/2 连接错误。
|
## 内核升级回归
|
||||||
|
|
||||||
已有实例升级镜像时,必须把 profile 中 dsh-preview-download 依赖的归档路径从 dsh-preview-download-1.0.0.tgz 改为 dsh-preview-download-1.0.1.tgz,保留其他配置和 bundles,重启后刷新页面;已有 profile 不自动改写。尚未完成线上浏览器下载验收。
|
发布前除 `flutter analyze`、`flutter test` 和 APK 构建外,应在真实 Android 设备上检查:
|
||||||
|
|
||||||
## runtime-7.7:预览下载插件
|
- 登录后重启应用,确认站点的持久登录状态及本地存储保留。
|
||||||
|
- 工作区单文件/多文件上传、选择器取消、配置备份导入。
|
||||||
|
- 从下载目录、内部存储及云盘提供程序选择文件;验证中文名、同名多选、空文件、大文件、复制失败及复制中退出。目录上传仍走 Gecko 原有目录 URI 处理路径,需单独验收。
|
||||||
|
- 需要登录的文件、目录 ZIP、配置备份下载,检查保存内容及取消流程。
|
||||||
|
- 下载通知:已知/未知总大小、多个下载、断网/保存失败、退出取消;Android 13+ 通知权限允许和拒绝,以及系统关闭通知的情况。
|
||||||
|
- JS alert/confirm/prompt、输入法、页面滚动、SPA 返回键、横竖屏及前后台切换。
|
||||||
|
- 断网重试与无效证书拒绝;确认没有绕过 TLS 校验。
|
||||||
|
|
||||||
内置独立插件 dsh-preview-download@1.0.0,在文档预览工具栏提供「下载原文件」。使用正式 sidebar.right.tab.document.actions 插槽,不修改上游 UI、不扫描 DOM、不增加服务端下载接口。通过当前会话 workspaceFiles.readBytes 获取原始字节,沿用服务端权限和完整文件大小上限;支持中文文件名、下载中状态、失败提示及切换文件/卸载时取消读取。下载的是原始文件,不是 Office 转换出的 PDF。文件元数据无法读取时不会显示工具栏按钮。
|
Flutter 桥接单测不运行 Gecko 内核,不能替代上述真机测试。
|
||||||
|
|
||||||
新建空 profile 默认启用。已有实例升级镜像后,在 /workspace/profiles/web/package.json 的 dependencies 中添加以下条目,并在 dsh.profile.bundles 中追加一次 dsh-preview-download,保留其他配置,然后重启:
|
|
||||||
|
|
||||||
~~~json
|
|
||||||
"dsh-preview-download": "file:/usr/local/lib/dsh-mgr/dsh-preview-download-1.0.1.tgz"
|
|
||||||
~~~
|
|
||||||
|
|
||||||
插件源码位于 image/preview-download,也可单独打包安装。无第三方运行时依赖,React 由 DSH 浏览器提供。当前仅针对 DSH 0.2.0-rc.2 验证。25 项离线回归测试通过,已在现有 AMD64 镜像中验证插件打包、新 profile 离线安装及服务启动;完整 runtime-7.7 构建在 MiSans 下载阶段超时,尚未完成浏览器实际点击下载、移动端和 ARM64 验收。不应把单元测试中的拒绝响应模拟当作真实权限端到端验收。
|
|
||||||
|
|
||||||
## runtime-7.6:原生 Office 文档预览
|
|
||||||
|
|
||||||
Linux 文档预览通过构建期补丁将 Office-to-PDF 转换入口接到系统 LibreOffice,不再使用该入口原先默认的 WASM 后端。补丁限定 DSH 和 dsh-office-to-pdf 为 0.2.0-rc.2,目标 import 不匹配或重复应用时构建失败。保留上游授权、队列、缓存和 PDF 验证;不修改独立 dsoffice CLI 或其他插件。
|
|
||||||
|
|
||||||
每次转换使用私有临时目录、独立 profile、最高宏安全级别和独立进程组;保留默认 60 秒超时,取消时终止进程组。检查输入大小、OOXML 条目数、解压总量和 CRC、输出大小及完整性。子进程采用最小环境变量;校验器地址空间上限 512 MiB,LibreOffice 为 2 GiB,单文件写入上限为 max(maxOutputBytes, 16 MiB),最终 PDF 仍严格检查 maxOutputBytes。进程资源限制不是进程树总量或临时目录总量限制,部署时仍须容器内存、CPU、PID 和临时磁盘限制。
|
|
||||||
|
|
||||||
安全边界:原生 LibreOffice 与 DSH 使用同一 UID,独立 profile 不是文件系统或网络沙箱;不应将此功能视为隔离恶意文档的服务。容器内敏感文件及网络访问仍需部署侧隔离,尤其是处理不可信外部文档时。
|
|
||||||
|
|
||||||
字体由系统解析;显式 fontDirectories/fontFallbacks 配置会被拒绝,应移除并改用系统字体配置。kit 的 maxFontFiles/maxFontFileBytes/maxLoadedFontBytes 不再约束系统字体。missingFonts 为空仅表示不提供缺失字体诊断,不代表没有字体替换。
|
|
||||||
|
|
||||||
已完成 AMD64 构建、22 项回归测试,以及非 root/禁网容器中 DOCX/PPTX/DOC/PPT 并发转换、已安装 DSH convertBytes 集成、输出/压缩包限制、超时及清理验证。小型单页样本约 3 秒/文件,不代表实际文档性能保证。ARM64、真实失败样本、浏览器端到端、XLS/XLSX 和视觉版式尚未验收。
|
|
||||||
|
|
||||||
构建后在仓库根目录运行:
|
|
||||||
|
|
||||||
~~~sh
|
|
||||||
docker run --rm --network none --init \
|
|
||||||
--mount type=bind,src="$PWD/image",dst=/test,readonly \
|
|
||||||
--entrypoint node dsh-web:runtime-7.6-amd64 /test/office-preview-smoke.mjs
|
|
||||||
~~~
|
|
||||||
|
|
||||||
现有实例需升级新镜像才生效,不需要清空工作区。不自动提交、推送或升级线上实例。
|
|
||||||
|
|
||||||
## runtime-7.5:精简 MiSans 字重
|
|
||||||
|
|
||||||
runtime-7.5 修订仅保留 MiSans Regular 和 Bold,从第三方 GitHub 仓库 boyan01/mi_sans_font 的固定提交下载独立 TTF 并逐个校验 SHA-256,合计约 15.73 MB,不再下载官方完整 ZIP。构建环境需要能够访问 raw.githubusercontent.com。
|
|
||||||
|
|
||||||
## runtime-7.4:文档工具、中文字体与 Chat Manager 修复
|
|
||||||
|
|
||||||
runtime-7.4 修订内容:新增 Python、LibreOffice、Noto CJK 与 MiSans,并内置修复版 Chat Manager。DSH 版本仍为 0.2.0-rc.2,运行布局仍为 7,继续保留 runtime-7.3 的域名设置适配。已完成本地 AMD64 构建、19 项测试、离线启动及插件接口认证拒绝检查;ARM64 与线上面板完整删除流程尚未验收。
|
|
||||||
|
|
||||||
## 内置修复版 Chat Manager
|
|
||||||
|
|
||||||
镜像内置 `[email protected]`(基于官方 1.5.12,保留 MIT 许可及服务端安全校验)。补丁仅针对客户端:删除、恢复、归档搜索在面板内使用当前 `/dsh/<实例ID>/` 前缀,独立 DSH 根路径部署保持原行为;非 JSON 响应显示 HTTP 状态和代理排查提示,不再直接暴露 JSON 解析异常。不会修改全局 fetch、绕过 Cookie/Origin/可信主机校验或自动重试删除请求。
|
|
||||||
|
|
||||||
新建空 web profile 默认启用此插件,内置包和 clsx 依赖均支持离线安装。已有 profile 不会被覆盖,也不会自动替换用户已安装的官方版本。升级镜像后,如需迁移已有实例,请保留其他设置,将 profile 中同名依赖改为 `file:/usr/local/lib/dsh-mgr/dsh-chat-manager-1.5.12-dsh-mgr.1.tgz`,并设置 pnpm override `dsh-chat-manager>clsx` 为 `file:/usr/local/lib/dsh-mgr/clsx-2.1.1.tgz`,确保 bundles 中只有一次 `dsh-chat-manager`,再重启实例。不要同时启用重复的手动 patch 插件条目。
|
|
||||||
|
|
||||||
域名访问仍要求 DSH 配置相应 trusted host,且 runtime 必须经过面板认证与实例归属校验。此修复不开放面板根路径的插件接口。
|
|
||||||
|
|
||||||
## 内置 Python 与 LibreOffice
|
|
||||||
|
|
||||||
Runtime 镜像包含 Debian Bookworm 的 Python 3、pip、venv(`python` 指向 `python3`),以及 LibreOffice 和 Noto CJK 中文字体。LibreOffice 可通过 `libreoffice --headless` 无界面运行。Python 项目依赖请安装到工作区的虚拟环境中,不要覆盖系统 Python 包。
|
|
||||||
|
|
||||||
这些依赖在镜像构建时安装;已有实例需要重建镜像并升级后才生效。
|
|
||||||
|
|
||||||
另包含小米 MiSans 简体中文字体的 Regular 和 Bold 两个静态 TTF 字重,保留 Noto CJK,不修改默认字体优先级。构建时从第三方镜像 boyan01/mi_sans_font 的固定提交 68f3ec3fee5830ce8a1d955d462fa0df401a5341 下载两个 TTF 并校验各自 SHA-256;镜像文件与当前官网 ZIP 不同,不保证为最新版本,GitHub 仓库许可不替代小米字体许可。字体及声明位于 `/usr/local/share/fonts/misans`。MiSans 使用小米专有免费商用许可,并非开源字体许可;安装使用及对外分发镜像前应阅读[官方许可协议](https://hyperos.mi.com/font/zh/download/),不得单独分发或售卖字体。
|
|
||||||
|
|
||||||
## runtime-7.3:域名访问设置适配
|
|
||||||
|
|
||||||
自 runtime-7.3 起,构建时对 DSH 0.2.0-rc.2 的设置 UI 应用精确补丁,使设置视图与表单使用 host 持久化;不修改 connection.isLoopback、可信主机、Origin 或认证逻辑。版本或目标语句不匹配时构建失败,升级上游版本必须重新审核补丁。
|
|
||||||
|
|
||||||
这是构建期适配,不是普通插件。补丁本身不识别面板登录状态,该镜像的设置 UI 会对所有访问地址尝试远程设置操作,因此只能部署在受认证、校验实例归属的面板入口之后,不得绕过认证直接公开 runtime 端口。权限由服务端保证。
|
|
||||||
|
|
||||||
发布前须在非 localhost 域名验证提供商目录、配置与凭据保存、刷新和重启持久化,以及未登录、跨用户和非法 Origin 的拒绝行为。单元测试不代替这些验收;现有实例需升级镜像才生效,不覆盖用户 profile 或创建空 patch。
|
|
||||||
|
|
||||||
`dockerfile` 分支只维护发布清单和 runtime 镜像构建文件,不包含管理面板源码或部署配置。
|
|
||||||
|
|
||||||
## 发布清单
|
|
||||||
|
|
||||||
`release.json` 是发布入口:`schema_version` 为清单格式版本,`release_id` 标识一次发布,`version` 为精确 DSH 版本,`deployment_schema` 为运行布局版本,`platforms` 为支持的平台。当前待验收配置为 `runtime-7.11`、DSH `0.2.1-rc.5`、布局 7、`linux/amd64` 和 `linux/arm64`(aarch64,不含 32 位 ARM)。
|
|
||||||
|
|
||||||
ARM64 验收状态:已确认 Node/Caddy 基础镜像提供 ARM64 版本,但尚未完成 ARM64 runtime 构建及运行验收。清单声明会开放面板的对应平台构建入口,不等于兼容性证明;验收完成前不要推送发布。
|
|
||||||
|
|
||||||
此分支是可信代码来源,不是仅供读取的数据源:Dockerfile 和安装脚本会在构建时执行。只使用经过审核的提交,不要将不可信分支、PR 或任意远端内容直接用于构建。清单本身不代表镜像已经完成运行验收。
|
|
||||||
|
|
||||||
## 本地构建与检查
|
|
||||||
|
|
||||||
需要 Docker、Node.js,以及拉取基础镜像和构建依赖的网络。从仓库根目录执行,DSH 版本取自清单,平台按 Docker daemon 架构选择,不跟踪 latest:
|
|
||||||
|
|
||||||
~~~sh
|
|
||||||
set -eu
|
|
||||||
DSH_VERSION="$(node -p "require('./release.json').version")"
|
|
||||||
case "$(docker info --format '{{.Architecture}}')" in
|
|
||||||
aarch64|arm64) PLATFORM=linux/arm64 ;;
|
|
||||||
x86_64|amd64) PLATFORM=linux/amd64 ;;
|
|
||||||
*) echo 'Unsupported Docker daemon architecture' >&2; exit 1 ;;
|
|
||||||
esac
|
|
||||||
RELEASE_ID="$(node -p "require('./release.json').release_id")"
|
|
||||||
node --test image/*.test.mjs
|
|
||||||
docker build --platform "$PLATFORM" \
|
|
||||||
--build-arg DSH_VERSION="$DSH_VERSION" \
|
|
||||||
-t "dsh-web:$RELEASE_ID-${PLATFORM#linux/}" ./image
|
|
||||||
~~~
|
|
||||||
|
|
||||||
默认按 Docker daemon 架构原生构建,不再固定取清单第一个平台。在交叉构建环境可手动将 PLATFORM 设置为 linux/arm64 或 linux/amd64;需要事先配置对应原生 builder 或 QEMU/binfmt,仅设置 --platform 不会自动提供模拟能力。
|
|
||||||
|
|
||||||
Dockerfile 两个阶段均继承目标平台,无需写死架构;不要将 Caddy 阶段固定为构建主机平台,也不要复用另一架构的 node_modules。
|
|
||||||
|
|
||||||
发布前分别验证两个架构的镜像构建、非 root 启动、代理认证、终端及原生模块、内置插件离线协调、工作区写入和重启持久化,以及升级/恢复路径。迁移工作区时需重新安装其中的架构相关依赖。
|
|
||||||
|
|
||||||
`image/` 是构建上下文。构建只生成本地镜像,不推送镜像,也不自动发布分支。保留 `image/init-profile.test.mjs` 作为独立 runtime 回归测试,覆盖初始化幂等性、保留已有配置和拒绝符号链接目录;它不依赖面板,且被 `image/.dockerignore` 排除在构建上下文之外。此测试不能替代真实容器启动、插件和持久化验收。
|
|
||||||
|
|
||||||
## 布局 7 合约
|
|
||||||
|
|
||||||
- 以 UID/GID 1000 运行;实例持久目录挂载到 `/workspace`,必须允许该身份读写。`DSH_HOME`、`HOME` 和工作目录均为 `/workspace`,XDG 数据与配置也位于其中。
|
|
||||||
- `/data/dsh-home` 是指向 `/workspace` 的兼容符号链接;不要用旧 home 挂载覆盖该路径。`/shared` 不是布局 7 的持久化入口。
|
|
||||||
- DSH 监听 loopback 3080,Caddy 在容器 8080 转发;只通过受信入口访问,不直接公开 runtime 端口,不挂载 Docker socket。原生模块需要可执行的 `/tmp`,可保留 nosuid/nodev 与容量限制。
|
|
||||||
- 新 profile 默认启用固定版本的 mobile、free-search 和 workspace bundle,默认工作区为 `/workspace`。已有非空 profile 不自动注入或覆盖;升级镜像不会强制改变已有插件选择。
|
|
||||||
- 启动前协调插件依赖,失败则不启动应用;仅含内置固定依赖的 profile 使用离线安装。用户添加插件后可能需要网络。
|
|
||||||
|
|
||||||
## 手动发布
|
|
||||||
|
|
||||||
1. 审核 runtime 资产变更及其可信来源,选择精确 DSH 版本,更新 `release.json`。每次发布变更都必须使用新的 `release_id`;布局合约变化时同步更新 `deployment_schema`、镜像及调用方适配,不能只修改数字。
|
|
||||||
2. 运行上述测试与本地构建,并完成目标平台的容器启动、插件、持久化与兼容验收;检查 `git diff --check` 和最终差异。
|
|
||||||
3. 维护者确认后,**手动提交并手动推送** `dockerfile` 分支到已核实的发布远端。修改清单或本地构建不会自动 commit/push,也不会自动更新运行中的实例。
|
|
||||||
|
|
||||||
## 文件范围
|
|
||||||
|
|
||||||
- `release.json`:发布清单。
|
|
||||||
- `README.md`:本分支说明。
|
|
||||||
- `image/Dockerfile`、`image/Caddyfile`、`image/entrypoint.sh`、`image/.dockerignore`:镜像构建与启动。
|
|
||||||
- `image/install-*.mjs`、`image/init-profile.mjs`:内置 runtime 资产安装与 profile 初始化。
|
|
||||||
- `image/init-profile.test.mjs`:保留的 profile 初始化回归测试。
|
|
||||||
- `image/entrypoint.test.mjs`:从面板测试迁移的 runtime 启动失败保护与镜像约束测试,需要 Bash;不依赖 Go 或面板。
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
.git
|
|
||||||
**/.git
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
**/.env
|
|
||||||
**/.env.*
|
|
||||||
node_modules
|
|
||||||
**/node_modules
|
|
||||||
*.test.mjs
|
|
||||||
embed.go
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
admin off
|
|
||||||
auto_https off
|
|
||||||
}
|
|
||||||
:8080 {
|
|
||||||
reverse_proxy 127.0.0.1:3080
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
FROM caddy:2.10.2 AS proxy
|
|
||||||
FROM node:24-bookworm-slim
|
|
||||||
ARG DSH_VERSION
|
|
||||||
# Keep the historical dependency-layer cache key; the template revision is set below.
|
|
||||||
ARG RECIPE_REV=3
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
tini ca-certificates git procps \
|
|
||||||
python3 python3-pip python3-venv python-is-python3 \
|
|
||||||
libreoffice fonts-noto-cjk \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
RUN python --version && python3 -m pip --version && libreoffice --headless --version
|
|
||||||
RUN test -n "$DSH_VERSION" && npm install --global --foreground-scripts --loglevel=verbose --registry=https://registry.npmjs.org "@deepseek-ai/dsh@${DSH_VERSION}"
|
|
||||||
RUN npm install --global --ignore-scripts --registry=https://registry.npmjs.org [email protected] && test "$(pnpm --version)" = "10.11.0"
|
|
||||||
COPY install-mobile.mjs /usr/local/lib/dsh-mgr/install-mobile.mjs
|
|
||||||
RUN node /usr/local/lib/dsh-mgr/install-mobile.mjs
|
|
||||||
COPY install-free-search.mjs /usr/local/lib/dsh-mgr/install-free-search.mjs
|
|
||||||
RUN node /usr/local/lib/dsh-mgr/install-free-search.mjs
|
|
||||||
COPY install-chat-manager.mjs patch-chat-manager.mjs /usr/local/lib/dsh-mgr/
|
|
||||||
RUN node /usr/local/lib/dsh-mgr/install-chat-manager.mjs
|
|
||||||
COPY install-workspace.mjs /usr/local/lib/dsh-mgr/install-workspace.mjs
|
|
||||||
RUN node /usr/local/lib/dsh-mgr/install-workspace.mjs
|
|
||||||
COPY init-profile.mjs reconcile-profile.mjs /usr/local/lib/dsh-mgr/
|
|
||||||
COPY install-host-settings.mjs /usr/local/lib/dsh-mgr/install-host-settings.mjs
|
|
||||||
RUN node /usr/local/lib/dsh-mgr/install-host-settings.mjs
|
|
||||||
COPY --from=proxy /usr/bin/caddy /usr/bin/caddy
|
|
||||||
# Strip upstream file capabilities without granting any runtime capabilities.
|
|
||||||
# A fresh file copy omits security.capability xattrs; port 8080 needs none.
|
|
||||||
RUN cp /usr/bin/caddy /usr/bin/caddy.clean && mv /usr/bin/caddy.clean /usr/bin/caddy
|
|
||||||
COPY Caddyfile /etc/caddy/Caddyfile
|
|
||||||
COPY entrypoint.sh /usr/local/bin/dsh-entrypoint
|
|
||||||
RUN chmod 755 /usr/local/bin/dsh-entrypoint && mkdir -p /data /workspace /shared && ln -s /workspace /data/dsh-home && chown -R node:node /data /workspace /shared
|
|
||||||
COPY install-misans.mjs /usr/local/lib/dsh-mgr/install-misans.mjs
|
|
||||||
RUN node /usr/local/lib/dsh-mgr/install-misans.mjs
|
|
||||||
COPY MiSans-NOTICE.txt /usr/local/share/fonts/misans/NOTICE.txt
|
|
||||||
COPY install-office-preview.mjs native-office.mjs validate-office.py office-worker.py /usr/local/lib/dsh-mgr/
|
|
||||||
RUN node /usr/local/lib/dsh-mgr/install-office-preview.mjs
|
|
||||||
COPY preview-download /usr/local/lib/dsh-mgr/preview-download
|
|
||||||
COPY install-preview-download.mjs /usr/local/lib/dsh-mgr/install-preview-download.mjs
|
|
||||||
RUN node /usr/local/lib/dsh-mgr/install-preview-download.mjs
|
|
||||||
ARG RECIPE_REV=7
|
|
||||||
LABEL dsh-mgr.role=runtime dsh-mgr.version=$DSH_VERSION dsh-mgr.recipe=$RECIPE_REV
|
|
||||||
ENV DSH_HOME=/workspace HOME=/workspace XDG_DATA_HOME=/workspace/.local/share XDG_CONFIG_HOME=/workspace/.config
|
|
||||||
USER node
|
|
||||||
WORKDIR /workspace
|
|
||||||
EXPOSE 8080
|
|
||||||
ENTRYPOINT ["/usr/bin/tini", "-g", "--", "/usr/local/bin/dsh-entrypoint"]
|
|
||||||
CMD ["web", "--no-open", "--port", "3080"]
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
This runtime uses MiSans fonts by Xiaomi.
|
|
||||||
Official source: https://hyperos.mi.com/font/zh/download/
|
|
||||||
Download mirror (third-party, not an official Xiaomi repository):
|
|
||||||
https://github.com/boyan01/mi_sans_font/tree/68f3ec3fee5830ce8a1d955d462fa0df401a5341/lib/fonts
|
|
||||||
Files: MiSans-Regular.ttf and MiSans-Bold.ttf
|
|
||||||
These mirrored files differ from the current official ZIP; they are not claimed
|
|
||||||
to be the latest release. The mirror repository's license does not replace the
|
|
||||||
Xiaomi font license.
|
|
||||||
|
|
||||||
MiSans is subject to the MiSans Fonts Intellectual Property License Agreement
|
|
||||||
published on the official download page. It is not an open-source font license.
|
|
||||||
Installation and use indicate acceptance of that agreement. Do not modify the
|
|
||||||
fonts or distribute/sell them separately. Review the complete agreement before
|
|
||||||
redistributing a runtime image containing these fonts.
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -Eeuo pipefail
|
|
||||||
|
|
||||||
export npm_config_ignore_scripts=true npm_config_ignore_pnpmfile=true
|
|
||||||
node /usr/local/lib/dsh-mgr/init-profile.mjs
|
|
||||||
if [[ -f "${DSH_HOME}/profiles/web/package.json" ]]; then
|
|
||||||
echo "dsh-mgr: plugin prestart reconciliation starting" >&2
|
|
||||||
# pnpm otherwise probes registry metadata for the overlapping optional peer.
|
|
||||||
# Restrict offline mode to profiles containing only our fixed archives.
|
|
||||||
install_env=()
|
|
||||||
if node -e 'import("/usr/local/lib/dsh-mgr/reconcile-profile.mjs").then(({BUILTIN_DEPENDENCIES:pins,BUILTIN_OVERRIDES:overrides})=>{const m=require(process.env.DSH_HOME+"/profiles/web/package.json"); process.exit([m.dependencies,m.devDependencies,m.optionalDependencies].every(section=>Object.entries(section||{}).every(([n,v])=>pins[n]===v))&&Object.entries(m.pnpm?.overrides||{}).every(([n,v])=>overrides[n]===v)?0:1)})'; then
|
|
||||||
install_env=(npm_config_offline=true)
|
|
||||||
fi
|
|
||||||
# Only migration installs may refresh the lockfile. Keep the marker on failure
|
|
||||||
# so a restart retries even when package.json already contains the new pins.
|
|
||||||
pending_lockfile="${DSH_HOME}/profiles/web/.dsh-lockfile-pending"
|
|
||||||
install_args=()
|
|
||||||
if [[ -f "$pending_lockfile" ]]; then
|
|
||||||
install_args+=(--no-frozen-lockfile)
|
|
||||||
fi
|
|
||||||
# Hide routine progress/peer warnings, but replay diagnostics on any failure.
|
|
||||||
if install_output=$(timeout --signal=TERM --kill-after=5s 120s env CI=true "${install_env[@]}" dsh plugin --profile web install --ignore-scripts "${install_args[@]}" 2>&1); then
|
|
||||||
rm -f -- "$pending_lockfile"
|
|
||||||
echo "dsh-mgr: plugin prestart reconciliation complete" >&2
|
|
||||||
else
|
|
||||||
status=$?
|
|
||||||
printf '%s\n' "$install_output" >&2
|
|
||||||
echo "dsh-mgr: plugin prestart reconciliation failed (exit $status); application not started" >&2
|
|
||||||
exit "$status"
|
|
||||||
fi
|
|
||||||
unset install_output
|
|
||||||
else
|
|
||||||
echo "dsh-mgr: no web plugin manifest; using bundled plugins" >&2
|
|
||||||
fi
|
|
||||||
|
|
||||||
caddy run --config /etc/caddy/Caddyfile --adapter caddyfile &
|
|
||||||
proxy_pid=$!
|
|
||||||
echo
|
|
||||||
dsh "$@" &
|
|
||||||
dsh_pid=$!
|
|
||||||
cleanup() {
|
|
||||||
trap - TERM INT
|
|
||||||
kill -TERM "$proxy_pid" "$dsh_pid" 2>/dev/null || true
|
|
||||||
wait "$proxy_pid" 2>/dev/null || true
|
|
||||||
wait "$dsh_pid" 2>/dev/null || true
|
|
||||||
}
|
|
||||||
trap cleanup EXIT
|
|
||||||
trap 'exit 143' TERM
|
|
||||||
trap 'exit 130' INT
|
|
||||||
# Either child exiting terminates the container; never leave a proxy-only instance.
|
|
||||||
set +e
|
|
||||||
wait -n "$proxy_pid" "$dsh_pid"
|
|
||||||
status=$?
|
|
||||||
set -e
|
|
||||||
exit "$status"
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync, existsSync } from 'node:fs';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import { spawnSync } from 'node:child_process';
|
|
||||||
|
|
||||||
for (const status of ['0', '23', '124', '137', 'empty']) {
|
|
||||||
for (const pending of [false, true]) {
|
|
||||||
test('runtime prestart status ' + status + ', pending migration ' + pending, () => {
|
|
||||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-prestart-'));
|
|
||||||
try {
|
|
||||||
if (status !== 'empty') {
|
|
||||||
mkdirSync(join(dir, 'profiles/web'), { recursive: true, mode: 0o700 });
|
|
||||||
writeFileSync(join(dir, 'profiles/web/package.json'), '{}', { mode: 0o600 });
|
|
||||||
if (pending) writeFileSync(join(dir, 'profiles/web/.dsh-lockfile-pending'), 'pending');
|
|
||||||
}
|
|
||||||
const scripts = {
|
|
||||||
node: `#!/bin/bash
|
|
||||||
exit 0
|
|
||||||
`,
|
|
||||||
timeout: `#!/bin/bash
|
|
||||||
printf 'timeout %s\n' "$*" >> "$TRACE"
|
|
||||||
test "$1 $2 $3" = "--signal=TERM --kill-after=5s 120s" || exit 90
|
|
||||||
shift 3
|
|
||||||
exec "$@"
|
|
||||||
`,
|
|
||||||
dsh: `#!/bin/bash
|
|
||||||
printf 'dsh %s\n' "$*" >> "$TRACE"
|
|
||||||
test "$npm_config_ignore_scripts:$npm_config_ignore_pnpmfile" = true:true || exit 91
|
|
||||||
if [ "$1" = plugin ]; then
|
|
||||||
echo 'install progress and peer warnings'
|
|
||||||
echo 'install stderr diagnostics' >&2
|
|
||||||
test "$*" = "plugin --profile web install --ignore-scripts$EXPECTED_ARGS" || exit 92
|
|
||||||
exit "$PLUGIN_STATUS"
|
|
||||||
fi
|
|
||||||
exit 0
|
|
||||||
`,
|
|
||||||
caddy: `#!/bin/bash
|
|
||||||
echo caddy >> "$TRACE"
|
|
||||||
sleep 0.1
|
|
||||||
`,
|
|
||||||
};
|
|
||||||
for (const [name, script] of Object.entries(scripts)) {
|
|
||||||
writeFileSync(join(dir, name), script, { mode: 0o700 });
|
|
||||||
}
|
|
||||||
const result = spawnSync('bash', [fileURLToPath(new URL('./entrypoint.sh', import.meta.url)), 'web', '--no-open', '--port', '3080'], {
|
|
||||||
env: { ...process.env, PATH: dir + ':' + process.env.PATH, DSH_HOME: dir, TRACE: join(dir, 'trace'), PLUGIN_STATUS: status, EXPECTED_ARGS: pending ? ' --no-frozen-lockfile' : '' },
|
|
||||||
encoding: 'utf8', timeout: 5000,
|
|
||||||
});
|
|
||||||
assert.ifError(result.error);
|
|
||||||
const output = result.stdout + result.stderr;
|
|
||||||
const trace = readFileSync(join(dir, 'trace'), 'utf8');
|
|
||||||
assert.equal(result.status, status === 'empty' ? 0 : Number(status), output);
|
|
||||||
if (status === 'empty') {
|
|
||||||
assert.doesNotMatch(trace, /plugin/);
|
|
||||||
assert.match(trace, /dsh web/);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
assert.match(trace, /dsh plugin --profile web install --ignore-scripts/);
|
|
||||||
assert.equal(existsSync(join(dir, 'profiles/web/.dsh-lockfile-pending')), pending && status !== '0');
|
|
||||||
if (status === '0') {
|
|
||||||
assert.doesNotMatch(output, /install progress|install stderr/);
|
|
||||||
assert.match(output, /reconciliation complete/);
|
|
||||||
assert.match(trace, /dsh web/);
|
|
||||||
} else {
|
|
||||||
assert.match(result.stderr, /install progress and peer warnings/);
|
|
||||||
assert.match(result.stderr, /install stderr diagnostics/);
|
|
||||||
assert.ok(output.includes('failed (exit ' + status + ')'), output);
|
|
||||||
assert.doesNotMatch(trace, /caddy|dsh web/);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
rmSync(dir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test('runtime pins pnpm and uses nonroot with tini process-group forwarding', () => {
|
|
||||||
const dockerfile = readFileSync(new URL('./Dockerfile', import.meta.url), 'utf8');
|
|
||||||
for (const required of ['[email protected]', 'test "$(pnpm --version)" = "10.11.0"', 'USER node', '"/usr/bin/tini", "-g"']) {
|
|
||||||
assert.ok(dockerfile.includes(required), 'missing ' + required);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { mkdirSync, existsSync, writeFileSync, readFileSync, readdirSync } from 'node:fs';
|
|
||||||
import { join, resolve } from 'node:path';
|
|
||||||
import { pathToFileURL } from 'node:url';
|
|
||||||
import { BUILTIN_DEPENDENCIES, BUILTIN_OVERRIDES, assertSafeProfile, reconcileProfile, markLockfilePending } from './reconcile-profile.mjs';
|
|
||||||
export function initProfile(home, options) {
|
|
||||||
if (!home) throw new Error('DSH_HOME required');
|
|
||||||
const profile = join(home, 'profiles/web');
|
|
||||||
assertSafeProfile(profile);
|
|
||||||
if (!existsSync(profile) || readdirSync(profile).length === 0) {
|
|
||||||
mkdirSync(profile, { recursive: true });
|
|
||||||
writeFileSync(join(profile, 'package.json'), JSON.stringify({
|
|
||||||
name: 'dsh-profile-web', private: true,
|
|
||||||
dependencies: BUILTIN_DEPENDENCIES,
|
|
||||||
pnpm: { overrides: BUILTIN_OVERRIDES },
|
|
||||||
dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', 'dsh-web-mobile', 'dsh-free-search', 'dsh-mgr-workspace', 'dsh-chat-manager', 'dsh-preview-download'] } },
|
|
||||||
}, null, 2), { flag: 'wx', mode: 0o600 });
|
|
||||||
console.log('dsh-mgr: new web profile includes [email protected] and [email protected] and [email protected]');
|
|
||||||
}
|
|
||||||
const migration = reconcileProfile(profile, options);
|
|
||||||
console.log(migration.changed
|
|
||||||
? "dsh-mgr: builtin profile updated; original saved to " + migration.backupPath
|
|
||||||
: "dsh-mgr: builtin profile check complete; no changes");
|
|
||||||
const manifestPath = join(profile, 'package.json');
|
|
||||||
const workspacePath = join(profile, 'pnpm-workspace.yaml');
|
|
||||||
// Backups omit workspace settings; restore host-provided peers before pnpm runs.
|
|
||||||
if (existsSync(manifestPath) && !existsSync(workspacePath)) {
|
|
||||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
||||||
if (['dependencies', 'devDependencies', 'optionalDependencies'].some(section =>
|
|
||||||
['dsh-chat-manager', 'dsh-web-mobile', 'dsh-free-search'].some(name => manifest[section]?.[name] === BUILTIN_DEPENDENCIES[name]))) {
|
|
||||||
markLockfilePending(profile);
|
|
||||||
writeFileSync(workspacePath, 'packages:\n - .\n\nnodeLinker: hoisted\nautoInstallPeers: false\n', { flag: 'wx', mode: 0o600 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
|
||||||
initProfile(process.env.DSH_HOME);
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync, symlinkSync, existsSync } from 'node:fs';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join, basename } from 'node:path';
|
|
||||||
import { execFileSync } from 'node:child_process';
|
|
||||||
import { initProfile } from './init-profile.mjs';
|
|
||||||
import { BUILTIN_DEPENDENCIES, BUILTIN_OVERRIDES } from './reconcile-profile.mjs';
|
|
||||||
function archives(home) {
|
|
||||||
const archiveDirectory = join(home, 'archives');
|
|
||||||
mkdirSync(archiveDirectory, { recursive: true });
|
|
||||||
for (const value of [...Object.values(BUILTIN_DEPENDENCIES), ...Object.values(BUILTIN_OVERRIDES)]) {
|
|
||||||
writeFileSync(join(archiveDirectory, basename(value)), 'fixture');
|
|
||||||
}
|
|
||||||
return { archiveDirectory };
|
|
||||||
}
|
|
||||||
test('new profile enables bundle once and preserves existing configuration', () => {
|
|
||||||
const home = mkdtempSync(join(tmpdir(), 'dsh-plugin-init-'));
|
|
||||||
try {
|
|
||||||
const options = archives(home);
|
|
||||||
const run = () => initProfile(home, options);
|
|
||||||
run();
|
|
||||||
const file=join(home,'profiles/web/package.json');
|
|
||||||
const original=readFileSync(file,'utf8');
|
|
||||||
assert.equal(JSON.parse(original).dependencies['dsh-preview-download'], 'file:/usr/local/lib/dsh-mgr/dsh-preview-download-1.0.1.tgz');
|
|
||||||
assert.equal(JSON.parse(original).dsh.profile.bundles.filter(name => name === 'dsh-preview-download').length, 1);
|
|
||||||
assert.ok(JSON.parse(original).dsh.profile.bundles.includes('dsh-chat-manager'));
|
|
||||||
assert.equal(JSON.parse(original).dependencies['dsh-chat-manager'], 'file:/usr/local/lib/dsh-mgr/dsh-chat-manager-1.5.20-dsh-mgr.1.tgz');
|
|
||||||
assert.equal(JSON.parse(original).pnpm.overrides['dsh-chat-manager>clsx'], 'file:/usr/local/lib/dsh-mgr/clsx-2.1.1.tgz');
|
|
||||||
assert.ok(JSON.parse(original).dsh.profile.bundles.includes('dsh-web-mobile'));
|
|
||||||
assert.ok(JSON.parse(original).dsh.profile.bundles.includes('dsh-free-search'));
|
|
||||||
assert.ok(JSON.parse(original).dsh.profile.bundles.includes('dsh-mgr-workspace'));
|
|
||||||
assert.equal(JSON.parse(original).dependencies['dsh-mgr-workspace'], 'file:/usr/local/lib/dsh-mgr/dsh-mgr-workspace-1.0.0.tgz');
|
|
||||||
assert.equal(JSON.parse(original).dependencies['dsh-free-search'], 'file:/usr/local/lib/dsh-mgr/dsh-free-search-0.9.21.tgz');
|
|
||||||
assert.equal(JSON.parse(original).pnpm.overrides['dsh-free-search>@deepseek-ai/schemastery'], 'file:/usr/local/lib/dsh-mgr/schemastery-3.18.4.tgz');
|
|
||||||
assert.equal(JSON.parse(original).dependencies['dsh-web-mobile'], 'file:/usr/local/lib/dsh-mgr/dsh-web-mobile-3.2.1.tgz');
|
|
||||||
const workspace = join(home, 'profiles/web/pnpm-workspace.yaml');
|
|
||||||
assert.match(readFileSync(workspace, 'utf8'), /autoInstallPeers: false/);
|
|
||||||
assert.match(readFileSync(workspace, 'utf8'), /nodeLinker: hoisted/);
|
|
||||||
run();assert.equal(readFileSync(file,'utf8'),original);
|
|
||||||
const legacy = JSON.parse(original);
|
|
||||||
delete legacy.dependencies['dsh-mgr-workspace'];
|
|
||||||
delete legacy.dependencies['dsh-chat-manager'];
|
|
||||||
delete legacy.pnpm.overrides['dsh-chat-manager>clsx'];
|
|
||||||
legacy.dsh.profile.bundles = legacy.dsh.profile.bundles.filter(name => name !== 'dsh-chat-manager');
|
|
||||||
legacy.dsh.profile.bundles = legacy.dsh.profile.bundles.filter(name => name !== 'dsh-mgr-workspace');
|
|
||||||
writeFileSync(file, JSON.stringify(legacy));
|
|
||||||
run();
|
|
||||||
assert.deepEqual(JSON.parse(readFileSync(file)), legacy);
|
|
||||||
const disabled = JSON.parse(original);
|
|
||||||
disabled.dsh.profile.bundles = disabled.dsh.profile.bundles.filter(name => name !== 'dsh-chat-manager');
|
|
||||||
disabled.dsh.profile.bundles = disabled.dsh.profile.bundles.filter(name => name !== 'dsh-web-mobile');
|
|
||||||
writeFileSync(file, JSON.stringify(disabled));
|
|
||||||
writeFileSync(workspace, 'custom workspace');
|
|
||||||
const patch = join(home, 'profiles/web/cordis.patch.yml');
|
|
||||||
writeFileSync(patch, '[]');
|
|
||||||
run();
|
|
||||||
assert.deepEqual(JSON.parse(readFileSync(file)), disabled);
|
|
||||||
assert.equal(readFileSync(workspace, 'utf8'), 'custom workspace');
|
|
||||||
assert.equal(readFileSync(patch, 'utf8'), '[]');
|
|
||||||
const custom=JSON.stringify({custom:true});
|
|
||||||
writeFileSync(file,custom);run();assert.equal(readFileSync(file,'utf8'),custom);
|
|
||||||
} finally { rmSync(home,{recursive:true,force:true}); }
|
|
||||||
});
|
|
||||||
test('chat-manager-only restored profile gets overrides and offline peer settings', () => {
|
|
||||||
const home = mkdtempSync(join(tmpdir(), 'dsh-chat-restore-'));
|
|
||||||
try {
|
|
||||||
const profile = join(home, 'profiles/web');
|
|
||||||
mkdirSync(profile, { recursive: true });
|
|
||||||
const manifest = JSON.stringify({ dependencies: { 'dsh-chat-manager': 'file:/usr/local/lib/dsh-mgr/dsh-chat-manager-1.5.20-dsh-mgr.1.tgz' } });
|
|
||||||
writeFileSync(join(profile, 'package.json'), manifest);
|
|
||||||
initProfile(home, archives(home));
|
|
||||||
assert.deepEqual(JSON.parse(readFileSync(join(profile, 'package.json'))), { ...JSON.parse(manifest), pnpm: { overrides: { 'dsh-chat-manager>clsx': BUILTIN_OVERRIDES['dsh-chat-manager>clsx'] } } });
|
|
||||||
assert.match(readFileSync(join(profile, 'pnpm-workspace.yaml'), 'utf8'), /autoInstallPeers: false/);
|
|
||||||
const entrypoint = readFileSync(new URL('./entrypoint.sh', import.meta.url), 'utf8');
|
|
||||||
const predicate = entrypoint.match(/if node -e '([^']+)'/)[1].replace('/usr/local/lib/dsh-mgr/reconcile-profile.mjs', new URL('./reconcile-profile.mjs', import.meta.url).href);
|
|
||||||
execFileSync(process.execPath, ['-e', predicate], { env: { ...process.env, DSH_HOME: home } });
|
|
||||||
for (const custom of [
|
|
||||||
{ dependencies: { 'dsh-chat-manager': BUILTIN_DEPENDENCIES['dsh-chat-manager'] }, pnpm: { overrides: { 'dsh-chat-manager>clsx': '^2.0.0' } } },
|
|
||||||
{ dependencies: { 'dsh-chat-manager': '^1.5.16' }, optionalDependencies: { 'dsh-chat-manager': BUILTIN_DEPENDENCIES['dsh-chat-manager'] } },
|
|
||||||
]) {
|
|
||||||
writeFileSync(join(profile, 'package.json'), JSON.stringify(custom));
|
|
||||||
assert.throws(() => execFileSync(process.execPath, ['-e', predicate], { env: { ...process.env, DSH_HOME: home }, stdio: 'pipe' }));
|
|
||||||
}
|
|
||||||
writeFileSync(join(profile, 'package.json'), JSON.stringify({ dependencies: { 'dsh-chat-manager': '^1.5.16' } }));
|
|
||||||
assert.throws(() => execFileSync(process.execPath, ['-e', predicate], { env: { ...process.env, DSH_HOME: home }, stdio: 'pipe' }));
|
|
||||||
} finally { rmSync(home, { recursive: true, force: true }); }
|
|
||||||
});
|
|
||||||
test('nonempty legacy profiles are not automatically injected', () => {
|
|
||||||
const home = mkdtempSync(join(tmpdir(), 'dsh-plugin-legacy-'));
|
|
||||||
try {
|
|
||||||
const profile = join(home, 'profiles/web');
|
|
||||||
mkdirSync(profile, { recursive: true });
|
|
||||||
writeFileSync(join(profile, 'cordis.patch.yml'), '[]');
|
|
||||||
initProfile(home, archives(home));
|
|
||||||
assert.equal(existsSync(join(profile, 'package.json')), false);
|
|
||||||
assert.equal(existsSync(join(profile, 'pnpm-workspace.yaml')), false);
|
|
||||||
} finally { rmSync(home, { recursive: true, force: true }); }
|
|
||||||
});
|
|
||||||
test('rejects symlinked profile ancestors', () => {
|
|
||||||
const home=mkdtempSync(join(tmpdir(),'dsh-plugin-init-'));
|
|
||||||
const outside=mkdtempSync(join(tmpdir(),'dsh-plugin-outside-'));
|
|
||||||
try {
|
|
||||||
symlinkSync(outside,join(home,'profiles'));
|
|
||||||
assert.throws(()=>execFileSync(process.execPath,[new URL('./init-profile.mjs',import.meta.url).pathname],{env:{...process.env,DSH_HOME:home},stdio:'pipe'}));
|
|
||||||
assert.equal(existsSync(join(outside,'web/package.json')),false);
|
|
||||||
} finally {rmSync(home,{recursive:true,force:true});rmSync(outside,{recursive:true,force:true});}
|
|
||||||
});
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import { createHash } from 'node:crypto';
|
|
||||||
import { mkdirSync, writeFileSync, readFileSync, readdirSync } from 'node:fs';
|
|
||||||
import { execFileSync } from 'node:child_process';
|
|
||||||
import { createRequire } from 'node:module';
|
|
||||||
import { patchClient } from './patch-chat-manager.mjs';
|
|
||||||
|
|
||||||
const root = '/usr/local/lib/dsh-mgr';
|
|
||||||
const version = '1.5.20';
|
|
||||||
const patchedVersion = '1.5.20-dsh-mgr.1';
|
|
||||||
const hostManifest = '/usr/local/lib/node_modules/@deepseek-ai/dsh/package.json';
|
|
||||||
const destination = '/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/dsh-chat-manager';
|
|
||||||
const packages = [
|
|
||||||
['dsh-chat-manager', version, 'https://registry.npmjs.org/dsh-chat-manager/-/dsh-chat-manager-1.5.20.tgz', 'V/7N48lJioxBr2h+hmVnNoZB5jjoT/EGC6Pk8pSbNJveQoHye+zBj3tUX1Xf520YyopVk5fSVpt+aeCkBBVxdA=='],
|
|
||||||
['clsx', '2.1.1', 'https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz', 'eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=='],
|
|
||||||
];
|
|
||||||
mkdirSync(root, { recursive: true });
|
|
||||||
for (const [name, release, url, integrity] of packages) {
|
|
||||||
const response = await fetch(url, { signal: AbortSignal.timeout(120000) });
|
|
||||||
if (!response.ok) throw new Error('download failed: ' + name);
|
|
||||||
const data = Buffer.from(await response.arrayBuffer());
|
|
||||||
if (createHash('sha512').update(data).digest('base64') !== integrity) throw new Error('integrity mismatch: ' + name);
|
|
||||||
writeFileSync(`${root}/${name}-${release}.tgz`, data, { mode: 0o644 });
|
|
||||||
}
|
|
||||||
mkdirSync(destination, { recursive: true });
|
|
||||||
execFileSync('tar', ['-xzf', `${root}/dsh-chat-manager-${version}.tgz`, '--strip-components=1', '--no-same-owner', '-C', destination]);
|
|
||||||
const pkg = JSON.parse(readFileSync(destination + '/package.json'));
|
|
||||||
if (pkg.name !== 'dsh-chat-manager' || pkg.version !== version || pkg.license !== 'MIT' || JSON.stringify(pkg.dependencies) !== JSON.stringify({ clsx: '^2.0.0' })) throw new Error('unexpected chat-manager metadata');
|
|
||||||
if (!readFileSync(destination + '/LICENSE', 'utf8').includes('MIT License')) throw new Error('missing chat-manager MIT license');
|
|
||||||
const require = createRequire(hostManifest);
|
|
||||||
const host = JSON.parse(readFileSync(hostManifest));
|
|
||||||
const semver = createRequire('/usr/local/lib/node_modules/npm/package.json')('semver');
|
|
||||||
for (const [name, range] of Object.entries(pkg.peerDependencies || {})) {
|
|
||||||
// 1.5.20 retains this optional legacy peer but its runtime no longer imports it.
|
|
||||||
if (name === '@deepseek-ai/dsh-invariants' && pkg.peerDependenciesMeta?.[name]?.optional) {
|
|
||||||
for (const file of ['src/index.js', 'src/host/archive-manager.mjs', 'src/host/delete-session.mjs', 'lib/client.js']) {
|
|
||||||
if (readFileSync(destination + '/' + file, 'utf8').includes(name)) throw new Error('legacy invariants peer is used again');
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (name === 'react') {
|
|
||||||
if (host.version !== '0.2.1-rc.5') throw new Error('unverified browser React host: ' + host.version);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// New web hosts bundle this pure UI module in the browser bootstrap.
|
|
||||||
if (['@deepseek-ai/dsh-client-ui-primitives', '@deepseek-ai/dsh-client-ui-slots'].includes(name) && host.version === '0.2.1-rc.5') {
|
|
||||||
const frontend = require.resolve('@deepseek-ai/dsh-web-frontend/package.json');
|
|
||||||
const assets = frontend.slice(0, -'package.json'.length) + 'dist/assets';
|
|
||||||
const frontendVersion = JSON.parse(readFileSync(frontend)).version;
|
|
||||||
if (frontendVersion !== host.version || !semver.satisfies(frontendVersion, range) || !readdirSync(assets).filter(file => /^index-.*\.js$/.test(file)).some(file => readFileSync(assets + '/' + file, 'utf8').includes(JSON.stringify(name) + ':'))) throw new Error('missing browser UI provider');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const peer = require(name + '/package.json');
|
|
||||||
if (!semver.satisfies(peer.version, range)) throw new Error('incompatible chat-manager host dependency: ' + name + '@' + peer.version);
|
|
||||||
}
|
|
||||||
const client = destination + '/lib/client.js';
|
|
||||||
writeFileSync(client, patchClient(readFileSync(client, 'utf8'), version));
|
|
||||||
pkg.version = patchedVersion;
|
|
||||||
writeFileSync(destination + '/package.json', JSON.stringify(pkg, null, 2));
|
|
||||||
execFileSync('chmod', ['-R', 'a+rX', destination]);
|
|
||||||
execFileSync('tar', ['-czf', `${root}/dsh-chat-manager-${patchedVersion}.tgz`, '--transform=s,^dsh-chat-manager,package,', '-C', destination + '/..', 'dsh-chat-manager']);
|
|
||||||
execFileSync('chmod', ['644', `${root}/dsh-chat-manager-${patchedVersion}.tgz`]);
|
|
||||||
const clsx = destination + '/node_modules/clsx';
|
|
||||||
mkdirSync(clsx, { recursive: true });
|
|
||||||
execFileSync('tar', ['-xzf', `${root}/clsx-2.1.1.tgz`, '--strip-components=1', '--no-same-owner', '-C', clsx]);
|
|
||||||
execFileSync('chmod', ['-R', 'a+rX', clsx]);
|
|
||||||
host.dependencies = { ...host.dependencies, 'dsh-chat-manager': patchedVersion };
|
|
||||||
writeFileSync(hostManifest, JSON.stringify(host, null, 2));
|
|
||||||
console.log('Installed dsh-chat-manager@' + patchedVersion + ' and [email protected] (SHA-512 verified; no lifecycle scripts; MIT retained)');
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { createHash } from 'node:crypto';
|
|
||||||
import { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
|
||||||
import { execFileSync } from 'node:child_process';
|
|
||||||
import { createRequire } from 'node:module';
|
|
||||||
const root = '/usr/local/lib/dsh-mgr';
|
|
||||||
const packages = [
|
|
||||||
['dsh-free-search', '0.9.21', 'https://registry.npmjs.org/dsh-free-search/-/dsh-free-search-0.9.21.tgz', '3RtN+hphVn5WcqjbwPBkG+QGgz7YO6KtkAKQFmWmsVl3lmDEhLENuno95JheKahllt0IXCLFCTHsI2HSzttg4g=='],
|
|
||||||
['@deepseek-ai/schemastery', '3.18.4', 'https://registry.npmjs.org/@deepseek-ai/schemastery/-/schemastery-3.18.4.tgz', 'SSXO6tYuyrIqKVbmOnIq0s+riUywYzouFMcnBluHF9n4KMo2G8HvEzhCYj6pj2617/glOjbJuVInJ9hfONsjkg=='],
|
|
||||||
['@deepseek-ai/cosmokit', '1.8.5', 'https://registry.npmjs.org/@deepseek-ai/cosmokit/-/cosmokit-1.8.5.tgz', 'LXsrlem9z8dq4sLflj2yuCuYX7KqhdzF5hZly3eZyuTo8d6oDSOXuEgC5DbQWKW+lksm6zmOutQLsP/ma6wR6A=='],
|
|
||||||
['@standard-schema/spec', '1.1.0', 'https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz', 'l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=='],
|
|
||||||
];
|
|
||||||
for (const [name, version, url, integrity] of packages) {
|
|
||||||
const response = await fetch(url, { signal: AbortSignal.timeout(120000) });
|
|
||||||
if (!response.ok) throw new Error('download failed: ' + name);
|
|
||||||
const data = Buffer.from(await response.arrayBuffer());
|
|
||||||
if (createHash('sha512').update(data).digest('base64') !== integrity) throw new Error('integrity mismatch: ' + name);
|
|
||||||
const archive = `${root}/${name.split('/').pop()}-${version}.tgz`;
|
|
||||||
writeFileSync(archive, data, { mode: 0o644 });
|
|
||||||
}
|
|
||||||
const hostManifest = '/usr/local/lib/node_modules/@deepseek-ai/dsh/package.json';
|
|
||||||
const destination = '/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/dsh-free-search';
|
|
||||||
mkdirSync(destination, { recursive: true });
|
|
||||||
execFileSync('tar', ['-xzf', `${root}/dsh-free-search-0.9.21.tgz`, '--strip-components=1', '--no-same-owner', '-C', destination]);
|
|
||||||
execFileSync('chmod', ['-R', 'a+rX', destination]);
|
|
||||||
const pkg = JSON.parse(readFileSync(destination + '/package.json'));
|
|
||||||
if (pkg.name !== 'dsh-free-search' || pkg.version !== '0.9.21' || JSON.stringify(pkg.dependencies) !== JSON.stringify({ '@deepseek-ai/schemastery': '^3.18.4' })) throw new Error('unexpected free-search metadata');
|
|
||||||
const require = createRequire(hostManifest);
|
|
||||||
const semver = createRequire('/usr/local/lib/node_modules/npm/package.json')('semver');
|
|
||||||
for (const [name, range] of Object.entries({ ...pkg.peerDependencies, ...pkg.dependencies })) {
|
|
||||||
if (name === 'react') {
|
|
||||||
if (require('./package.json').version !== '0.2.1-rc.5') throw new Error('unverified browser React host');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const peer = require(name + '/package.json');
|
|
||||||
if (!(semver.satisfies(peer.version, range) || (peer.version === "0.2.1-rc.5" && name.startsWith("@deepseek-ai/dsh-") && semver.satisfies(peer.version, range, { includePrerelease: true })))) throw new Error('incompatible free-search host dependency: ' + name);
|
|
||||||
}
|
|
||||||
const host = JSON.parse(readFileSync(hostManifest));
|
|
||||||
host.dependencies = { ...host.dependencies, 'dsh-free-search': '0.9.21' };
|
|
||||||
writeFileSync(hostManifest, JSON.stringify(host, null, 2));
|
|
||||||
console.log('Installed [email protected] and pinned offline dependency archives (SHA-512 verified; no lifecycle scripts)');
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { readFileSync, writeFileSync } from 'node:fs';
|
|
||||||
import { join, resolve } from 'node:path';
|
|
||||||
import { pathToFileURL } from 'node:url';
|
|
||||||
|
|
||||||
export const target = 'const persistence = ctx.remote.$host.isLoopback ? "host" : "memory";';
|
|
||||||
const replacement = 'const persistence = "host"; /* dsh-mgr: authenticated remote settings */';
|
|
||||||
|
|
||||||
// Deliberately scoped to the settings UI, not connection.isLoopback or auth.
|
|
||||||
// Fail closed on upstream changes instead of silently shipping a broken UI.
|
|
||||||
export function patchSettings(source, version) {
|
|
||||||
// Native host settings supersede the old persistence patch.
|
|
||||||
if (version === "0.2.1-rc.5") {
|
|
||||||
if (source.includes(target) || !source.includes("this.ctx.remote.settings.describe()") || !source.includes("this.ctx.remote.settings.mutate(")) throw new Error("expected native host settings API");
|
|
||||||
return source;
|
|
||||||
}
|
|
||||||
if (version !== '0.2.0-rc.2') throw new Error('unsupported DSH version for host settings patch');
|
|
||||||
if (source.split(target).length !== 2 || source.includes(replacement)) {
|
|
||||||
throw new Error('expected exactly one unpatched settings persistence selector');
|
|
||||||
}
|
|
||||||
return source.replace(target, replacement);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function install(root = '/usr/local/lib/node_modules/@deepseek-ai/dsh') {
|
|
||||||
const { version } = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
|
||||||
const file = join(root, 'node_modules/@deepseek-ai/dsh-client-ui-settings/lib/client.js');
|
|
||||||
const source = readFileSync(file, 'utf8');
|
|
||||||
writeFileSync(file, patchSettings(source, version));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
|
||||||
install();
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { runInNewContext } from 'node:vm';
|
|
||||||
import { readFileSync } from 'node:fs';
|
|
||||||
import { patchSettings, target } from './install-host-settings.mjs';
|
|
||||||
|
|
||||||
test("next host settings API remains unchanged", () => {
|
|
||||||
const source = "this.ctx.remote.settings.describe(); this.ctx.remote.settings.mutate(namespace, ops, revision);";
|
|
||||||
assert.equal(patchSettings(source, "0.2.1-rc.5"), source);
|
|
||||||
assert.throws(() => patchSettings(target, "0.2.1-rc.5"), /native host settings/);
|
|
||||||
assert.throws(() => patchSettings("", "0.2.1-rc.5"), /native host settings/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('settings use host persistence on domains without changing loopback facts', () => {
|
|
||||||
for (const isLoopback of [false, true]) {
|
|
||||||
const ctx = { remote: { $host: { isLoopback } } };
|
|
||||||
const source = '(function(ctx) { ' + target + ' return persistence; })(ctx)';
|
|
||||||
assert.equal(runInNewContext(patchSettings(source, '0.2.0-rc.2'), { ctx }), 'host');
|
|
||||||
assert.equal(ctx.remote.$host.isLoopback, isLoopback);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('reject unsupported versions, changed selectors, duplicates and repeat installation', () => {
|
|
||||||
assert.throws(() => patchSettings(target, '0.2.0-rc.3'), /unsupported DSH version/);
|
|
||||||
for (const source of ['', target.replace('isLoopback', 'isLocal'), target + target,
|
|
||||||
patchSettings(target, '0.2.0-rc.2')]) {
|
|
||||||
assert.throws(() => patchSettings(source, '0.2.0-rc.2'), /exactly one/);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('patch leaves surrounding code unchanged and is wired into the image', () => {
|
|
||||||
const source = 'before; ' + target + ' after;';
|
|
||||||
const result = patchSettings(source, '0.2.0-rc.2');
|
|
||||||
assert.ok(result.startsWith('before; '));
|
|
||||||
assert.ok(result.endsWith(' after;'));
|
|
||||||
const dockerfile = readFileSync(new URL('./Dockerfile', import.meta.url), 'utf8');
|
|
||||||
assert.ok(dockerfile.includes('COPY install-host-settings.mjs /usr/local/lib/dsh-mgr/install-host-settings.mjs'));
|
|
||||||
assert.ok(dockerfile.indexOf('RUN node /usr/local/lib/dsh-mgr/install-host-settings.mjs') > dockerfile.indexOf('npm install --global --foreground-scripts'));
|
|
||||||
});
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { createHash } from 'node:crypto';
|
|
||||||
import { writeFileSync, mkdirSync } from 'node:fs';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { execFileSync } from 'node:child_process';
|
|
||||||
|
|
||||||
// Third-party mirror, pinned to a reviewed commit; Xiaomi's font license applies.
|
|
||||||
const base = 'https://raw.githubusercontent.com/boyan01/mi_sans_font/68f3ec3fee5830ce8a1d955d462fa0df401a5341/lib/fonts/';
|
|
||||||
const fonts = [
|
|
||||||
['MiSans-Regular.ttf', '7172aa1b5c703780ee12df3fe4c50481dde01f3b125a045a398000a88d032ae5', 'Regular'],
|
|
||||||
['MiSans-Bold.ttf', '250fb5c8d4657f103e332b0b948a995e45f2c159beb8d0528424e2d32ae421be', 'Bold'],
|
|
||||||
];
|
|
||||||
const destination = '/usr/local/share/fonts/misans';
|
|
||||||
mkdirSync(destination, { recursive: true });
|
|
||||||
for (const [name, sha256] of fonts) {
|
|
||||||
console.log('Downloading ' + name);
|
|
||||||
const response = await fetch(base + name, { signal: AbortSignal.timeout(120000) });
|
|
||||||
if (!response.ok) throw new Error('MiSans download failed: ' + name + ' HTTP ' + response.status);
|
|
||||||
const font = Buffer.from(await response.arrayBuffer());
|
|
||||||
if (createHash('sha256').update(font).digest('hex') !== sha256) {
|
|
||||||
throw new Error('MiSans checksum mismatch: ' + name);
|
|
||||||
}
|
|
||||||
writeFileSync(join(destination, name), font, { mode: 0o644 });
|
|
||||||
}
|
|
||||||
execFileSync('fc-cache', ['-f', destination], { stdio: 'inherit' });
|
|
||||||
for (const [name, , style] of fonts) {
|
|
||||||
const file = execFileSync('fc-match', ['-f', '%{file}', 'MiSans:style=' + style], { encoding: 'utf8' });
|
|
||||||
if (file !== join(destination, name)) throw new Error('MiSans font lookup failed: ' + style + ': ' + file);
|
|
||||||
}
|
|
||||||
console.log('Installed MiSans Regular and Bold (SHA-256 verified)');
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { createHash } from 'node:crypto';
|
|
||||||
import { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
|
||||||
import { execFileSync } from 'node:child_process';
|
|
||||||
import { createRequire } from 'node:module';
|
|
||||||
const version = '3.2.1';
|
|
||||||
const integrity = '2XHbebwr78ujeJVt3wqffSp7/LEnLqUajawnL0TWNGG6LcAimAm8q8GJdh28c7gdRs6BYAGxfF/TatA5E6xZ+A==';
|
|
||||||
const response = await fetch('https://registry.npmjs.org/dsh-web-mobile/-/dsh-web-mobile-3.2.1.tgz', { signal: AbortSignal.timeout(120000) });
|
|
||||||
if (!response.ok) throw new Error('mobile plugin download failed');
|
|
||||||
const data = Buffer.from(await response.arrayBuffer());
|
|
||||||
if (createHash('sha512').update(data).digest('base64') !== integrity) throw new Error('mobile plugin integrity mismatch');
|
|
||||||
const destination = '/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/dsh-web-mobile';
|
|
||||||
mkdirSync(destination, { recursive: true });
|
|
||||||
const archive = '/usr/local/lib/dsh-mgr/dsh-web-mobile-3.2.1.tgz';
|
|
||||||
writeFileSync(archive, data, { mode: 0o644 });
|
|
||||||
execFileSync('tar', ['-xzf', archive, '--strip-components=1', '--no-same-owner', '-C', destination]);
|
|
||||||
// Published tar modes can be owner-only; runtime is UID 1000, not build root.
|
|
||||||
execFileSync('chmod', ['-R', 'a+rX', destination]);
|
|
||||||
const pkg = JSON.parse(readFileSync(destination + '/package.json'));
|
|
||||||
if (pkg.version !== version || pkg.name !== 'dsh-web-mobile' || Object.keys(pkg.dependencies || {}).length) throw new Error('unexpected mobile package metadata');
|
|
||||||
const require = createRequire('/usr/local/lib/node_modules/@deepseek-ai/dsh/package.json');
|
|
||||||
const semver = createRequire('/usr/local/lib/node_modules/npm/package.json')('semver');
|
|
||||||
for (const [name, range] of Object.entries(pkg.peerDependencies || {})) {
|
|
||||||
// React is provided by the browser shell, not a Node package in DSH.
|
|
||||||
// Limit that exception to the upstream-declared, tested RC hosts.
|
|
||||||
if (name === 'react') {
|
|
||||||
const host = require('./package.json').version;
|
|
||||||
if (host !== '0.2.1-rc.5') throw new Error('unverified browser React host: ' + host);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let peer;
|
|
||||||
try { peer = require(name + '/package.json'); }
|
|
||||||
catch (error) {
|
|
||||||
if (pkg.peerDependenciesMeta?.[name]?.optional) continue;
|
|
||||||
throw new Error('missing required mobile peer: ' + name);
|
|
||||||
}
|
|
||||||
if (!(semver.satisfies(peer.version, range) || (peer.version === "0.2.1-rc.5" && name.startsWith("@deepseek-ai/dsh-") && semver.satisfies(peer.version, range, { includePrerelease: true })))) throw new Error('incompatible mobile peer: ' + name + '@' + peer.version);
|
|
||||||
}
|
|
||||||
// DSH runtime resolution traverses the installation manifest, not arbitrary
|
|
||||||
// directories in node_modules. Declare the baked package in that graph.
|
|
||||||
const hostManifest = '/usr/local/lib/node_modules/@deepseek-ai/dsh/package.json';
|
|
||||||
const host = JSON.parse(readFileSync(hostManifest));
|
|
||||||
host.dependencies = { ...host.dependencies, 'dsh-web-mobile': version };
|
|
||||||
writeFileSync(hostManifest, JSON.stringify(host, null, 2));
|
|
||||||
console.log('Installed [email protected] (SHA-512 verified; no lifecycle scripts)');
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { readFileSync, writeFileSync, copyFileSync } from 'node:fs';
|
|
||||||
import { join, resolve } from 'node:path';
|
|
||||||
import { pathToFileURL } from 'node:url';
|
|
||||||
|
|
||||||
export const target = 'import { createConverter } from "@deepseek-ai/libreoffice-kit";';
|
|
||||||
export function patchOffice(source, hostVersion, packageVersion) {
|
|
||||||
if (hostVersion !== '0.2.1-rc.5' || packageVersion !== '0.2.1-rc.5') throw new Error('unsupported Office preview version');
|
|
||||||
if (source.split(target).length !== 2) throw new Error('expected exactly one original converter import');
|
|
||||||
return source.replace(target, 'import { createConverter } from "./dsh-native-office.mjs";');
|
|
||||||
}
|
|
||||||
export function install(root = '/usr/local/lib/node_modules/@deepseek-ai/dsh') {
|
|
||||||
const dir = join(root, 'node_modules/@deepseek-ai/dsh-office-to-pdf');
|
|
||||||
const version = path => JSON.parse(readFileSync(join(path, 'package.json'), 'utf8')).version;
|
|
||||||
const file = join(dir, 'lib/index.js');
|
|
||||||
const patched = patchOffice(readFileSync(file, 'utf8'), version(root), version(dir));
|
|
||||||
for (const [source, name] of [['native-office.mjs', 'dsh-native-office.mjs'], ['validate-office.py', 'validate-office.py'], ['office-worker.py', 'office-worker.py']]) {
|
|
||||||
copyFileSync(new URL(source, import.meta.url), join(dir, 'lib', name));
|
|
||||||
}
|
|
||||||
writeFileSync(file, patched);
|
|
||||||
}
|
|
||||||
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) install();
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { cpSync, mkdirSync, readFileSync } from 'node:fs';
|
|
||||||
import { execFileSync } from 'node:child_process';
|
|
||||||
const root = '/usr/local/lib/dsh-mgr';
|
|
||||||
const host = JSON.parse(readFileSync('/usr/local/lib/node_modules/@deepseek-ai/dsh/package.json'));
|
|
||||||
if (host.version !== '0.2.1-rc.5') throw new Error('Unverified preview download host');
|
|
||||||
mkdirSync(root + '/preview-download-package', { recursive: true });
|
|
||||||
cpSync(new URL('./preview-download', import.meta.url), root + '/preview-download-package/package', { recursive: true });
|
|
||||||
execFileSync('tar', ['-czf', root + '/dsh-preview-download-1.0.1.tgz', '-C', root + '/preview-download-package', 'package']);
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
||||||
import { execFileSync } from 'node:child_process';
|
|
||||||
const root = '/usr/local/lib/dsh-mgr';
|
|
||||||
const destination = root + '/workspace-package/package';
|
|
||||||
mkdirSync(destination, { recursive: true });
|
|
||||||
writeFileSync(destination + '/package.json', JSON.stringify({
|
|
||||||
name: 'dsh-mgr-workspace', version: '1.0.0', type: 'module', main: 'index.js',
|
|
||||||
dsh: { bundle: { patch: 'cordis.patch.yml' } },
|
|
||||||
}));
|
|
||||||
writeFileSync(destination + '/index.js', "export const inject = ['workspaceRegistry', 'sessions'];\nexport async function apply(ctx) {\n await ctx.workspaceRegistry.initializeDefault(() => '/workspace');\n}\n");
|
|
||||||
writeFileSync(destination + '/cordis.patch.yml', "- insert:\n - id: dsh-mgr-workspace\n name: dsh-mgr-workspace\n");
|
|
||||||
execFileSync('tar', ['-czf', root + '/dsh-mgr-workspace-1.0.0.tgz', '-C', root + '/workspace-package', 'package']);
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
// Local Docker integration: no published ports or existing workspaces are used.
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { execFileSync } from 'node:child_process';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
const oldImage = process.env.DSH_OLD_IMAGE || 'dsh-web:runtime-7.10-amd64';
|
|
||||||
const newImage = process.env.DSH_NEW_IMAGE || 'dsh-web:runtime-7.11-amd64';
|
|
||||||
const volume = `dsh-lockfile-smoke-${randomUUID()}`;
|
|
||||||
const docker = args => execFileSync('docker', args, { encoding: 'utf8', timeout: 180000, maxBuffer: 8 * 1024 * 1024 });
|
|
||||||
const run = (image, args, mounts = []) => docker(['run', '--rm', '--network', 'none', '-v', `${volume}:/workspace`, ...mounts, '--entrypoint', 'bash', image, '-ec', args]);
|
|
||||||
docker(['volume', 'create', volume]);
|
|
||||||
try {
|
|
||||||
run(oldImage, 'node /usr/local/lib/dsh-mgr/init-profile.mjs; CI=true npm_config_offline=true dsh plugin --profile web install --ignore-scripts; test -s /workspace/profiles/web/pnpm-lock.yaml');
|
|
||||||
const mounts = ['init-profile.mjs', 'reconcile-profile.mjs'].flatMap(name => ['-v', `${fileURLToPath(new URL(name, import.meta.url))}:/usr/local/lib/dsh-mgr/${name}:ro`]);
|
|
||||||
mounts.push('-v', `${fileURLToPath(new URL('entrypoint.sh', import.meta.url))}:/usr/local/bin/dsh-entrypoint:ro`);
|
|
||||||
const output = run(newImage, 'bash /usr/local/bin/dsh-entrypoint --version; test ! -e /workspace/profiles/web/.dsh-lockfile-pending; CI=true npm_config_offline=true dsh plugin --profile web install --ignore-scripts', mounts);
|
|
||||||
assert.doesNotMatch(output, /ERR_PNPM_OUTDATED_LOCKFILE/);
|
|
||||||
const again = run(newImage, 'sha256sum /workspace/profiles/web/pnpm-lock.yaml; bash /usr/local/bin/dsh-entrypoint --version; sha256sum /workspace/profiles/web/pnpm-lock.yaml', mounts);
|
|
||||||
const hashes = again.match(/^[a-f0-9]{64} .*pnpm-lock.yaml$/gm);
|
|
||||||
assert.equal(hashes?.length, 2, again);
|
|
||||||
assert.equal(hashes[0], hashes[1]);
|
|
||||||
console.log(`PASS: ${oldImage} → ${newImage} migration, frozen reinstall, unchanged lockfile on restart`);
|
|
||||||
} finally {
|
|
||||||
docker(['volume', 'rm', volume]);
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import { spawn } from 'node:child_process';
|
|
||||||
import { constants } from 'node:fs';
|
|
||||||
import { open, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join, extname, isAbsolute } from 'node:path';
|
|
||||||
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
||||||
|
|
||||||
const failure = (code, message) => Object.assign(new Error(message), { code });
|
|
||||||
|
|
||||||
// No shell; terminate the whole group, including soffice.bin, on cancellation.
|
|
||||||
export function runProcess(executable, args, signal, env = process.env) {
|
|
||||||
signal.throwIfAborted();
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const child = spawn(executable, args, { detached: true, stdio: 'ignore', env });
|
|
||||||
let error;
|
|
||||||
const kill = () => {
|
|
||||||
if (child.pid) {
|
|
||||||
try { process.kill(-child.pid, 'SIGKILL'); }
|
|
||||||
catch (cause) { if (cause.code !== 'ESRCH') error ??= cause; }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const abort = () => { error = signal.reason; kill(); };
|
|
||||||
signal.addEventListener('abort', abort, { once: true });
|
|
||||||
if (signal.aborted) abort();
|
|
||||||
child.once('error', cause => { error ??= failure('unavailable', 'Cannot start helper: ' + cause.code); });
|
|
||||||
child.once('close', code => {
|
|
||||||
signal.removeEventListener('abort', abort);
|
|
||||||
kill();
|
|
||||||
if (error) reject(error);
|
|
||||||
else if (code !== 0) reject(failure('invalid-document', 'Conversion helper exit: ' + code));
|
|
||||||
else resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readBounded(path, maximum, kind, signal) {
|
|
||||||
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
||||||
try {
|
|
||||||
const stat = await file.stat();
|
|
||||||
if (!stat.isFile()) throw failure('invalid-' + (kind === 'input' ? 'document' : 'output'), 'Regular file required');
|
|
||||||
if (stat.size > maximum) throw failure(kind + '-too-large', 'File exceeds limit');
|
|
||||||
const bytes = Buffer.alloc(stat.size + 1);
|
|
||||||
let length = 0;
|
|
||||||
while (length < bytes.length) {
|
|
||||||
signal.throwIfAborted();
|
|
||||||
const read = await file.read(bytes, length, bytes.length - length, null);
|
|
||||||
if (!read.bytesRead) break;
|
|
||||||
length += read.bytesRead;
|
|
||||||
}
|
|
||||||
if (length !== stat.size) throw failure('invalid-' + (kind === 'input' ? 'document' : 'output'), 'File changed');
|
|
||||||
return bytes.subarray(0, length);
|
|
||||||
} finally { await file.close(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createConverter(options = {}) {
|
|
||||||
if (process.platform !== 'linux') throw failure('unavailable', 'Native preview requires Linux');
|
|
||||||
if (options.fontDirectories !== undefined || options.fontFallbacks !== undefined) {
|
|
||||||
throw failure('unavailable', 'Native preview uses system fonts; remove kit-only font overrides');
|
|
||||||
}
|
|
||||||
const lifetime = new AbortController();
|
|
||||||
const active = new Set();
|
|
||||||
const limits = { timeoutMs: 60000, maxInputBytes: 50 * 1024 ** 2, maxOutputBytes: 100 * 1024 ** 2, maxArchiveEntries: 10000, maxUncompressedBytes: 250 * 1024 ** 2, maxImageResolution: 192, ...options };
|
|
||||||
for (const key of ['timeoutMs', 'maxInputBytes', 'maxOutputBytes', 'maxArchiveEntries', 'maxUncompressedBytes', 'maxImageResolution']) {
|
|
||||||
if (!Number.isSafeInteger(limits[key]) || limits[key] < 1) throw new TypeError('Invalid ' + key);
|
|
||||||
}
|
|
||||||
async function render({ inputPath, outputPath }, external = new AbortController().signal) {
|
|
||||||
const deadline = new AbortController();
|
|
||||||
const signal = AbortSignal.any([external, lifetime.signal, deadline.signal]);
|
|
||||||
const timer = setTimeout(() => deadline.abort(failure('timeout', 'Native Office conversion timed out')), limits.timeoutMs);
|
|
||||||
let scratch;
|
|
||||||
let output;
|
|
||||||
let success = false;
|
|
||||||
try {
|
|
||||||
signal.throwIfAborted();
|
|
||||||
const extension = extname(inputPath).slice(1).toLowerCase();
|
|
||||||
if (!['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes(extension)) throw failure('unsupported-format', 'Unsupported Office extension');
|
|
||||||
if (!isAbsolute(inputPath) || !isAbsolute(outputPath)) throw failure('invalid-document', 'Absolute paths required');
|
|
||||||
output = await open(outputPath, 'wx', 0o600);
|
|
||||||
scratch = await mkdtemp(join(tmpdir(), 'dsh-native-office-'));
|
|
||||||
const source = join(scratch, 'source.' + extension);
|
|
||||||
await writeFile(source, await readBounded(inputPath, limits.maxInputBytes, 'input', signal), { flag: 'wx', mode: 0o600, signal });
|
|
||||||
const launcher = fileURLToPath(new URL('./office-worker.py', import.meta.url));
|
|
||||||
const env = { PATH: '/usr/bin:/bin', LANG: 'C.UTF-8', HOME: scratch, TMPDIR: scratch, SAL_USE_VCLPLUGIN: 'svp', SAL_DISABLE_PRINTERLIST: '1', SAL_DISABLE_DEFAULTPRINTER: '1' };
|
|
||||||
await runProcess('/usr/bin/python3', ['-I', launcher, String(512 * 1024 ** 2), String(limits.maxOutputBytes), '/usr/bin/python3', '-I', fileURLToPath(new URL('./validate-office.py', import.meta.url)), source, extension, String(limits.maxArchiveEntries), String(limits.maxUncompressedBytes)], signal, env);
|
|
||||||
const profile = join(scratch, 'profile');
|
|
||||||
await mkdir(join(profile, 'user'), { recursive: true, mode: 0o700 });
|
|
||||||
await writeFile(join(profile, 'user/registrymodifications.xcu'), '<?xml version="1.0"?><oor:items xmlns:oor="http://openoffice.org/2001/registry"><item oor:path="/org.openoffice.Office.Common/Security/Scripting"><prop oor:name="MacroSecurityLevel" oor:op="fuse"><value>3</value></prop></item></oor:items>');
|
|
||||||
const filter = extension.startsWith('ppt') ? 'impress_pdf_Export' : extension.startsWith('xls') ? 'calc_pdf_Export' : 'writer_pdf_Export';
|
|
||||||
const pdfOptions = JSON.stringify({ ReduceImageResolution: { type: 'boolean', value: 'true' }, MaxImageResolution: { type: 'long', value: String(limits.maxImageResolution) } });
|
|
||||||
await runProcess('/usr/bin/python3', ['-I', launcher, String(2 * 1024 ** 3), String(Math.max(limits.maxOutputBytes, 16 * 1024 ** 2)), '/usr/bin/libreoffice', '-env:UserInstallation=' + pathToFileURL(profile).href, '--headless', '--nologo', '--nodefault', '--nofirststartwizard', '--convert-to', 'pdf:' + filter + ':' + pdfOptions, '--outdir', scratch, source], signal, env);
|
|
||||||
let bytes;
|
|
||||||
try { bytes = await readBounded(join(scratch, 'source.pdf'), limits.maxOutputBytes, 'output', signal); }
|
|
||||||
catch (cause) { if (cause.code === 'ENOENT') throw failure('invalid-output', 'LibreOffice produced no PDF'); throw cause; }
|
|
||||||
if (!/^%PDF-[0-9][.][0-9]/.test(bytes.subarray(0, 8).toString()) || !bytes.subarray(-1024).toString().trimEnd().endsWith('%%EOF')) throw failure('invalid-output', 'Incomplete PDF');
|
|
||||||
signal.throwIfAborted();
|
|
||||||
await output.writeFile(bytes);
|
|
||||||
signal.throwIfAborted();
|
|
||||||
success = true;
|
|
||||||
// System LibreOffice has no kit missing-font diagnostic API.
|
|
||||||
return { backend: 'native-system', missingFonts: [] };
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timer);
|
|
||||||
try { await output?.close(); }
|
|
||||||
finally {
|
|
||||||
try { if (output && !success) await rm(outputPath, { force: true }); }
|
|
||||||
finally { if (scratch) await rm(scratch, { recursive: true, force: true }); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
render(request, signal) {
|
|
||||||
const task = render(request, signal);
|
|
||||||
active.add(task);
|
|
||||||
task.then(() => active.delete(task), () => active.delete(task));
|
|
||||||
return task;
|
|
||||||
},
|
|
||||||
async dispose() { lifetime.abort(failure('unavailable', 'Converter disposed')); await Promise.allSettled([...active]); }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { mkdtemp, writeFile, readFile, rm, access } from 'node:fs/promises';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { createConverter, runProcess } from './native-office.mjs';
|
|
||||||
import { patchOffice, target } from './install-office-preview.mjs';
|
|
||||||
|
|
||||||
test('patch is exact, version gated and refuses repeat application', () => {
|
|
||||||
const patched = patchOffice(target, '0.2.1-rc.5', '0.2.1-rc.5');
|
|
||||||
assert.match(patched, /dsh-native-office/);
|
|
||||||
for (const source of ['', target + target, patched]) assert.throws(() => patchOffice(source, '0.2.1-rc.5', '0.2.1-rc.5'));
|
|
||||||
assert.throws(() => patchOffice(target, 'next', '0.2.1-rc.5'));
|
|
||||||
assert.throws(() => patchOffice(target, '0.2.1-rc.5', 'next'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('process cancellation, missing binary and failure are classified', async () => {
|
|
||||||
const signal = new AbortController().signal;
|
|
||||||
await assert.rejects(runProcess('/not-an-office-binary', [], signal), { code: 'unavailable' });
|
|
||||||
await assert.rejects(runProcess(process.execPath, ['-e', 'process.exit(2)'], signal), { code: 'invalid-document' });
|
|
||||||
const controller = new AbortController();
|
|
||||||
const reason = new Error('cancel test');
|
|
||||||
const pending = runProcess(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], controller.signal);
|
|
||||||
controller.abort(reason);
|
|
||||||
await assert.rejects(pending, error => error === reason);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('input bounds, corruption, exclusive output, abort and disposal', async () => {
|
|
||||||
const directory = await mkdtemp(join(tmpdir(), 'office-test-'));
|
|
||||||
const inputPath = join(directory, 'source.docx');
|
|
||||||
const outputPath = join(directory, 'output.pdf');
|
|
||||||
const converter = await createConverter();
|
|
||||||
try {
|
|
||||||
await writeFile(inputPath, 'invalid');
|
|
||||||
await assert.rejects(converter.render({ inputPath, outputPath }), { code: 'invalid-document' });
|
|
||||||
await assert.rejects(access(outputPath));
|
|
||||||
const small = await createConverter({ maxInputBytes: 1 });
|
|
||||||
await assert.rejects(small.render({ inputPath, outputPath }), { code: 'input-too-large' });
|
|
||||||
await small.dispose();
|
|
||||||
await writeFile(outputPath, 'preserve');
|
|
||||||
await assert.rejects(converter.render({ inputPath, outputPath }), { code: 'EEXIST' });
|
|
||||||
assert.equal(await readFile(outputPath, 'utf8'), 'preserve');
|
|
||||||
const controller = new AbortController();
|
|
||||||
controller.abort(new Error('cancelled'));
|
|
||||||
await assert.rejects(converter.render({ inputPath, outputPath }, controller.signal), /cancelled/);
|
|
||||||
await converter.dispose();
|
|
||||||
await assert.rejects(converter.render({ inputPath, outputPath }), { code: 'unavailable' });
|
|
||||||
await assert.rejects(createConverter({ fontDirectories: [] }), { code: 'unavailable' });
|
|
||||||
} finally { await converter.dispose(); await rm(directory, { recursive: true, force: true }); }
|
|
||||||
});
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
// Run inside the built image as UID 1000. No downloads or fixture dependencies.
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { mkdtemp, writeFile, readFile, rm, readdir } from 'node:fs/promises';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { pathToFileURL } from 'node:url';
|
|
||||||
import { runProcess } from './native-office.mjs';
|
|
||||||
const installed = '/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-office-to-pdf/lib/';
|
|
||||||
const { createConverter } = await import(installed + 'dsh-native-office.mjs');
|
|
||||||
const { OfficeToPdf } = await import(installed + 'index.js');
|
|
||||||
|
|
||||||
assert.notEqual(process.getuid(), 0, 'smoke test must run as non-root');
|
|
||||||
const dir = await mkdtemp(join(tmpdir(), 'office-smoke-'));
|
|
||||||
const signal = new AbortController().signal;
|
|
||||||
const ns = 'xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0"';
|
|
||||||
const converter = await createConverter();
|
|
||||||
try {
|
|
||||||
await writeFile(join(dir, 'word.fodt'), '<?xml version="1.0"?><office:document ' + ns + ' office:version="1.2" office:mimetype="application/vnd.oasis.opendocument.text"><office:body><office:text><text:p>中文预览 Word conversion test</text:p></office:text></office:body></office:document>');
|
|
||||||
await writeFile(join(dir, 'slides.fodp'), '<?xml version="1.0"?><office:document ' + ns + ' office:version="1.2" office:mimetype="application/vnd.oasis.opendocument.presentation"><office:body><office:presentation><draw:page draw:name="slide1"><draw:frame svg:x="2cm" svg:y="2cm" svg:width="20cm" svg:height="5cm"><draw:text-box><text:p>中文预览 PPT conversion test</text:p></draw:text-box></draw:frame></draw:page></office:presentation></office:body></office:document>');
|
|
||||||
for (const [source, format] of [['word.fodt', 'docx'], ['slides.fodp', 'pptx'], ['word.fodt', 'doc'], ['slides.fodp', 'ppt']]) {
|
|
||||||
await runProcess('/usr/bin/libreoffice', ['-env:UserInstallation=' + pathToFileURL(join(dir, 'fixture-profile')).href, '--headless', '--convert-to', format, '--outdir', dir, join(dir, source)], signal);
|
|
||||||
}
|
|
||||||
await Promise.all(['word.docx', 'slides.pptx', 'word.doc', 'slides.ppt'].map(async name => {
|
|
||||||
const start = performance.now();
|
|
||||||
const outputPath = join(dir, name + '.pdf');
|
|
||||||
const result = await converter.render({ inputPath: join(dir, name), outputPath });
|
|
||||||
assert.equal(result.backend, 'native-system');
|
|
||||||
assert.match((await readFile(outputPath)).subarray(0, 8).toString(), /^%PDF-/);
|
|
||||||
console.log(name + ': native PDF in ' + Math.round(performance.now() - start) + 'ms');
|
|
||||||
}));
|
|
||||||
const small = await createConverter({ maxOutputBytes: 1 });
|
|
||||||
await assert.rejects(small.render({ inputPath: join(dir, 'word.docx'), outputPath: join(dir, 'small.pdf') }), { code: 'output-too-large' });
|
|
||||||
await small.dispose();
|
|
||||||
const timeout = await createConverter({ timeoutMs: 1 });
|
|
||||||
await assert.rejects(timeout.render({ inputPath: join(dir, 'word.docx'), outputPath: join(dir, 'timeout.pdf') }), { code: 'timeout' });
|
|
||||||
await timeout.dispose();
|
|
||||||
const limited = await createConverter({ maxArchiveEntries: 1 });
|
|
||||||
await assert.rejects(limited.render({ inputPath: join(dir, 'word.docx'), outputPath: join(dir, 'limit.pdf') }), { code: 'invalid-document' });
|
|
||||||
await limited.dispose();
|
|
||||||
const provider = { slots: [], options: {}, config: { maxOutputBytes: 100 * 1024 ** 2 } };
|
|
||||||
const converted = await OfficeToPdf.prototype.convertBytes.call(provider, await readFile(join(dir, 'slides.pptx')), 'pptx', signal);
|
|
||||||
assert.match(Buffer.from(converted.pdf).subarray(0, 8).toString(), /^%PDF-/);
|
|
||||||
await Promise.all(provider.slots.map(async slot => (await slot.converter).dispose()));
|
|
||||||
console.log('installed DSH convertBytes integration passed');
|
|
||||||
assert.equal((await readdir(tmpdir())).filter(name => name.startsWith('dsh-native-office-')).length, 0);
|
|
||||||
console.log('concurrent DOCX/PPTX/DOC/PPT, output/ZIP limits, timeout and cleanup passed');
|
|
||||||
} finally { await converter.dispose(); await rm(dir, { recursive: true, force: true }); }
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
"""Apply process limits before executing a trusted conversion helper."""
|
|
||||||
import os
|
|
||||||
import resource
|
|
||||||
import sys
|
|
||||||
|
|
||||||
memory, file_bytes, *command = sys.argv[1:]
|
|
||||||
resource.setrlimit(resource.RLIMIT_AS, (int(memory), int(memory)))
|
|
||||||
resource.setrlimit(resource.RLIMIT_FSIZE, (int(file_bytes), int(file_bytes)))
|
|
||||||
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
|
|
||||||
os.execv(command[0], command)
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
// Scoped to this plugin; host authentication and global fetch are unchanged.
|
|
||||||
async function dshChatFetch(path, init) {
|
|
||||||
const parts = window.location.pathname.split('/');
|
|
||||||
const prefix = parts[1] === 'dsh' && parts[2] ? '/dsh/' + parts[2] : '';
|
|
||||||
return fetch(prefix + path, init);
|
|
||||||
}
|
|
||||||
async function dshChatJson(response) {
|
|
||||||
const text = await response.text();
|
|
||||||
try { return JSON.parse(text); } catch {
|
|
||||||
throw new Error('Chat Manager: HTTP ' + response.status + ' returned a non-JSON response; check the instance proxy path and authentication.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
export const clientHelpers = dshChatFetch.toString() + String.fromCharCode(10) + dshChatJson.toString() + String.fromCharCode(10);
|
|
||||||
|
|
||||||
export function patchClient(source, version) {
|
|
||||||
if (version !== '1.5.20') throw new Error('unsupported chat-manager version');
|
|
||||||
if (source.includes('dshChatFetch') || source.split('await fetch(').length !== 4
|
|
||||||
|| source.split('await response.json()').length !== 4
|
|
||||||
|| source.split('/plugins/dsh-session-delete/').length !== 4) {
|
|
||||||
throw new Error('unexpected chat-manager client; review upstream changes');
|
|
||||||
}
|
|
||||||
return clientHelpers + source.replaceAll('await fetch(', 'await dshChatFetch(')
|
|
||||||
.replaceAll('await response.json()', 'await dshChatJson(response)');
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { runInNewContext } from 'node:vm';
|
|
||||||
import { clientHelpers, patchClient } from './patch-chat-manager.mjs';
|
|
||||||
|
|
||||||
test('chat paths preserve instance prefix and POST options', async () => {
|
|
||||||
for (const pathname of ['/dsh/demo/', '/dsh/demo', '/dsh/demo/session/123', '/', '/other/']) {
|
|
||||||
let received;
|
|
||||||
const context = { window: { location: { pathname } }, fetch: (...args) => { received = args; } };
|
|
||||||
runInNewContext(clientHelpers, context);
|
|
||||||
for (const action of ['delete', 'restore', 'archive-search']) {
|
|
||||||
const path = '/plugins/dsh-session-delete/' + action;
|
|
||||||
const options = { method: 'POST', body: '{}', headers: { 'x-dsh-session-delete-confirmation': 'delete-session' } };
|
|
||||||
await context.dshChatFetch(path, options);
|
|
||||||
assert.equal(received[0], (pathname.startsWith('/dsh/demo') ? '/dsh/demo' : '') + path);
|
|
||||||
assert.equal(received[1], options);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
test('non-JSON errors report HTTP status without exposing response content', async () => {
|
|
||||||
const context = {};
|
|
||||||
runInNewContext(clientHelpers, context);
|
|
||||||
await assert.rejects(context.dshChatJson(new Response('method not allowed', { status: 405 })), /HTTP 405.*non-JSON/);
|
|
||||||
const body = await context.dshChatJson(Response.json({ ok: false, error: { code: 'forbidden' } }, { status: 403 }));
|
|
||||||
assert.equal(body.error.code, 'forbidden');
|
|
||||||
});
|
|
||||||
test('patch rejects unsupported or changed clients and double patching', () => {
|
|
||||||
const source = [
|
|
||||||
"await fetch('/plugins/dsh-session-delete/delete', options); await response.json()",
|
|
||||||
'await fetch(`/plugins/dsh-session-delete/${action}`, options); await response.json()',
|
|
||||||
"await fetch('/plugins/dsh-session-delete/archive-search', options); await response.json()",
|
|
||||||
].join(';');
|
|
||||||
const patched = patchClient(source, '1.5.20');
|
|
||||||
assert.equal(patched.split('await dshChatFetch(').length, 4);
|
|
||||||
assert.equal(patched.slice(clientHelpers.length).replaceAll('await dshChatFetch(', 'await fetch(')
|
|
||||||
.replaceAll('await dshChatJson(response)', 'await response.json()'), source);
|
|
||||||
assert.throws(() => patchClient(source, '1.5.16'), /unsupported/);
|
|
||||||
assert.throws(() => patchClient(source, '1.5.13'), /unsupported/);
|
|
||||||
assert.throws(() => patchClient('', '1.5.20'), /unexpected/);
|
|
||||||
assert.throws(() => patchClient(source.replace('await fetch(', 'fetch('), '1.5.20'), /unexpected/);
|
|
||||||
assert.throws(() => patchClient(source.replace('await response.json()', 'response.json()'), '1.5.20'), /unexpected/);
|
|
||||||
assert.throws(() => patchClient(source.replace('/plugins/dsh-session-delete/', '/plugins/changed/'), '1.5.20'), /unexpected/);
|
|
||||||
assert.throws(() => patchClient(patched, '1.5.20'), /unexpected/);
|
|
||||||
});
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { readFileSync } from 'node:fs';
|
|
||||||
|
|
||||||
test('release targets AMD64 and ARM64 with layout 7', () => {
|
|
||||||
const release = JSON.parse(readFileSync(new URL('../release.json', import.meta.url), 'utf8'));
|
|
||||||
assert.deepEqual(release.platforms, ['linux/amd64', 'linux/arm64']);
|
|
||||||
assert.equal(release.deployment_schema, 7);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Node and Caddy stages inherit the target platform', () => {
|
|
||||||
const dockerfile = readFileSync(new URL('./Dockerfile', import.meta.url), 'utf8');
|
|
||||||
const stages = dockerfile.split(String.fromCharCode(10)).filter(line => line.startsWith('FROM '));
|
|
||||||
assert.deepEqual(stages, ['FROM caddy:2.10.2 AS proxy', 'FROM node:24-bookworm-slim']);
|
|
||||||
assert.ok(dockerfile.includes('COPY --from=proxy /usr/bin/caddy /usr/bin/caddy'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('runtime includes Python tooling and headless LibreOffice with CJK fonts', () => {
|
|
||||||
const dockerfile = readFileSync(new URL('./Dockerfile', import.meta.url), 'utf8');
|
|
||||||
const install = dockerfile.split(String.fromCharCode(92, 10)).join(' ').split(String.fromCharCode(10)).find(line => line.startsWith('RUN apt-get update && apt-get install'));
|
|
||||||
for (const pkg of ['python3', 'python3-pip', 'python3-venv', 'python-is-python3', 'libreoffice', 'fonts-noto-cjk']) {
|
|
||||||
assert.ok(install.split(/\s+/).includes(pkg), `missing runtime package: ${pkg}`);
|
|
||||||
}
|
|
||||||
assert.ok(dockerfile.includes('RUN python --version && python3 -m pip --version && libreoffice --headless --version'));
|
|
||||||
});
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { readFileSync } from 'node:fs';
|
|
||||||
import { runInNewContext } from 'node:vm';
|
|
||||||
|
|
||||||
test('installer accepts the release host and rejects unverified hosts before writing', () => {
|
|
||||||
const source = readFileSync(new URL('./install-preview-download.mjs', import.meta.url), 'utf8')
|
|
||||||
.replace(/^import .*;$/gm, '').replaceAll('import.meta.url', JSON.stringify(import.meta.url));
|
|
||||||
const release = JSON.parse(readFileSync(new URL('../release.json', import.meta.url)));
|
|
||||||
for (const version of [release.version, '0.2.1-rc.2', '0.2.1-rc.4', 'next']) {
|
|
||||||
const calls = [];
|
|
||||||
const run = () => runInNewContext(source, {
|
|
||||||
URL, readFileSync: () => JSON.stringify({ version }),
|
|
||||||
mkdirSync: (...args) => calls.push(['mkdir', ...args]),
|
|
||||||
cpSync: (...args) => calls.push(['copy', ...args]),
|
|
||||||
execFileSync: (...args) => calls.push(['exec', ...args]),
|
|
||||||
});
|
|
||||||
if (version === release.version) {
|
|
||||||
run();
|
|
||||||
assert.deepEqual(calls.map(call => call[0]), ['mkdir', 'copy', 'exec']);
|
|
||||||
assert.equal(calls[2][1], 'tar');
|
|
||||||
assert.equal(calls[2][2][1], '/usr/local/lib/dsh-mgr/dsh-preview-download-1.0.1.tgz');
|
|
||||||
} else {
|
|
||||||
assert.throws(run, /Unverified preview download host/);
|
|
||||||
assert.equal(calls.length, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function load() {
|
|
||||||
let plugin;
|
|
||||||
runInNewContext(readFileSync(new URL('./preview-download/client.js', import.meta.url), 'utf8'), {
|
|
||||||
Uint8Array, window: { __ModuleLoader__: { load({ id, factory }) { assert.equal(id, 'dsh-preview-download'); plugin = factory(() => ({})); } } }
|
|
||||||
});
|
|
||||||
return plugin;
|
|
||||||
}
|
|
||||||
test('download passes exact session/path/signal to authorized complete-file RPC', async () => {
|
|
||||||
const plugin = load();
|
|
||||||
const data = new Uint8Array([0, 255, 10]);
|
|
||||||
const signal = new AbortController().signal;
|
|
||||||
const remote = { workspaceFiles: { async readBytes(...args) {
|
|
||||||
assert.equal(args[0], 'session-A'); assert.equal(args[1], '/workspace/中文.pptx');
|
|
||||||
assert.equal(Object.keys(args[2]).length, 0); assert.equal(args[3], signal);
|
|
||||||
return { ok: true, value: { offset: 0, eof: true, bytes: 3, data } };
|
|
||||||
} } };
|
|
||||||
assert.equal(await plugin.readOriginal(remote, 'session-A', '/workspace/中文.pptx', signal), data);
|
|
||||||
assert.equal(plugin.filename('/workspace/中文.pptx'), '中文.pptx');
|
|
||||||
assert.equal(plugin.filename('C:' + String.fromCharCode(92) + 'folder' + String.fromCharCode(92) + 'file.docx'), 'file.docx');
|
|
||||||
});
|
|
||||||
test('denied, partial, size mismatch and cancelled reads never produce download data', async () => {
|
|
||||||
const plugin = load();
|
|
||||||
for (const result of [
|
|
||||||
{ ok: false, error: { code: 'gateway/forbidden' } },
|
|
||||||
{ ok: true, value: { offset: 0, eof: false, data: new Uint8Array() } },
|
|
||||||
{ ok: true, value: { offset: 0, eof: true, bytes: 5, data: new Uint8Array(2) } }
|
|
||||||
]) {
|
|
||||||
await assert.rejects(plugin.readOriginal({ workspaceFiles: { readBytes: async () => result } }, 's', '/file', new AbortController().signal));
|
|
||||||
}
|
|
||||||
const controller = new AbortController(); controller.abort();
|
|
||||||
await assert.rejects(plugin.readOriginal({ workspaceFiles: { readBytes: async () => ({ ok: true, value: {} }) } }, 's', '/file', controller.signal));
|
|
||||||
});
|
|
||||||
test('plugin registers only the supported preview action slot', () => {
|
|
||||||
const plugin = load();
|
|
||||||
let registration;
|
|
||||||
plugin.apply({ effect: fn => fn(), locale: { register: () => () => {} }, remote: {}, slots: {
|
|
||||||
inject(name, fn) { assert.equal(name, 'sidebar.right.tab.document.actions'); return fn(); },
|
|
||||||
register(options, component) { registration = options; assert.equal(typeof component, 'function'); return () => {}; }
|
|
||||||
} });
|
|
||||||
assert.equal(registration.id, 'dsh-preview-download');
|
|
||||||
assert.equal(typeof registration.inject('session-A').download, 'function');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('registered download declares every service used by its callback', async () => {
|
|
||||||
const plugin = load();
|
|
||||||
assert.ok(plugin.inject.includes('remote'));
|
|
||||||
assert.ok(plugin.inject.includes('remote.workspaceFiles'));
|
|
||||||
let registration;
|
|
||||||
let calls = 0;
|
|
||||||
const data = new Uint8Array([1, 2, 255]);
|
|
||||||
const ctx = {
|
|
||||||
effect: fn => fn(),
|
|
||||||
locale: { register: () => () => {} },
|
|
||||||
slots: { inject: (_name, fn) => fn(), register(options) { registration = options; return () => {}; } },
|
|
||||||
get remote() {
|
|
||||||
assert.ok(plugin.inject.includes('remote'), 'Cordis requires explicit remote injection');
|
|
||||||
return { workspaceFiles: { async readBytes(session, path) {
|
|
||||||
assert.equal(session, 'session-A'); assert.equal(path, '/file.bin'); calls++;
|
|
||||||
return { ok: true, value: { offset: 0, eof: true, bytes: 3, data } };
|
|
||||||
} } };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
plugin.apply(ctx);
|
|
||||||
assert.equal(await registration.inject('session-A').download('/file.bin', new AbortController().signal), data);
|
|
||||||
assert.equal(calls, 1);
|
|
||||||
});
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
window.__ModuleLoader__.load({ id: 'dsh-preview-download', factory: require => {
|
|
||||||
const React = require('react');
|
|
||||||
const { createElement: h, useState, useRef, useEffect } = React;
|
|
||||||
const namespace = 'previewDownload';
|
|
||||||
|
|
||||||
function filename(path) {
|
|
||||||
const name = path.replaceAll('\\', '/').split('/').pop() || 'download';
|
|
||||||
return Array.from(name, char => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127 || '<>:"|?*'.includes(char) ? '_' : char).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readOriginal(remote, sessionId, path, signal) {
|
|
||||||
signal.throwIfAborted();
|
|
||||||
const result = await remote.workspaceFiles.readBytes(sessionId, path, {}, signal);
|
|
||||||
signal.throwIfAborted();
|
|
||||||
if (!result.ok) throw Object.assign(new Error('Download refused'), { code: result.error.code });
|
|
||||||
const file = result.value;
|
|
||||||
if (!file.eof || file.offset !== 0 || !(file.data instanceof Uint8Array) || (file.bytes !== undefined && file.bytes !== file.data.byteLength)) {
|
|
||||||
throw new Error('Incomplete file response');
|
|
||||||
}
|
|
||||||
return file.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Download({ absolutePath, download, t }) {
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const pending = useRef(null);
|
|
||||||
useEffect(() => {
|
|
||||||
setBusy(false);
|
|
||||||
setError('');
|
|
||||||
return () => { pending.current?.abort(); pending.current = null; };
|
|
||||||
}, [absolutePath, download]);
|
|
||||||
async function start() {
|
|
||||||
if (pending.current) return;
|
|
||||||
const controller = new AbortController();
|
|
||||||
pending.current = controller;
|
|
||||||
setBusy(true);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
const bytes = await download(absolutePath, controller.signal);
|
|
||||||
controller.signal.throwIfAborted();
|
|
||||||
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/octet-stream' }));
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.download = filename(absolutePath);
|
|
||||||
link.hidden = true;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
try { link.click(); }
|
|
||||||
finally { link.remove(); setTimeout(() => URL.revokeObjectURL(url), 60000); }
|
|
||||||
} catch (cause) {
|
|
||||||
if (!controller.signal.aborted) setError(t(cause?.code === 'workspace-file/too-large' ? 'tooLarge' : 'failed'));
|
|
||||||
} finally {
|
|
||||||
if (pending.current === controller) { pending.current = null; setBusy(false); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: '6px', flexShrink: 0 } },
|
|
||||||
h('button', { type: 'button', disabled: busy, onClick: start, title: t('download'), 'aria-label': t('download'), 'aria-busy': busy, 'data-preview-download': '',
|
|
||||||
style: { color: 'inherit', background: 'transparent', border: '1px solid currentColor', borderRadius: '6px', padding: '4px 8px', font: 'inherit', cursor: busy ? 'wait' : 'pointer' }
|
|
||||||
}, busy ? t('loading') : t('download')),
|
|
||||||
error && h('span', { role: 'alert', style: { fontSize: '12px', maxWidth: '180px' } }, error));
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
inject: ['slots', 'locale', 'remote', 'remote.workspaceFiles'],
|
|
||||||
apply(ctx) {
|
|
||||||
ctx.effect(() => ctx.locale.register(namespace, {
|
|
||||||
zh: { download: '下载原文件', loading: '下载中…', failed: '下载失败,请检查文件权限或重试。', tooLarge: '文件超过下载大小限制。' },
|
|
||||||
en: { download: 'Download original', loading: 'Downloading…', failed: 'Download failed. Check access or retry.', tooLarge: 'File exceeds the download size limit.' }
|
|
||||||
}));
|
|
||||||
ctx.effect(() => ctx.slots.inject('sidebar.right.tab.document.actions', () => ctx.slots.register({
|
|
||||||
name: 'sidebar.right.tab.document.actions', id: 'dsh-preview-download', locale: namespace,
|
|
||||||
inject: sessionId => ({ download: (path, signal) => readOriginal(ctx.remote, sessionId, path, signal) })
|
|
||||||
}, Download)));
|
|
||||||
},
|
|
||||||
// Pure helpers are also exercised by the offline regression suite.
|
|
||||||
filename, readOriginal
|
|
||||||
};
|
|
||||||
} });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
- insert:
|
|
||||||
- id: dsh-preview-download
|
|
||||||
name: dsh-preview-download
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
// Client-only extension: no new server endpoints or authorization changes.
|
|
||||||
export const name = 'dsh-preview-download';
|
|
||||||
export function apply() {}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "dsh-preview-download",
|
|
||||||
"version": "1.0.1",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"main": "index.js",
|
|
||||||
"exports": { ".": "./index.js", "./client": "./client.js" },
|
|
||||||
"dsh": {
|
|
||||||
"bundle": { "patch": "cordis.patch.yml" },
|
|
||||||
"client": {
|
|
||||||
"platform": "web",
|
|
||||||
"inject": ["@deepseek-ai/dsh-client-ui-sidebar-documentpreview", "@deepseek-ai/dsh-client-ui-slots", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-api-workspace-files"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
import { lstatSync, readFileSync, writeFileSync, openSync, closeSync, fsyncSync, renameSync, unlinkSync } from 'node:fs';
|
|
||||||
import { dirname, join, resolve, basename } from 'node:path';
|
|
||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
|
|
||||||
export const BUILTIN_DEPENDENCIES = Object.freeze({
|
|
||||||
'dsh-preview-download': 'file:/usr/local/lib/dsh-mgr/dsh-preview-download-1.0.1.tgz',
|
|
||||||
'dsh-chat-manager': 'file:/usr/local/lib/dsh-mgr/dsh-chat-manager-1.5.20-dsh-mgr.1.tgz',
|
|
||||||
'dsh-mgr-workspace': 'file:/usr/local/lib/dsh-mgr/dsh-mgr-workspace-1.0.0.tgz',
|
|
||||||
'dsh-web-mobile': 'file:/usr/local/lib/dsh-mgr/dsh-web-mobile-3.2.1.tgz',
|
|
||||||
'dsh-free-search': 'file:/usr/local/lib/dsh-mgr/dsh-free-search-0.9.21.tgz',
|
|
||||||
});
|
|
||||||
export const BUILTIN_OVERRIDES = Object.freeze({
|
|
||||||
'dsh-chat-manager>clsx': 'file:/usr/local/lib/dsh-mgr/clsx-2.1.1.tgz',
|
|
||||||
'dsh-free-search>@deepseek-ai/schemastery': 'file:/usr/local/lib/dsh-mgr/schemastery-3.18.4.tgz',
|
|
||||||
'@deepseek-ai/schemastery>@deepseek-ai/cosmokit': 'file:/usr/local/lib/dsh-mgr/cosmokit-1.8.5.tgz',
|
|
||||||
'@deepseek-ai/schemastery>@standard-schema/spec': 'file:/usr/local/lib/dsh-mgr/spec-1.1.0.tgz',
|
|
||||||
});
|
|
||||||
const sections = ['dependencies', 'devDependencies', 'optionalDependencies'];
|
|
||||||
export const LOCKFILE_PENDING = '.dsh-lockfile-pending';
|
|
||||||
|
|
||||||
export function markLockfilePending(profile) {
|
|
||||||
assertSafeProfile(profile);
|
|
||||||
const fd = openSync(join(profile, LOCKFILE_PENDING), 'w', 0o600);
|
|
||||||
try { writeFileSync(fd, 'builtin profile migration\n'); fsyncSync(fd); }
|
|
||||||
finally { closeSync(fd); }
|
|
||||||
const directoryFd = openSync(profile, 'r');
|
|
||||||
try { fsyncSync(directoryFd); } finally { closeSync(directoryFd); }
|
|
||||||
}
|
|
||||||
const overrideOwners = ['dsh-chat-manager', 'dsh-free-search', 'dsh-free-search', 'dsh-free-search'];
|
|
||||||
const semver = '(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)(?:-(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?';
|
|
||||||
|
|
||||||
function object(value, label) {
|
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`invalid ${label}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function reconcileManifest(input) {
|
|
||||||
object(input, 'manifest');
|
|
||||||
const manifest = structuredClone(input);
|
|
||||||
const active = new Set();
|
|
||||||
const archives = new Set();
|
|
||||||
let changed = false;
|
|
||||||
for (const section of sections) {
|
|
||||||
if (manifest[section] === undefined) continue;
|
|
||||||
object(manifest[section], section);
|
|
||||||
for (const [name, target] of Object.entries(BUILTIN_DEPENDENCIES)) {
|
|
||||||
const current = manifest[section][name];
|
|
||||||
if (typeof current !== 'string' || !new RegExp(`^file:/usr/local/lib/dsh-mgr/${name}-${semver}\\.tgz$`).test(current)) continue;
|
|
||||||
active.add(name);
|
|
||||||
archives.add(target);
|
|
||||||
if (current !== target) {
|
|
||||||
manifest[section][name] = target;
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (manifest.pnpm !== undefined) {
|
|
||||||
object(manifest.pnpm, 'pnpm');
|
|
||||||
if (manifest.pnpm.overrides !== undefined) object(manifest.pnpm.overrides, 'pnpm.overrides');
|
|
||||||
}
|
|
||||||
Object.entries(BUILTIN_OVERRIDES).forEach(([key, target], index) => {
|
|
||||||
if (!active.has(overrideOwners[index])) return;
|
|
||||||
manifest.pnpm ??= {};
|
|
||||||
manifest.pnpm.overrides ??= {};
|
|
||||||
if (!Object.hasOwn(manifest.pnpm.overrides, key)) {
|
|
||||||
manifest.pnpm.overrides[key] = target;
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
if (manifest.pnpm.overrides[key] === target) archives.add(target);
|
|
||||||
});
|
|
||||||
return { manifest, changed, archives: [...archives] };
|
|
||||||
}
|
|
||||||
|
|
||||||
function statIfPresent(path) {
|
|
||||||
try { return lstatSync(path); } catch (error) {
|
|
||||||
if (error.code !== 'ENOENT') throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function assertSafeProfile(profile) {
|
|
||||||
for (let dir = resolve(profile); ; dir = dirname(dir)) {
|
|
||||||
const stat = statIfPresent(dir);
|
|
||||||
if (stat && (!stat.isDirectory() || stat.isSymbolicLink())) throw new Error(`unsafe profile directory: ${dir}`);
|
|
||||||
if (dirname(dir) === dir) break;
|
|
||||||
}
|
|
||||||
for (const name of ['package.json', 'pnpm-workspace.yaml', 'pnpm-lock.yaml', LOCKFILE_PENDING]) {
|
|
||||||
const path = join(profile, name);
|
|
||||||
const stat = statIfPresent(path);
|
|
||||||
if (stat && (!stat.isFile() || stat.isSymbolicLink())) throw new Error(`unsafe profile file: ${path}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function reconcileProfile(profile, { archiveDirectory = '/usr/local/lib/dsh-mgr' } = {}) {
|
|
||||||
assertSafeProfile(profile);
|
|
||||||
const path = join(profile, 'package.json');
|
|
||||||
if (!statIfPresent(path)) return { changed: false };
|
|
||||||
const original = readFileSync(path);
|
|
||||||
const result = reconcileManifest(JSON.parse(original.toString('utf8')));
|
|
||||||
for (const reference of result.archives) {
|
|
||||||
const archive = join(archiveDirectory, basename(reference.slice(5)));
|
|
||||||
const stat = lstatSync(archive);
|
|
||||||
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`unsafe builtin archive: ${archive}`);
|
|
||||||
}
|
|
||||||
if (!result.changed) return { changed: false };
|
|
||||||
const backupPath = `${path}.backup-${randomUUID()}`;
|
|
||||||
const temporaryPath = `${path}.tmp-${randomUUID()}`;
|
|
||||||
let temporaryCreated = false;
|
|
||||||
try {
|
|
||||||
const backupFd = openSync(backupPath, 'wx', 0o600);
|
|
||||||
try { writeFileSync(backupFd, original); fsyncSync(backupFd); }
|
|
||||||
finally { closeSync(backupFd); }
|
|
||||||
const syncDirectory = () => {
|
|
||||||
const directoryFd = openSync(profile, 'r');
|
|
||||||
try { fsyncSync(directoryFd); } finally { closeSync(directoryFd); }
|
|
||||||
};
|
|
||||||
// Make the recovery copy durable before replacing the active manifest.
|
|
||||||
syncDirectory();
|
|
||||||
const fd = openSync(temporaryPath, 'wx', 0o600);
|
|
||||||
temporaryCreated = true;
|
|
||||||
try {
|
|
||||||
writeFileSync(fd, JSON.stringify(result.manifest, null, 2) + '\n');
|
|
||||||
fsyncSync(fd);
|
|
||||||
} finally { closeSync(fd); }
|
|
||||||
assertSafeProfile(profile);
|
|
||||||
// Persist before changing the manifest, including across failed installs/restarts.
|
|
||||||
markLockfilePending(profile);
|
|
||||||
renameSync(temporaryPath, path);
|
|
||||||
temporaryCreated = false;
|
|
||||||
syncDirectory();
|
|
||||||
} finally {
|
|
||||||
if (temporaryCreated) unlinkSync(temporaryPath);
|
|
||||||
}
|
|
||||||
return { changed: true, backupPath };
|
|
||||||
}
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import fs, { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync, symlinkSync, readdirSync, statSync, linkSync } from 'node:fs';
|
|
||||||
import { syncBuiltinESMExports } from 'node:module';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join, basename } from 'node:path';
|
|
||||||
import { BUILTIN_DEPENDENCIES, BUILTIN_OVERRIDES, reconcileManifest, reconcileProfile } from './reconcile-profile.mjs';
|
|
||||||
import { initProfile } from './init-profile.mjs';
|
|
||||||
|
|
||||||
const old = name => 'file:/usr/local/lib/dsh-mgr/' + name + '-0.0.1-rc.1+build.2.tgz';
|
|
||||||
test('runtime-7.9 plugin references migrate to runtime-7.11 without changing bundle choices', t => {
|
|
||||||
const bundles = ['dsh-web-mobile', 'dsh-chat-manager'];
|
|
||||||
const f = fixture(t, {
|
|
||||||
dependencies: {
|
|
||||||
'dsh-web-mobile': 'file:/usr/local/lib/dsh-mgr/dsh-web-mobile-3.1.0.tgz',
|
|
||||||
'dsh-free-search': 'file:/usr/local/lib/dsh-mgr/dsh-free-search-0.9.2.tgz',
|
|
||||||
'dsh-chat-manager': 'file:/usr/local/lib/dsh-mgr/dsh-chat-manager-1.5.16-dsh-mgr.1.tgz',
|
|
||||||
},
|
|
||||||
dsh: { profile: { bundles } },
|
|
||||||
});
|
|
||||||
const result = f.run();
|
|
||||||
assert.equal(result.changed, true);
|
|
||||||
assert.equal(readFileSync(result.backupPath, 'utf8'), f.original);
|
|
||||||
const current = JSON.parse(readFileSync(f.path));
|
|
||||||
for (const name of Object.keys(current.dependencies)) assert.equal(current.dependencies[name], BUILTIN_DEPENDENCIES[name]);
|
|
||||||
assert.deepEqual(current.dsh.profile.bundles, bundles);
|
|
||||||
assert.deepEqual(f.run(), { changed: false });
|
|
||||||
});
|
|
||||||
test('runtime-7.10 plugin references migrate to runtime-7.11 without changing bundle choices', t => {
|
|
||||||
const bundles = ['dsh-web-mobile', 'dsh-chat-manager'];
|
|
||||||
const f = fixture(t, {
|
|
||||||
dependencies: {
|
|
||||||
'dsh-web-mobile': 'file:/usr/local/lib/dsh-mgr/dsh-web-mobile-3.2.1.tgz',
|
|
||||||
'dsh-free-search': 'file:/usr/local/lib/dsh-mgr/dsh-free-search-0.9.15.tgz',
|
|
||||||
'dsh-chat-manager': 'file:/usr/local/lib/dsh-mgr/dsh-chat-manager-1.5.18-dsh-mgr.1.tgz',
|
|
||||||
},
|
|
||||||
dsh: { profile: { bundles } },
|
|
||||||
});
|
|
||||||
const result = f.run();
|
|
||||||
assert.equal(result.changed, true);
|
|
||||||
assert.equal(readFileSync(result.backupPath, 'utf8'), f.original);
|
|
||||||
const current = JSON.parse(readFileSync(f.path));
|
|
||||||
for (const name of Object.keys(current.dependencies)) assert.equal(current.dependencies[name], BUILTIN_DEPENDENCIES[name]);
|
|
||||||
assert.deepEqual(current.dsh.profile.bundles, bundles);
|
|
||||||
assert.deepEqual(f.run(), { changed: false });
|
|
||||||
});
|
|
||||||
function fixture(t, manifest) {
|
|
||||||
const root = mkdtempSync(join(tmpdir(), 'dsh-reconcile-'));
|
|
||||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
||||||
const profile = join(root, 'profiles/web');
|
|
||||||
const archiveDirectory = join(root, 'archives');
|
|
||||||
mkdirSync(profile, { recursive: true });
|
|
||||||
mkdirSync(archiveDirectory);
|
|
||||||
for (const reference of [...Object.values(BUILTIN_DEPENDENCIES), ...Object.values(BUILTIN_OVERRIDES)]) {
|
|
||||||
writeFileSync(join(archiveDirectory, basename(reference)), 'fixture');
|
|
||||||
}
|
|
||||||
const path = join(profile, 'package.json');
|
|
||||||
const original = typeof manifest === 'string' ? manifest : JSON.stringify(manifest, null, 4) + '\n';
|
|
||||||
writeFileSync(path, original);
|
|
||||||
return { root, profile, path, original, archiveDirectory, run: () => reconcileProfile(profile, { archiveDirectory }) };
|
|
||||||
}
|
|
||||||
|
|
||||||
test('migrates all sections, backs up exact bytes, replaces atomically with 0600, and is idempotent', t => {
|
|
||||||
const f = fixture(t, {
|
|
||||||
dependencies: { 'dsh-web-mobile': old('dsh-web-mobile') },
|
|
||||||
devDependencies: { 'dsh-chat-manager': old('dsh-chat-manager') },
|
|
||||||
optionalDependencies: { 'dsh-free-search': old('dsh-free-search') },
|
|
||||||
dsh: { profile: { bundles: [] } },
|
|
||||||
});
|
|
||||||
const originalLink = join(f.root, 'original-link');
|
|
||||||
linkSync(f.path, originalLink);
|
|
||||||
const first = f.run();
|
|
||||||
assert.equal(first.changed, true);
|
|
||||||
assert.equal(readFileSync(first.backupPath, 'utf8'), f.original);
|
|
||||||
assert.equal(readFileSync(originalLink, 'utf8'), f.original);
|
|
||||||
assert.equal(statSync(f.path).mode & 0o777, 0o600);
|
|
||||||
assert.equal(statSync(first.backupPath).mode & 0o777, 0o600);
|
|
||||||
const current = JSON.parse(readFileSync(f.path));
|
|
||||||
for (const section of ['dependencies', 'devDependencies', 'optionalDependencies']) {
|
|
||||||
for (const name of Object.keys(current[section])) assert.equal(current[section][name], BUILTIN_DEPENDENCIES[name]);
|
|
||||||
}
|
|
||||||
assert.deepEqual(current.pnpm.overrides, BUILTIN_OVERRIDES);
|
|
||||||
assert.deepEqual(current.dsh.profile.bundles, []);
|
|
||||||
const before = statSync(f.path);
|
|
||||||
assert.deepEqual(f.run(), { changed: false });
|
|
||||||
assert.equal(statSync(f.path).ino, before.ino);
|
|
||||||
assert.equal(statSync(f.path).mtimeMs, before.mtimeMs);
|
|
||||||
assert.equal(readdirSync(f.profile).length, 3);
|
|
||||||
assert.equal(readFileSync(join(f.profile, '.dsh-lockfile-pending'), 'utf8'), 'builtin profile migration\n');
|
|
||||||
writeFileSync(f.path, f.original);
|
|
||||||
assert.notEqual(f.run().backupPath, first.backupPath);
|
|
||||||
assert.equal(readFileSync(first.backupPath, 'utf8'), f.original);
|
|
||||||
});
|
|
||||||
|
|
||||||
function withSyncTrace(f, callback, failAt) {
|
|
||||||
const originals = Object.fromEntries(
|
|
||||||
['openSync', 'writeFileSync', 'fsyncSync', 'closeSync', 'renameSync'].map(name => [name, fs[name]]),
|
|
||||||
);
|
|
||||||
const descriptors = new Map();
|
|
||||||
const events = [];
|
|
||||||
let directoryOpens = 0;
|
|
||||||
fs.openSync = (path, ...args) => {
|
|
||||||
const fd = originals.openSync(path, ...args);
|
|
||||||
const target = path === f.profile ? `directory-${++directoryOpens}`
|
|
||||||
: String(path).startsWith(`${f.path}.backup-`) ? 'backup'
|
|
||||||
: String(path).startsWith(`${f.path}.tmp-`) ? 'temporary' : undefined;
|
|
||||||
if (target) {
|
|
||||||
descriptors.set(fd, target);
|
|
||||||
events.push(`open:${target}`);
|
|
||||||
}
|
|
||||||
return fd;
|
|
||||||
};
|
|
||||||
for (const [name, operation] of [['writeFileSync', 'write'], ['fsyncSync', 'fsync'], ['closeSync', 'close']]) {
|
|
||||||
fs[name] = (fd, ...args) => {
|
|
||||||
const target = descriptors.get(fd);
|
|
||||||
if (target) {
|
|
||||||
const event = `${operation}:${target}`;
|
|
||||||
events.push(event);
|
|
||||||
if (event === failAt) throw new Error(`injected ${event} failure`);
|
|
||||||
}
|
|
||||||
const result = originals[name](fd, ...args);
|
|
||||||
if (name === 'closeSync') descriptors.delete(fd);
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
fs.renameSync = (source, destination) => {
|
|
||||||
assert.ok(source.startsWith(`${f.path}.tmp-`));
|
|
||||||
assert.equal(destination, f.path);
|
|
||||||
events.push('rename:manifest');
|
|
||||||
return originals.renameSync(source, destination);
|
|
||||||
};
|
|
||||||
syncBuiltinESMExports();
|
|
||||||
try {
|
|
||||||
callback(events);
|
|
||||||
} finally {
|
|
||||||
Object.assign(fs, originals);
|
|
||||||
syncBuiltinESMExports();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const durableWriteOrder = [
|
|
||||||
'open:backup', 'write:backup', 'fsync:backup', 'close:backup',
|
|
||||||
'open:directory-1', 'fsync:directory-1', 'close:directory-1',
|
|
||||||
'open:temporary', 'write:temporary', 'fsync:temporary', 'close:temporary',
|
|
||||||
'open:directory-2', 'fsync:directory-2', 'close:directory-2',
|
|
||||||
'rename:manifest',
|
|
||||||
'open:directory-3', 'fsync:directory-3', 'close:directory-3',
|
|
||||||
];
|
|
||||||
|
|
||||||
test('syncs backup and directory before replacement, then syncs the renamed manifest directory', t => {
|
|
||||||
const f = fixture(t, { dependencies: { 'dsh-web-mobile': old('dsh-web-mobile') } });
|
|
||||||
withSyncTrace(f, events => {
|
|
||||||
const result = f.run();
|
|
||||||
assert.equal(result.changed, true);
|
|
||||||
assert.deepEqual(events, durableWriteOrder);
|
|
||||||
assert.equal(readFileSync(result.backupPath, 'utf8'), f.original);
|
|
||||||
assert.equal(JSON.parse(readFileSync(f.path)).dependencies['dsh-web-mobile'], BUILTIN_DEPENDENCIES['dsh-web-mobile']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const target of ['backup', 'directory-1', 'temporary']) {
|
|
||||||
test(`${target} fsync failure preserves the original manifest and does not rename`, t => {
|
|
||||||
const f = fixture(t, { dependencies: { 'dsh-web-mobile': old('dsh-web-mobile') } });
|
|
||||||
const before = statSync(f.path);
|
|
||||||
const failAt = `fsync:${target}`;
|
|
||||||
withSyncTrace(f, events => {
|
|
||||||
assert.throws(f.run, { message: `injected ${failAt} failure` });
|
|
||||||
assert.deepEqual(events, [
|
|
||||||
...durableWriteOrder.slice(0, durableWriteOrder.indexOf(failAt) + 1),
|
|
||||||
`close:${target}`,
|
|
||||||
]);
|
|
||||||
}, failAt);
|
|
||||||
assert.equal(readFileSync(f.path, 'utf8'), f.original);
|
|
||||||
assert.equal(statSync(f.path).ino, before.ino);
|
|
||||||
assert.equal(statSync(f.path).mtimeMs, before.mtimeMs);
|
|
||||||
const backups = readdirSync(f.profile).filter(name => name.startsWith('package.json.backup-'));
|
|
||||||
assert.equal(backups.length, 1);
|
|
||||||
assert.equal(readFileSync(join(f.profile, backups[0]), 'utf8'), f.original);
|
|
||||||
assert.deepEqual(readdirSync(f.profile).sort(), ['package.json', ...backups].sort());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test('preserves deleted plugins, disabled bundles, custom dependencies and overrides', t => {
|
|
||||||
const custom = {
|
|
||||||
dependencies: { 'dsh-chat-manager': old('dsh-chat-manager'), 'dsh-web-mobile': '^3.0.0', other: old('other') },
|
|
||||||
devDependencies: { 'dsh-free-search': 'file:/custom/dsh-free-search-0.1.0.tgz' },
|
|
||||||
optionalDependencies: { 'dsh-preview-download': 'file:/usr/local/lib/dsh-mgr/not-the-same-1.0.0.tgz' },
|
|
||||||
pnpm: { overrides: { 'dsh-chat-manager>clsx': '^2.0.0', unrelated: 'custom' }, other: true },
|
|
||||||
dsh: { profile: { bundles: ['user-plugin'] } },
|
|
||||||
};
|
|
||||||
const f = fixture(t, custom);
|
|
||||||
writeFileSync(join(f.profile, 'pnpm-workspace.yaml'), 'custom workspace');
|
|
||||||
writeFileSync(join(f.profile, 'cordis.patch.yml'), 'custom patch');
|
|
||||||
f.run();
|
|
||||||
const expected = structuredClone(custom);
|
|
||||||
expected.dependencies['dsh-chat-manager'] = BUILTIN_DEPENDENCIES['dsh-chat-manager'];
|
|
||||||
assert.deepEqual(JSON.parse(readFileSync(f.path)), expected);
|
|
||||||
assert.equal(readFileSync(join(f.profile, 'pnpm-workspace.yaml'), 'utf8'), 'custom workspace');
|
|
||||||
assert.equal(readFileSync(join(f.profile, 'cordis.patch.yml'), 'utf8'), 'custom patch');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('strict archive matching leaves non-semver references untouched', () => {
|
|
||||||
for (const version of ['latest', '01.0.0', '1.0', '1.0.0-01', '1.0.0.tgz/extra']) {
|
|
||||||
const input = { dependencies: { 'dsh-web-mobile': 'file:/usr/local/lib/dsh-mgr/dsh-web-mobile-' + version + '.tgz' } };
|
|
||||||
assert.equal(reconcileManifest(input).changed, false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('current defaults and user-only manifests remain byte-identical', t => {
|
|
||||||
for (const manifest of [
|
|
||||||
{ dependencies: BUILTIN_DEPENDENCIES, pnpm: { overrides: BUILTIN_OVERRIDES } },
|
|
||||||
{ dependencies: { 'dsh-chat-manager': '^1.0.0' }, custom: true },
|
|
||||||
{ dsh: { profile: { bundles: [] } } },
|
|
||||||
]) {
|
|
||||||
const f = fixture(t, manifest);
|
|
||||||
assert.equal(f.run().changed, false);
|
|
||||||
assert.equal(readFileSync(f.path, 'utf8'), f.original);
|
|
||||||
assert.deepEqual(readdirSync(f.profile), ['package.json']);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('missing archives fail before writes, even when already current', t => {
|
|
||||||
for (const reference of [old('dsh-web-mobile'), BUILTIN_DEPENDENCIES['dsh-web-mobile']]) {
|
|
||||||
const f = fixture(t, { dependencies: { 'dsh-web-mobile': reference } });
|
|
||||||
rmSync(join(f.archiveDirectory, basename(BUILTIN_DEPENDENCIES['dsh-web-mobile'])));
|
|
||||||
assert.throws(f.run, /ENOENT/);
|
|
||||||
assert.equal(readFileSync(f.path, 'utf8'), f.original);
|
|
||||||
assert.deepEqual(readdirSync(f.profile), ['package.json']);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('invalid JSON and malformed sections fail without changing original', t => {
|
|
||||||
for (const input of ['{ broken', 'null', '[]', JSON.stringify({ dependencies: [] }), JSON.stringify({ pnpm: { overrides: null } })]) {
|
|
||||||
const f = fixture(t, input);
|
|
||||||
assert.throws(f.run);
|
|
||||||
assert.equal(readFileSync(f.path, 'utf8'), input);
|
|
||||||
assert.deepEqual(readdirSync(f.profile), ['package.json']);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects manifest/workspace symlinks, dangling links and directories', t => {
|
|
||||||
for (const name of ['package.json', 'pnpm-workspace.yaml', 'pnpm-lock.yaml', '.dsh-lockfile-pending']) {
|
|
||||||
for (const kind of ['symlink', 'dangling', 'directory']) {
|
|
||||||
const f = fixture(t, { dependencies: { 'dsh-web-mobile': old('dsh-web-mobile') } });
|
|
||||||
const target = join(f.root, 'outside');
|
|
||||||
if (kind === 'symlink') writeFileSync(target, f.original);
|
|
||||||
const path = join(f.profile, name);
|
|
||||||
rmSync(path, { force: true });
|
|
||||||
if (kind === 'directory') mkdirSync(path);
|
|
||||||
else symlinkSync(target, path);
|
|
||||||
const before = readdirSync(f.profile);
|
|
||||||
assert.throws(f.run, /unsafe profile file/);
|
|
||||||
assert.deepEqual(readdirSync(f.profile), before);
|
|
||||||
if (kind === 'symlink') assert.equal(readFileSync(target, 'utf8'), f.original);
|
|
||||||
if (name !== 'package.json') assert.equal(readFileSync(f.path, 'utf8'), f.original);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects symlinked profile and ancestors', t => {
|
|
||||||
const f = fixture(t, '{}');
|
|
||||||
const link = join(f.root, 'linked');
|
|
||||||
symlinkSync(join(f.root, 'profiles'), link);
|
|
||||||
assert.throws(() => reconcileProfile(join(link, 'web'), { archiveDirectory: f.archiveDirectory }), /unsafe profile directory/);
|
|
||||||
const direct = join(f.root, 'direct');
|
|
||||||
symlinkSync(f.profile, direct);
|
|
||||||
assert.throws(() => reconcileProfile(direct), /unsafe profile directory/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('init migrates before restoring workspace and rejects unsafe workspace before writes', t => {
|
|
||||||
const f = fixture(t, { optionalDependencies: { 'dsh-web-mobile': old('dsh-web-mobile') } });
|
|
||||||
const run = () => initProfile(f.root, { archiveDirectory: f.archiveDirectory });
|
|
||||||
symlinkSync(join(f.root, 'missing'), join(f.profile, 'pnpm-workspace.yaml'));
|
|
||||||
assert.throws(run, /unsafe profile file/);
|
|
||||||
assert.equal(readFileSync(f.path, 'utf8'), f.original);
|
|
||||||
rmSync(join(f.profile, 'pnpm-workspace.yaml'));
|
|
||||||
run();
|
|
||||||
assert.equal(JSON.parse(readFileSync(f.path)).optionalDependencies['dsh-web-mobile'], BUILTIN_DEPENDENCIES['dsh-web-mobile']);
|
|
||||||
assert.match(readFileSync(join(f.profile, 'pnpm-workspace.yaml'), 'utf8'), /autoInstallPeers: false/);
|
|
||||||
});
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
"""Bounded, non-extracting Office container validation; Python stdlib only."""
|
|
||||||
import sys
|
|
||||||
import zipfile
|
|
||||||
|
|
||||||
path, ext, entries, maximum = sys.argv[1:]
|
|
||||||
if ext in ('doc', 'xls', 'ppt'):
|
|
||||||
with open(path, 'rb') as source:
|
|
||||||
if source.read(8) != bytes.fromhex('d0cf11e0a1b11ae1'):
|
|
||||||
raise ValueError('not OLE')
|
|
||||||
else:
|
|
||||||
expected = {'docx': 'word/document.xml', 'xlsx': 'xl/workbook.xml', 'pptx': 'ppt/presentation.xml'}[ext]
|
|
||||||
with zipfile.ZipFile(path) as archive:
|
|
||||||
info = archive.infolist()
|
|
||||||
names = [entry.filename for entry in info]
|
|
||||||
if len(info) > int(entries) or len(set(names)) != len(names):
|
|
||||||
raise ValueError('entry limit or duplicate')
|
|
||||||
if expected not in names or '[Content_Types].xml' not in names:
|
|
||||||
raise ValueError('format mismatch')
|
|
||||||
if sum(entry.file_size for entry in info) > int(maximum):
|
|
||||||
raise ValueError('uncompressed size limit')
|
|
||||||
total = 0
|
|
||||||
for entry in info:
|
|
||||||
if entry.flag_bits & 1:
|
|
||||||
raise ValueError('encrypted')
|
|
||||||
with archive.open(entry) as stream:
|
|
||||||
while chunk := stream.read(65536):
|
|
||||||
total += len(chunk)
|
|
||||||
if total > int(maximum):
|
|
||||||
raise ValueError('uncompressed size limit')
|
|
||||||
@@ -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
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"schema_version": 1,
|
|
||||||
"release_id": "runtime-7.11",
|
|
||||||
"version": "0.2.1-rc.5",
|
|
||||||
"deployment_schema": 7,
|
|
||||||
"platforms": ["linux/amd64", "linux/arm64"]
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||