Author SHA1 Message Date
m1saka 169e1ffdc2 chore(runtime): upgrade DSH and bundled plugins for runtime-7.11 2026-10-30 17:08:16 +08:00
m1saka 0389555d76 chore(runtime): upgrade DSH and bundled plugins for runtime-7.10 2026-10-26 11:22:36 +08:00
m1saka 9961d42a2f fix(runtime): remove obsolete PDF browser compatibility patch 2026-10-18 19:24:17 +08:00
m1saka e36f3c1fdd fix(runtime): support PDF preview on Chrome 143 2026-10-18 15:51:26 +08:00
m1saka 605a48adb0 Revert "fix(runtime): polyfill PDF.js Map APIs for older WebViews"
This reverts commit e84906652e.
2026-10-18 14:22:40 +08:00
m1saka e84906652e fix(runtime): polyfill PDF.js Map APIs for older WebViews 2026-10-18 13:16:32 +08:00
m1saka b13b71b27c fix(runtime): hide successful plugin install output 2026-10-18 12:43:21 +08:00
m1saka b2d97dfda2 fix(runtime): synchronize lockfile after builtin plugin migration 2026-10-18 11:44:15 +08:00
m1saka 2e31e74972 feat(runtime): upgrade DSH and bundled plugins with startup migration 2026-10-18 02:14:27 +08:00
m1saka eb7e809999 fix(preview-download): declare remote service dependency 2026-10-17 03:31:32 +08:00
m1saka bbdee632f6 feat(runtime): add native office preview and download plugin 2026-10-17 03:03:32 +08:00
m1saka 65d118a145 fix(runtime): download pinned MiSans fonts individually for 7.5 2026-10-16 16:32:58 +08:00
m1saka 39cd470f92 chore(release): bump runtime to 7.4 2026-10-16 16:16:42 +08:00
m1saka 1fbb2a6fed feat(runtime): add document tools, MiSans and patched chat manager 2026-10-16 16:13:35 +08:00
m1saka a6bf83f037 chore: ignore Android build leftovers and signing files 2026-10-15 16:51:27 +08:00
m1saka ed6467d132 fix: enable host settings persistence in runtime-7.3 2026-10-15 10:35:22 +08:00
m1saka 1fda59fb09 feat: add ARM64 runtime release configuration 2026-10-14 19:42:34 +08:00
m1saka 05ecfc88c0 chore: ignore local artifacts in runtime release branch 2026-10-14 15:46:49 +08:00
m1saka e72d73c497 release: add runtime-7.1 build files 2026-10-14 15:44:08 +08:00
m1saka ab76be422d chore: initialize empty dockerfile branch 2026-10-14 15:14:25 +08:00
84 changed files with 1824 additions and 2415 deletions
-17
View File
@@ -1,17 +0,0 @@
.git
.gitignore
web/
.mimocode/
build/
dist/
*.apk
*.aab
signing/
**/*.jks
**/*.keystore
**/*.p12
**/*.pfx
**/keystore.properties
**/signing.properties
.env
.env.*
+34 -87
View File
@@ -1,101 +1,48 @@
# Miscellaneous
*.class
# Local tooling and browser artifacts
/.mimocode/
.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
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history/
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
# Local IDE files
.idea/
*.iml
# VS Code local state
.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
# Generated files left when switching from android-app
/.dart_tool/
/.flutter-plugins-dependencies
/build/
/coverage/
/android/.gradle/
/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
# 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
# Android packages and signing credentials
*.apk
*.aab
*.ap_
*.dex
# Signing keys and local credentials
*.jks
*.keystore
*.p12
*.pfx
keystore.properties
signing.properties
.env
.env.*
!.env.example
!.env.sample
# OS metadata (already listed above, kept for clarity)
Thumbs.db
Desktop.ini
# Local agent/browser tooling
.mimocode/
.playwright-cli/
/output/playwright/
# Dependencies, build outputs and caches left by the web branch
node_modules/
.vite/
dist/
*.tsbuildinfo
# Locally compiled manager binary
/bin/dsh-mgr
# Keep the Gradle Wrapper in version control
!gradle/wrapper/gradle-wrapper.jar
!gradle/wrapper/gradle-wrapper.properties
/android/key.properties
-30
View File
@@ -1,30 +0,0 @@
# 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'
-62
View File
@@ -1,62 +0,0 @@
# 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
+147 -52
View File
@@ -1,76 +1,171 @@
# DSH 工作台(Android)
# DSH Runtime Release
DSH Web Manager 的 Android 客户端:使用随 APK 打包的 Mozilla GeckoView 内核,不依赖手机的系统 WebView。全部构建在 Docker 内完成,宿主机无需安装 Flutter/Android SDK。
## runtime-7.11:DSH rc.5 与内置插件更新(待运行验收)
## 特性
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 不变。
- 内置 GeckoView:Flutter 平台视图连接原生 GeckoSession,各设备使用同一发布版本的内核
- 登录状态持久化:使用非隐私 Gecko profile,持久 Cookie + DOM 存储跨重启保留(会话有效期仍由站点控制)
- 全面屏适配:edge-to-edge、刘海/挖孔 `shortEdges`、深色启动底色无白闪
- 文件上传:`<input type="file">` 走系统文件选择器(工作区上传、备份导入)
- 普通文件上传通过系统授权 URI 流式复制到应用私有临时目录后交给 Gecko,兼容下载目录和文档提供程序;需要足够的本地临时空间。已交给页面的临时文件保留至浏览会话关闭,避免提前删除中断上传。
- 文件下载:直接保存 Gecko 已认证请求的响应流,由系统“另存为”选择位置,不通过系统 WebView 获取 Cookie
- 下载状态通知:保存时显示进度和已下载大小,完成、失败或取消后更新状态;通知不提供打开文件或目录的操作。未知总大小时显示不定进度。Android 13+ 首次下载请求通知权限,拒绝不会阻止下载。
- 返回键:优先回退页面历史,无历史时退出应用
- 加载失败重试页、JS 原生对话框桥接
同步安装归档 SHA-512、新 profile、已有内置归档迁移目标及精确宿主版本门禁,保留自定义来源、启用选择和备份机制。真实发布包的 Chat Manager 客户端补丁、原生设置接口及 Office converter 导入检查通过。48 项单元测试通过,新增 runtime-7.10 插件引用迁移、备份和幂等回归。完整镜像与运行验收结果另行记录;未提交、推送或修改线上实例。
## 构建
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 镜像,旧镜像锁文件迁移集成测试未执行(配置迁移单元回归已通过)。
前置:本机可用 Docker(构建通过代理访问 Google/Gradle 源时需在环境变量中提供 `http_proxy`/`https_proxy`)。
## runtime-7.10:DSH rc.3 与内置插件更新(待运行验收)
Java/Gradle 不自动使用上述代理变量。需要时另外设置 `JAVA_TOOL_OPTIONS`,例如 `-Dhttp.proxyHost=代理主机 -Dhttp.proxyPort=端口 -Dhttps.proxyHost=代理主机 -Dhttps.proxyPort=端口`;`build.sh` 会将其传入镜像构建和 APK 构建容器。代理必须能从容器访问,不要把宿主机的 `127.0.0.1` 直接当成容器代理地址。Java 会输出此变量,切勿在其中放入密码或其他秘密。
沿用此前的 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 已核对并更新。
```sh
./build.sh https://panel.example.com --keystore /安全路径/dsh-release.jks --alias dsh-release
```
同步新 profile 和已有内置归档引用的迁移目标,保留自定义来源、插件启用选择、备份及锁文件同步机制。设置和 Office 补丁版本门禁对齐 rc.3,真实发布包的原生设置接口与 Office converter 导入入口检查通过。Chat Manager 的三处客户端适配仍精确匹配,新版真实源码补丁及语法检查通过;没有修改认证机制。mobile/free-search 的预发布 peer 例外仍只允许精确 rc.3 且满足 includePrerelease 范围。
脚本做两件事:
本次 `node --test image/*.test.mjs` 共 46 项通过,包含新增的 runtime-7.9 实际插件版本引用迁移、备份及幂等回归。单元测试和源码核查不等同于完整运行兼容性证明;浏览器交互、真实搜索、设置保存、Office 实际转换、终端原生模块、容器离线启动及 ARM64 仍需本版本验收。未提交、推送或修改线上实例。
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`。
锁文件集成脚本默认验证 `dsh-web:runtime-7.9-amd64` → `dsh-web:runtime-7.10-amd64`,也可使用 `DSH_OLD_IMAGE` / `DSH_NEW_IMAGE` 指定已构建的镜像:`node image/lockfile-upgrade-smoke.mjs`。须先准备两个镜像;本次尚未完成该集成检查,不沿用旧发布的通过结论。
产物在 `build/app/outputs/flutter-apk/`:
后续运行验收发现 preview-download 安装器仍限定 rc.2,已核对 rc.3 的操作插槽及 readBytes 接口并修正精确宿主门禁。新增安装器回归直接校验发布清单中的宿主版本可以完成打包调用、未经验证版本在写入前被拒绝;目前全部 47 项回归通过。
- `app-arm64-v8a-release.apk`:主流 64 位手机(一般装这个)
- `app-armeabi-v7a-release.apk`:老设备
- `app-x86_64-release.apk`:模拟器/ChromeOS
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 尚未验收,不代表可直接发布。
## 配置
## runtime-7.9:DSH next 与启动配置迁移
站点地址由 `build.sh` 的第一个参数传入,通过 `--dart-define=DSH_SITE_URL=...` 编译进 APK,无需修改源码,应用启动时也无需输入。必须使用完整 HTTPS URL,可带端口和路径,不能带账号密码、空白、查询参数或片段。脚本在编译前验证地址并运行测试;地址不会写入 Docker 镜像。示例域名仅供说明,请替换为真实站点。
锁文件同步修复:内置依赖迁移或补建 workspace 配置前,先持久化 `.dsh-lockfile-pending` 标记。仅带标记的启动安装显式传入 `--no-frozen-lockfile`,同步 `pnpm-lock.yaml`;成功后清除标记,后续启动恢复 CI 默认冻结检查。安装失败或超时保留标记并阻止应用启动,下次启动继续同步,不自动恢复旧 manifest。已有 manifest 备份机制保持不变。若旧版失败迁移已经留下“新 manifest+旧锁文件”且没有标记,应先恢复匹配的旧 profile 备份再升级;不要直接删除锁文件。
需要强制重建工具链镜像时,在上述命令后加 `--no-cache`。更换地址必须重新打包,已有 APK 不会自动更新。
本次修复验证:45 项自动化测试通过;`node image/lockfile-upgrade-smoke.mjs` 使用本地 `dsh-upgrade-test:runtime-7.8` 与 `dsh-web:runtime-7.9-amd64` 镜像、独立临时卷、禁网非 root 容器,挂载当前修复脚本,验证真实旧锁文件迁移、随后冻结安装成功及重启锁文件哈希不变。脚本结束删除测试卷。本次未重跑面板 HTTP 升级接口或首页验收,未发布新镜像。
## Release 签名
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` 保持不变。
`--keystore` 指向包含私钥的 JKS/PKCS12 密钥库(不是网站 TLS 证书,也不是仅含公钥的 .cer);`--alias` 指定私钥条目。相对路径基于运行命令时的目录。脚本需要 Bash,密码默认隐藏输入,私钥密码回车时沿用密钥库密码。
现有 runtime 每次启动时检查 `profiles/web/package.json`,不另加容器、不联网追踪最新版。仅将 dependencies/devDependencies/optionalDependencies 中同名 `file:/usr/local/lib/dsh-mgr/<包名>-<版本>.tgz` 引用对齐镜像内置清单,并补齐相关缺失的 pnpm overrides。保留自定义安装来源、自定义 overrides、bundles、已删除的插件和其他配置。目标归档缺失、无效配置或不安全的符号链接会阻止启动。
CI 可通过 secret 管理器注入 `DSH_STORE_PASSWORD` 和 `DSH_KEY_PASSWORD` 环境变量;后者省略时沿用前者。不支持把密码作为命令参数。密码会传入临时容器环境,Docker 管理员可查看,因此仅在可信的 Docker 主机上构建。不要使用 shell 跟踪(`bash -x`)或把密码打印到日志。
只有配置变化才写入,原文保存在同目录 `package.json.backup-<UUID>`,新文件通过原子替换写入,权限 `0600`;重复启动不会反复改写。恢复时先停止实例并备份现状,再恢复对应备份;要回退旧内置版本必须同时使用匹配的旧镜像,否则下一次启动会再次对齐当前镜像。迁移不提供针对同 UID 恶意并发修改目录的沙箱保护。
密钥库以只读 bind mount 提供给容器,不复制进镜像。推荐把密钥库放在仓库外;常见密钥扩展名及 signing/ 已从 Docker 构建上下文排除。密钥库和密码必须备份,覆盖升级需要保持包名与签名一致。
新版设置 UI 已原生使用 host settings describe/mutate,构建只验证接口、不再应用旧 persistence 替换。Office 转换补丁仍限定精确宿主及插件版本;下载 slot/readBytes 和 workspaceRegistry 接口已核对上游源码。mobile/free-search 的预发布 peer 例外仅允许精确 `0.2.1-rc.2` 的 DSH 包且仍要求满足原版本范围(includePrerelease),其他依赖照常严格校验。
## 注意事项
当前自动化回归 40 项通过;AMD64 镜像已构建,禁网 UID 1000 新 profile 安装、HTTP 未认证 401、旧 mobile/free-search/chat-manager 引用迁移、备份与幂等检查通过。Office 的已安装 DSH convertBytes 集成及 DOCX/PPTX/DOC/PPT 并发、限制、超时、清理测试通过。pnpm 会提示由宿主提供的 peer 缺失/范围冲突;这不是浏览器交互兼容性证明。浏览器完整交互、真实搜索结果、设置保存、终端原生模块和 ARM64 尚未验收。以下旧发布记录中的“已有 profile 不自动改写”仅适用于旧版本。
- **系统要求**:当前 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 的传统图标;各密度资源均已生成。
## runtime-7.8:修复下载按钮服务依赖
## 内核升级回归
下载插件升级为 dsh-preview-download@1.0.1,补齐 remote 服务声明,修复点击时在发送请求前抛出 cannot get property "remote" without inject 的问题。同步更新内置包和新 profile 引用,新增注册回调依赖回归测试。此修复不处理面板 401、Office 504 或 plugins/events HTTP/2 连接错误。
发布前除 `flutter analyze`、`flutter test` 和 APK 构建外,应在真实 Android 设备上检查:
已有实例升级镜像时,必须把 profile 中 dsh-preview-download 依赖的归档路径从 dsh-preview-download-1.0.0.tgz 改为 dsh-preview-download-1.0.1.tgz,保留其他配置和 bundles,重启后刷新页面;已有 profile 不自动改写。尚未完成线上浏览器下载验收。
- 登录后重启应用,确认站点的持久登录状态及本地存储保留。
- 工作区单文件/多文件上传、选择器取消、配置备份导入。
- 从下载目录、内部存储及云盘提供程序选择文件;验证中文名、同名多选、空文件、大文件、复制失败及复制中退出。目录上传仍走 Gecko 原有目录 URI 处理路径,需单独验收。
- 需要登录的文件、目录 ZIP、配置备份下载,检查保存内容及取消流程。
- 下载通知:已知/未知总大小、多个下载、断网/保存失败、退出取消;Android 13+ 通知权限允许和拒绝,以及系统关闭通知的情况。
- JS alert/confirm/prompt、输入法、页面滚动、SPA 返回键、横竖屏及前后台切换。
- 断网重试与无效证书拒绝;确认没有绕过 TLS 校验。
## runtime-7.7:预览下载插件
Flutter 桥接单测不运行 Gecko 内核,不能替代上述真机测试。
内置独立插件 dsh-preview-download@1.0.0,在文档预览工具栏提供「下载原文件」。使用正式 sidebar.right.tab.document.actions 插槽,不修改上游 UI、不扫描 DOM、不增加服务端下载接口。通过当前会话 workspaceFiles.readBytes 获取原始字节,沿用服务端权限和完整文件大小上限;支持中文文件名、下载中状态、失败提示及切换文件/卸载时取消读取。下载的是原始文件,不是 Office 转换出的 PDF。文件元数据无法读取时不会显示工具栏按钮。
新建空 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 或面板。
-33
View File
@@ -1,33 +0,0 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- build/**
- android/**
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
-14
View File
@@ -1,14 +0,0 @@
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
-80
View File
@@ -1,80 +0,0 @@
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")
}
@@ -1,7 +0,0 @@
<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>
-48
View File
@@ -1,48 +0,0 @@
<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>
@@ -1,55 +0,0 @@
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"
}
}
@@ -1,30 +0,0 @@
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
}
}
@@ -1,425 +0,0 @@
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)
}
}
@@ -1,119 +0,0 @@
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()
}
}
@@ -1,144 +0,0 @@
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
}
}
}
@@ -1,5 +0,0 @@
<?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>
@@ -1,17 +0,0 @@
<?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>
@@ -1,5 +0,0 @@
<?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>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

@@ -1,18 +0,0 @@
<?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>
@@ -1,13 +0,0 @@
<?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>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 站点主题色(与 index.html theme-color 一致) -->
<color name="dsh_theme">#FF152B36</color>
</resources>
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="launcher_background">#E3EEF6</color>
</resources>
@@ -1,18 +0,0 @@
<?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>
@@ -1,7 +0,0 @@
<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>
@@ -1,55 +0,0 @@
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)
}
}
@@ -1,240 +0,0 @@
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())
}
}
-25
View File
@@ -1,25 +0,0 @@
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)
}
-6
View File
@@ -1,6 +0,0 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This newDsl flag was added by the Flutter template
android.newDsl=false
# This builtInKotlin flag was added by the Flutter template
android.builtInKotlin=false
-5
View File
@@ -1,5 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
-26
View File
@@ -1,26 +0,0 @@
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")
-90
View File
@@ -1,90 +0,0 @@
#!/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
+10
View File
@@ -0,0 +1,10 @@
.git
**/.git
.env
.env.*
**/.env
**/.env.*
node_modules
**/node_modules
*.test.mjs
embed.go
+7
View File
@@ -0,0 +1,7 @@
{
admin off
auto_https off
}
:8080 {
reverse_proxy 127.0.0.1:3080
}
+47
View File
@@ -0,0 +1,47 @@
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"]
+14
View File
@@ -0,0 +1,14 @@
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.
+55
View File
@@ -0,0 +1,55 @@
#!/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"
+85
View File
@@ -0,0 +1,85 @@
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);
}
});
+38
View File
@@ -0,0 +1,38 @@
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);
}
+108
View File
@@ -0,0 +1,108 @@
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});}
});
+68
View File
@@ -0,0 +1,68 @@
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)');
+40
View File
@@ -0,0 +1,40 @@
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)');
+32
View File
@@ -0,0 +1,32 @@
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();
}
+39
View File
@@ -0,0 +1,39 @@
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'));
});
+29
View File
@@ -0,0 +1,29 @@
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)');
+44
View File
@@ -0,0 +1,44 @@
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)');
+21
View File
@@ -0,0 +1,21 @@
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();
+8
View File
@@ -0,0 +1,8 @@
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']);
+12
View File
@@ -0,0 +1,12 @@
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']);
+25
View File
@@ -0,0 +1,25 @@
// 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]);
}
+119
View File
@@ -0,0 +1,119 @@
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]); }
};
}
+50
View File
@@ -0,0 +1,50 @@
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 }); }
});
+47
View File
@@ -0,0 +1,47 @@
// 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 }); }
+10
View File
@@ -0,0 +1,10 @@
"""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)
+24
View File
@@ -0,0 +1,24 @@
// 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)');
}
+44
View File
@@ -0,0 +1,44 @@
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/);
});
+25
View File
@@ -0,0 +1,25 @@
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'));
});
+95
View File
@@ -0,0 +1,95 @@
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);
});
+77
View File
@@ -0,0 +1,77 @@
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
};
} });
+3
View File
@@ -0,0 +1,3 @@
- insert:
- id: dsh-preview-download
name: dsh-preview-download
+3
View File
@@ -0,0 +1,3 @@
// Client-only extension: no new server endpoints or authorization changes.
export const name = 'dsh-preview-download';
export function apply() {}
+15
View File
@@ -0,0 +1,15 @@
{
"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"]
}
}
}
+133
View File
@@ -0,0 +1,133 @@
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 };
}
+280
View File
@@ -0,0 +1,280 @@
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/);
});
+29
View File
@@ -0,0 +1,29 @@
"""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')
-26
View File
@@ -1,26 +0,0 @@
/// 全局配置。
///
/// [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;
-209
View File
@@ -1,209 +0,0 @@
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('重试'),
),
],
),
),
),
);
}
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

-205
View File
@@ -1,205 +0,0 @@
# 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"
-87
View File
@@ -1,87 +0,0 @@
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
+7
View File
@@ -0,0 +1,7 @@
{
"schema_version": 1,
"release_id": "runtime-7.11",
"version": "0.2.1-rc.5",
"deployment_schema": 7,
"platforms": ["linux/amd64", "linux/arm64"]
}
-32
View File
@@ -1,32 +0,0 @@
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);
});
}
-97
View File
@@ -1,97 +0,0 @@
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);
});
}
-12
View File
@@ -1,12 +0,0 @@
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;
}
}