Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
169e1ffdc2 | ||
|
|
0389555d76 | ||
|
|
9961d42a2f | ||
|
|
e36f3c1fdd | ||
|
|
605a48adb0 | ||
|
|
e84906652e | ||
|
|
b13b71b27c | ||
|
|
b2d97dfda2 | ||
|
|
2e31e74972 | ||
|
|
eb7e809999 | ||
|
|
bbdee632f6 | ||
|
|
65d118a145 | ||
|
|
39cd470f92 | ||
|
|
1fbb2a6fed | ||
|
|
a6bf83f037 | ||
|
|
ed6467d132 | ||
|
|
1fda59fb09 | ||
|
|
05ecfc88c0 | ||
|
|
e72d73c497 | ||
|
|
ab76be422d |
@@ -1,17 +0,0 @@
|
||||
.git
|
||||
.gitignore
|
||||
web/
|
||||
.mimocode/
|
||||
build/
|
||||
dist/
|
||||
*.apk
|
||||
*.aab
|
||||
signing/
|
||||
**/*.jks
|
||||
**/*.keystore
|
||||
**/*.p12
|
||||
**/*.pfx
|
||||
**/keystore.properties
|
||||
**/signing.properties
|
||||
.env
|
||||
.env.*
|
||||
@@ -1,95 +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/
|
||||
|
||||
# Dependencies and caches left by the web branch
|
||||
node_modules/
|
||||
.vite/
|
||||
|
||||
# Keep the Gradle Wrapper in version control
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
!gradle/wrapper/gradle-wrapper.properties
|
||||
/android/key.properties
|
||||
|
||||
@@ -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'
|
||||
@@ -1,60 +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" > /dev/null
|
||||
|
||||
RUN flutter config --android-sdk "${ANDROID_HOME}" \
|
||||
&& flutter --disable-analytics \
|
||||
&& flutter precache --android \
|
||||
&& flutter doctor
|
||||
|
||||
# 预热:跑一次与应用相同依赖的默认模板构建,把 Gradle 发行版/AGP/AndroidX
|
||||
# 组件、插件 AAR 和 SDK 平台下载进镜像,之后挂载源码的正式构建不再重复下载
|
||||
RUN flutter create --org dev.dsh --project-name warmup /tmp/warmup \
|
||||
&& cd /tmp/warmup \
|
||||
&& flutter pub add webview_flutter webview_flutter_android file_selector \
|
||||
&& flutter build apk --release \
|
||||
&& cd / && rm -rf /tmp/warmup \
|
||||
&& flutter doctor
|
||||
|
||||
WORKDIR /project
|
||||
@@ -1,53 +1,171 @@
|
||||
# DSH 工作台(Android)
|
||||
# DSH Runtime Release
|
||||
|
||||
DSH Web Manager 的 Android 客户端:基于系统 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 不变。
|
||||
|
||||
- 系统 WebView(`webview_flutter`),不打包浏览器内核,体积小
|
||||
- 登录状态持久化:系统 WebView 的 Cookie + DOM 存储跨重启保留
|
||||
- 全面屏适配:edge-to-edge、刘海/挖孔 `shortEdges`、深色启动底色无白闪
|
||||
- 文件上传:`<input type="file">` 走系统文件选择器(工作区上传、备份导入)
|
||||
- 文件下载:拦截面板下载导航,交给系统 DownloadManager(携带登录 Cookie)
|
||||
- 返回键:优先回退页面历史,无历史时退出应用
|
||||
- 加载失败重试页、JS 原生对话框(alert/confirm)桥接
|
||||
同步安装归档 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 与内置插件更新(待运行验收)
|
||||
|
||||
```sh
|
||||
./build.sh https://panel.example.com --keystore /安全路径/dsh-release.jks --alias dsh-release
|
||||
```
|
||||
沿用此前的 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 已核对并更新。
|
||||
|
||||
脚本做两件事:
|
||||
同步新 profile 和已有内置归档引用的迁移目标,保留自定义来源、插件启用选择、备份及锁文件同步机制。设置和 Office 补丁版本门禁对齐 rc.3,真实发布包的原生设置接口与 Office converter 导入入口检查通过。Chat Manager 的三处客户端适配仍精确匹配,新版真实源码补丁及语法检查通过;没有修改认证机制。mobile/free-search 的预发布 peer 例外仍只允许精确 rc.3 且满足 includePrerelease 范围。
|
||||
|
||||
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`。
|
||||
本次 `node --test image/*.test.mjs` 共 46 项通过,包含新增的 runtime-7.9 实际插件版本引用迁移、备份及幂等回归。单元测试和源码核查不等同于完整运行兼容性证明;浏览器交互、真实搜索、设置保存、Office 实际转换、终端原生模块、容器离线启动及 ARM64 仍需本版本验收。未提交、推送或修改线上实例。
|
||||
|
||||
产物在 `build/app/outputs/flutter-apk/`:
|
||||
锁文件集成脚本默认验证 `dsh-web:runtime-7.9-amd64` → `dsh-web:runtime-7.10-amd64`,也可使用 `DSH_OLD_IMAGE` / `DSH_NEW_IMAGE` 指定已构建的镜像:`node image/lockfile-upgrade-smoke.mjs`。须先准备两个镜像;本次尚未完成该集成检查,不沿用旧发布的通过结论。
|
||||
|
||||
- `app-arm64-v8a-release.apk`:主流 64 位手机(一般装这个)
|
||||
- `app-armeabi-v7a-release.apk`:老设备
|
||||
- `app-x86_64-release.apk`:模拟器/ChromeOS
|
||||
后续运行验收发现 preview-download 安装器仍限定 rc.2,已核对 rc.3 的操作插槽及 readBytes 接口并修正精确宿主门禁。新增安装器回归直接校验发布清单中的宿主版本可以完成打包调用、未经验证版本在写入前被拒绝;目前全部 47 项回归通过。
|
||||
|
||||
## 配置
|
||||
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 尚未验收,不代表可直接发布。
|
||||
|
||||
站点地址由 `build.sh` 的第一个参数传入,通过 `--dart-define=DSH_SITE_URL=...` 编译进 APK,无需修改源码,应用启动时也无需输入。必须使用完整 HTTPS URL,可带端口和路径,不能带账号密码、空白、查询参数或片段。脚本在编译前验证地址并运行测试;地址不会写入 Docker 镜像。示例域名仅供说明,请替换为真实站点。
|
||||
## runtime-7.9:DSH next 与启动配置迁移
|
||||
|
||||
需要强制重建工具链镜像时,在上述命令后加 `--no-cache`。更换地址必须重新打包,已有 APK 不会自动更新。
|
||||
锁文件同步修复:内置依赖迁移或补建 workspace 配置前,先持久化 `.dsh-lockfile-pending` 标记。仅带标记的启动安装显式传入 `--no-frozen-lockfile`,同步 `pnpm-lock.yaml`;成功后清除标记,后续启动恢复 CI 默认冻结检查。安装失败或超时保留标记并阻止应用启动,下次启动继续同步,不自动恢复旧 manifest。已有 manifest 备份机制保持不变。若旧版失败迁移已经留下“新 manifest+旧锁文件”且没有标记,应先恢复匹配的旧 profile 备份再升级;不要直接删除锁文件。
|
||||
|
||||
## Release 签名
|
||||
本次修复验证:45 项自动化测试通过;`node image/lockfile-upgrade-smoke.mjs` 使用本地 `dsh-upgrade-test:runtime-7.8` 与 `dsh-web:runtime-7.9-amd64` 镜像、独立临时卷、禁网非 root 容器,挂载当前修复脚本,验证真实旧锁文件迁移、随后冻结安装成功及重启锁文件哈希不变。脚本结束删除测试卷。本次未重跑面板 HTTP 升级接口或首页验收,未发布新镜像。
|
||||
|
||||
`--keystore` 指向包含私钥的 JKS/PKCS12 密钥库(不是网站 TLS 证书,也不是仅含公钥的 .cer);`--alias` 指定私钥条目。相对路径基于运行命令时的目录。脚本需要 Bash,密码默认隐藏输入,私钥密码回车时沿用密钥库密码。
|
||||
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` 保持不变。
|
||||
|
||||
CI 可通过 secret 管理器注入 `DSH_STORE_PASSWORD` 和 `DSH_KEY_PASSWORD` 环境变量;后者省略时沿用前者。不支持把密码作为命令参数。密码会传入临时容器环境,Docker 管理员可查看,因此仅在可信的 Docker 主机上构建。不要使用 shell 跟踪(`bash -x`)或把密码打印到日志。
|
||||
现有 runtime 每次启动时检查 `profiles/web/package.json`,不另加容器、不联网追踪最新版。仅将 dependencies/devDependencies/optionalDependencies 中同名 `file:/usr/local/lib/dsh-mgr/<包名>-<版本>.tgz` 引用对齐镜像内置清单,并补齐相关缺失的 pnpm overrides。保留自定义安装来源、自定义 overrides、bundles、已删除的插件和其他配置。目标归档缺失、无效配置或不安全的符号链接会阻止启动。
|
||||
|
||||
密钥库以只读 bind mount 提供给容器,不复制进镜像。推荐把密钥库放在仓库外;常见密钥扩展名及 signing/ 已从 Docker 构建上下文排除。密钥库和密码必须备份,覆盖升级需要保持包名与签名一致。
|
||||
只有配置变化才写入,原文保存在同目录 `package.json.backup-<UUID>`,新文件通过原子替换写入,权限 `0600`;重复启动不会反复改写。恢复时先停止实例并备份现状,再恢复对应备份;要回退旧内置版本必须同时使用匹配的旧镜像,否则下一次启动会再次对齐当前镜像。迁移不提供针对同 UID 恶意并发修改目录的沙箱保护。
|
||||
|
||||
## 注意事项
|
||||
新版设置 UI 已原生使用 host settings describe/mutate,构建只验证接口、不再应用旧 persistence 替换。Office 转换补丁仍限定精确宿主及插件版本;下载 slot/readBytes 和 workspaceRegistry 接口已核对上游源码。mobile/free-search 的预发布 peer 例外仅允许精确 `0.2.1-rc.2` 的 DSH 包且仍要求满足原版本范围(includePrerelease),其他依赖照常严格校验。
|
||||
|
||||
- **证书**:反代必须使用系统 WebView 信任的证书(如 Let's Encrypt)。自签证书会被拒绝,WebView 无法像浏览器那样手动放行。
|
||||
- **签名**:release 必须提供外部密钥库;缺失配置或密码/alias 不正确时构建失败,不回退到 debug 签名。此前的 debug 签名 APK 无法被新签名直接覆盖安装。
|
||||
- **Android 9 及以下**:下载保存到应用外部目录(免存储权限),不进公共下载目录。
|
||||
- **图标**:使用仓库根目录的 `logo.webp`。API 26+ 使用浅冰蓝底色的自适应图标,旧系统使用同一 logo 的传统图标;各密度资源均已生成。
|
||||
当前自动化回归 40 项通过;AMD64 镜像已构建,禁网 UID 1000 新 profile 安装、HTTP 未认证 401、旧 mobile/free-search/chat-manager 引用迁移、备份与幂等检查通过。Office 的已安装 DSH convertBytes 集成及 DOCX/PPTX/DOC/PPT 并发、限制、超时、清理测试通过。pnpm 会提示由宿主提供的 peer 缺失/范围冲突;这不是浏览器交互兼容性证明。浏览器完整交互、真实搜索结果、设置保存、终端原生模块和 ARM64 尚未验收。以下旧发布记录中的“已有 profile 不自动改写”仅适用于旧版本。
|
||||
|
||||
## runtime-7.8:修复下载按钮服务依赖
|
||||
|
||||
下载插件升级为 dsh-preview-download@1.0.1,补齐 remote 服务声明,修复点击时在发送请求前抛出 cannot get property "remote" without inject 的问题。同步更新内置包和新 profile 引用,新增注册回调依赖回归测试。此修复不处理面板 401、Office 504 或 plugins/events HTTP/2 连接错误。
|
||||
|
||||
已有实例升级镜像时,必须把 profile 中 dsh-preview-download 依赖的归档路径从 dsh-preview-download-1.0.0.tgz 改为 dsh-preview-download-1.0.1.tgz,保留其他配置和 bundles,重启后刷新页面;已有 profile 不自动改写。尚未完成线上浏览器下载验收。
|
||||
|
||||
## runtime-7.7:预览下载插件
|
||||
|
||||
内置独立插件 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 或面板。
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,75 +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 = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "ren.misaka.dshapp"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
// Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION
|
||||
// is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions)
|
||||
// You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true`
|
||||
// flag during build.
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
storeFile = System.getenv("DSH_KEYSTORE")?.let { file(it) }
|
||||
storePassword = System.getenv("DSH_STORE_PASSWORD")
|
||||
keyAlias = System.getenv("DSH_KEY_ALIAS")
|
||||
keyPassword = System.getenv("DSH_KEY_PASSWORD")
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 直接运行 Gradle/Flutter 也不能绕过 release 签名要求。debug 开发不受影响。
|
||||
gradle.taskGraph.whenReady {
|
||||
if (allTasks.any { it.project == project && it.name.contains("Release") }) {
|
||||
for (name in listOf("DSH_KEYSTORE", "DSH_STORE_PASSWORD", "DSH_KEY_ALIAS", "DSH_KEY_PASSWORD")) {
|
||||
check(!System.getenv(name).isNullOrBlank()) {
|
||||
"Release signing requires $name; use build.sh --keystore PATH --alias NAME"
|
||||
}
|
||||
}
|
||||
check(file(System.getenv("DSH_KEYSTORE")).isFile) { "Signing keystore does not exist" }
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
@@ -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,47 +0,0 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- 面板通过 HTTPS 反代访问,必须声明网络权限(release 模板默认不含) -->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<application
|
||||
android:label="DSH 工作台"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
@@ -1,92 +0,0 @@
|
||||
package ren.misaka.dshapp
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.webkit.CookieManager
|
||||
import android.widget.Toast
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL = "dsh.workbench/downloads"
|
||||
}
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
|
||||
.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"download" -> {
|
||||
val url = call.argument<String>("url")
|
||||
if (url == null) {
|
||||
result.error("ARG_MISSING", "url is required", null)
|
||||
return@setMethodCallHandler
|
||||
}
|
||||
try {
|
||||
startDownload(url)
|
||||
result.success(true)
|
||||
} catch (e: Exception) {
|
||||
result.error("DOWNLOAD_FAILED", e.message, null)
|
||||
}
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 把下载交给系统 DownloadManager,并携带面板登录 Cookie
|
||||
/// (下载接口与站点同源、需要 JWT 会话,不能走无 Cookie 的外部浏览器)。
|
||||
private fun startDownload(url: String) {
|
||||
val request = DownloadManager.Request(Uri.parse(url))
|
||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||
.setAllowedOverMetered(true)
|
||||
.setAllowedOverRoaming(true)
|
||||
|
||||
CookieManager.getInstance().getCookie(url)?.takeIf { it.isNotEmpty() }?.let { cookie ->
|
||||
request.addRequestHeader("Cookie", cookie)
|
||||
}
|
||||
|
||||
val fileName = deriveFileName(url)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
// DownloadManager 不接受 MediaStore 的 content:// URI。
|
||||
// targetSdk >= 29 时,系统下载器写公共 Downloads 不需要存储权限。
|
||||
request.setDestinationInExternalPublicDir(
|
||||
Environment.DIRECTORY_DOWNLOADS, fileName
|
||||
)
|
||||
} else {
|
||||
// Android 9-:公共目录写入需运行时权限;退到应用外部目录(免权限)
|
||||
@Suppress("DEPRECATION")
|
||||
request.setDestinationInExternalFilesDir(
|
||||
this, Environment.DIRECTORY_DOWNLOADS, fileName
|
||||
)
|
||||
}
|
||||
|
||||
val manager = getSystemService(DownloadManager::class.java)
|
||||
manager.enqueue(request)
|
||||
val destination = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
"系统下载目录"
|
||||
} else {
|
||||
"应用下载目录"
|
||||
}
|
||||
Toast.makeText(this, "已开始下载到$destination:$fileName", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
/// 从下载 URL 推断文件名,优先取 path 参数的 basename(即真实文件名)。
|
||||
private fun deriveFileName(url: String): String {
|
||||
val uri = Uri.parse(url)
|
||||
uri.getQueryParameter("path")?.substringAfterLast('/')?.takeIf { it.isNotBlank() }
|
||||
?.let { return sanitize(it) }
|
||||
uri.lastPathSegment?.takeIf { it.isNotBlank() && it != "download" }
|
||||
?.let { return sanitize(it) }
|
||||
return "dsh-download-${System.currentTimeMillis()}"
|
||||
}
|
||||
|
||||
private fun sanitize(name: String): String =
|
||||
name.replace(Regex("[\\\\/:*?\"<>|]"), "_").take(120).ifBlank { "file" }
|
||||
}
|
||||
@@ -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>
|
||||
|
Before Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
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,24 +0,0 @@
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
val newBuildDir: Directory =
|
||||
rootProject.layout.buildDirectory
|
||||
.dir("../../build")
|
||||
.get()
|
||||
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||
|
||||
subprojects {
|
||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
tasks.register<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -1,89 +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_ARGS[@]}" -t "$IMAGE" .
|
||||
fi
|
||||
|
||||
echo "==> 在容器内构建 release APK"
|
||||
docker run --rm \
|
||||
-v "$PWD":/project \
|
||||
-w /project \
|
||||
--mount "type=bind,source=$KEYSTORE,target=/run/dsh-signing/release.keystore,readonly" \
|
||||
-e DSH_KEYSTORE=/run/dsh-signing/release.keystore \
|
||||
-e DSH_KEY_ALIAS -e DSH_STORE_PASSWORD -e DSH_KEY_PASSWORD \
|
||||
-e http_proxy -e https_proxy -e no_proxy \
|
||||
-e PUB_CACHE=/opt/pub-cache \
|
||||
-e DSH_SITE_URL="$DSH_SITE_URL" \
|
||||
"$IMAGE" \
|
||||
sh -ec 'dart tool/validate_site_url.dart "$DSH_SITE_URL"; flutter pub get; flutter test --dart-define="DSH_SITE_URL=$DSH_SITE_URL"; flutter build apk --dart-define="DSH_SITE_URL=$DSH_SITE_URL" --release --split-per-abi --obfuscate --split-debug-info=build/symbols'
|
||||
|
||||
echo "==> 完成,产物:"
|
||||
ls -lh build/app/outputs/flutter-apk/*.apk
|
||||
@@ -0,0 +1,10 @@
|
||||
.git
|
||||
**/.git
|
||||
.env
|
||||
.env.*
|
||||
**/.env
|
||||
**/.env.*
|
||||
node_modules
|
||||
**/node_modules
|
||||
*.test.mjs
|
||||
embed.go
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
admin off
|
||||
auto_https off
|
||||
}
|
||||
:8080 {
|
||||
reverse_proxy 127.0.0.1:3080
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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});}
|
||||
});
|
||||
@@ -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)');
|
||||
@@ -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)');
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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)');
|
||||
@@ -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)');
|
||||
@@ -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();
|
||||
@@ -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']);
|
||||
@@ -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']);
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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]); }
|
||||
};
|
||||
}
|
||||
@@ -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 }); }
|
||||
});
|
||||
@@ -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 }); }
|
||||
@@ -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)
|
||||
@@ -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)');
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
} });
|
||||
@@ -0,0 +1,3 @@
|
||||
- insert:
|
||||
- id: dsh-preview-download
|
||||
name: dsh-preview-download
|
||||
@@ -0,0 +1,3 @@
|
||||
// Client-only extension: no new server endpoints or authorization changes.
|
||||
export const name = 'dsh-preview-download';
|
||||
export function apply() {}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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')
|
||||
@@ -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;
|
||||
@@ -1,324 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:file_selector/file_selector.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
import 'package:webview_flutter_android/webview_flutter_android.dart';
|
||||
|
||||
import 'config.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
// 全面屏:内容延伸到状态栏/导航栏之下,刘海与手势区交给 SafeArea 处理
|
||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
runApp(const DshApp());
|
||||
}
|
||||
|
||||
class DshApp extends StatelessWidget {
|
||||
const DshApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
// 站点为深色主题:状态栏透明、图标反白
|
||||
value: const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: Brightness.light,
|
||||
systemNavigationBarColor: Colors.transparent,
|
||||
systemNavigationBarIconBrightness: Brightness.light,
|
||||
),
|
||||
child: MaterialApp(
|
||||
title: 'DSH 工作台',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: const Color(kThemeColorValue),
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
scaffoldBackgroundColor: const Color(kThemeColorValue),
|
||||
),
|
||||
home: const HomePage(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class HomePage extends StatefulWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
@override
|
||||
State<HomePage> createState() => _HomePageState();
|
||||
}
|
||||
|
||||
class _HomePageState extends State<HomePage> {
|
||||
/// 原生下载通道(MainActivity 中用系统 DownloadManager 实现)
|
||||
static const MethodChannel _downloadsChannel = MethodChannel(
|
||||
'dsh.workbench/downloads',
|
||||
);
|
||||
|
||||
static final Uri _siteUri = Uri.parse(kSiteUrl);
|
||||
|
||||
late final WebViewController _controller;
|
||||
bool _loading = true;
|
||||
String? _loadError;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = _buildController();
|
||||
}
|
||||
|
||||
WebViewController _buildController() {
|
||||
final controller = WebViewController()
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..setBackgroundColor(const Color(kThemeColorValue))
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onPageStarted: (_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_loadError = null;
|
||||
});
|
||||
}
|
||||
},
|
||||
onPageFinished: (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
},
|
||||
onWebResourceError: (error) {
|
||||
// 只把主文档加载失败视作页面错误;子资源错误(图片等)
|
||||
// 以及被新导航打断(ERR_ABORTED)的情况交给页面自行处理
|
||||
if (!(error.isForMainFrame ?? false)) return;
|
||||
if (error.description.contains('ERR_ABORTED')) return;
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_loadError = error.description;
|
||||
});
|
||||
}
|
||||
},
|
||||
onNavigationRequest: _onNavigationRequest,
|
||||
),
|
||||
)
|
||||
..loadRequest(Uri.parse(kSiteUrl));
|
||||
|
||||
// Android 专属能力:文件选择器(上传)、原生 JS 对话框(alert/confirm)
|
||||
final platform = controller.platform;
|
||||
if (platform is AndroidWebViewController) {
|
||||
platform
|
||||
..setOnShowFileSelector(_onShowFileSelector)
|
||||
..setOnJavaScriptAlertDialog(_onJavaScriptAlert)
|
||||
..setOnJavaScriptConfirmDialog(_onJavaScriptConfirm);
|
||||
}
|
||||
return controller;
|
||||
}
|
||||
|
||||
/// 面板用 window.location.assign 触发下载(文件/目录 ZIP/备份包),
|
||||
/// 这里拦下来交给原生 DownloadManager(携带登录 Cookie),WebView 停留原页。
|
||||
NavigationDecision _onNavigationRequest(NavigationRequest request) {
|
||||
final uri = Uri.tryParse(request.url);
|
||||
if (uri != null && _isPanelDownload(uri)) {
|
||||
_startDownload(uri);
|
||||
return NavigationDecision.prevent;
|
||||
}
|
||||
return NavigationDecision.navigate;
|
||||
}
|
||||
|
||||
bool _isPanelDownload(Uri uri) {
|
||||
if (uri.host != _siteUri.host) return false;
|
||||
// 工作区文件/目录 ZIP 下载
|
||||
if (uri.path.endsWith('/api/ws/download')) return true;
|
||||
// 配置备份包下载
|
||||
if (uri.path.endsWith('/backups') &&
|
||||
uri.queryParameters.containsKey('download')) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> _startDownload(Uri uri) async {
|
||||
try {
|
||||
await _downloadsChannel.invokeMethod<bool>('download', {
|
||||
'url': uri.toString(),
|
||||
});
|
||||
} on PlatformException catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('下载失败:${e.message ?? '未知错误'}')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// WebView 里的 <input type="file"> 上传(工作区上传/备份导入)。
|
||||
/// Android 的类型过滤一次只支持单一 MIME 或单一扩展名,
|
||||
/// 多约束时退化为不限类型,避免选择器异常。
|
||||
Future<List<String>> _onShowFileSelector(FileSelectorParams params) async {
|
||||
final typeGroups = <XTypeGroup>[];
|
||||
final mimes = params.acceptTypes.where((t) => t.contains('/')).toList();
|
||||
if (mimes.length == 1) {
|
||||
typeGroups.add(XTypeGroup(mimeTypes: mimes));
|
||||
} else {
|
||||
final exts = params.acceptTypes
|
||||
.where((t) => t.startsWith('.'))
|
||||
.map((t) => t.toLowerCase())
|
||||
.toList();
|
||||
if (exts.length == 1) typeGroups.add(XTypeGroup(extensions: exts));
|
||||
}
|
||||
|
||||
if (params.mode == FileSelectorMode.openMultiple) {
|
||||
final files = await openFiles(acceptedTypeGroups: typeGroups);
|
||||
return files.map((f) => f.path).toList();
|
||||
}
|
||||
final file = await openFile(acceptedTypeGroups: typeGroups);
|
||||
return file == null ? const <String>[] : [file.path];
|
||||
}
|
||||
|
||||
/// 面板少量路径仍使用 window.alert / window.confirm,
|
||||
/// 未注册回调时 WebView 会静默返回 false,必须在这里补上。
|
||||
Future<void> _onJavaScriptAlert(JavaScriptAlertDialogRequest request) async {
|
||||
if (!mounted) return;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
content: Text(request.message),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('确定'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _onJavaScriptConfirm(
|
||||
JavaScriptConfirmDialogRequest request,
|
||||
) async {
|
||||
if (!mounted) return false;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
content: Text(request.message),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('确定'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return confirmed ?? false;
|
||||
}
|
||||
|
||||
Future<void> _onPopInvoked(bool didPop, Object? result) async {
|
||||
if (didPop) return;
|
||||
// 先回退 WebView 历史(SPA 内部导航),没有历史再退出应用
|
||||
if (await _controller.canGoBack()) {
|
||||
await _controller.goBack();
|
||||
} else {
|
||||
await SystemNavigator.pop();
|
||||
}
|
||||
}
|
||||
|
||||
void _retry() {
|
||||
setState(() {
|
||||
_loadError = null;
|
||||
_loading = true;
|
||||
});
|
||||
unawaited(_controller.reload());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: _onPopInvoked,
|
||||
child: Scaffold(
|
||||
backgroundColor: const Color(kThemeColorValue),
|
||||
body: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
WebViewWidget(controller: _controller),
|
||||
if (_loadError != null)
|
||||
_ErrorView(message: _loadError!, onRetry: _retry)
|
||||
else if (_loading)
|
||||
const _LoadingView(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LoadingView extends StatelessWidget {
|
||||
const _LoadingView();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const ColoredBox(
|
||||
color: Color(kThemeColorValue),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(color: Colors.white70),
|
||||
SizedBox(height: 16),
|
||||
Text('正在加载…', style: TextStyle(color: Colors.white70)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorView extends StatelessWidget {
|
||||
const _ErrorView({required this.message, required this.onRetry});
|
||||
|
||||
final String message;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColoredBox(
|
||||
color: const Color(kThemeColorValue),
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.cloud_off, size: 56, color: Colors.white54),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'无法连接到服务器',
|
||||
style: TextStyle(color: Colors.white, fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Colors.white38, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton.icon(
|
||||
onPressed: onRetry,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,354 +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"
|
||||
cross_file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cross_file
|
||||
sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.5+5"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
file_selector:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: file_selector
|
||||
sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
file_selector_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_android
|
||||
sha256: d670cd0ce77a2e785b18d8b4d0a8d6a222d6a813ec9b7ddf2790a1b4fb6fa92c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.2+11"
|
||||
file_selector_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_ios
|
||||
sha256: "97269e5307a0ab813b1fa2430bada0a96e0afb74848417f8676f64ba5de0051c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.3+6"
|
||||
file_selector_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_linux
|
||||
sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.4+1"
|
||||
file_selector_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_macos
|
||||
sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.5+1"
|
||||
file_selector_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_platform_interface
|
||||
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
file_selector_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_web
|
||||
sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.5"
|
||||
file_selector_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_windows
|
||||
sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.3+6"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_lints
|
||||
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.0.2"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.10"
|
||||
leak_tracker_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_testing
|
||||
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
lints:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lints
|
||||
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.20"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.0"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.18.3"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
plugin_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: plugin_platform_interface
|
||||
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.2"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_channel
|
||||
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: string_scanner
|
||||
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: term_glyph
|
||||
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.12"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.0"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.3.0"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
webview_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: webview_flutter
|
||||
sha256: d53e1ccf5516f25017e3c9d44c39034db352d20fa34fe200674270242c2c5111
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.14.1"
|
||||
webview_flutter_android:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: webview_flutter_android
|
||||
sha256: "4de8b3d1ff4ebe1bdb42e68a5e4f809194a3cb0117a8f495f590004f00da3964"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.14.1"
|
||||
webview_flutter_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webview_flutter_platform_interface
|
||||
sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.15.1"
|
||||
webview_flutter_wkwebview:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webview_flutter_wkwebview
|
||||
sha256: a9cc3f507ee40127ed0812ca54d00701a27833f51ea0b5820f4b3e7f42518bf3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.27.0"
|
||||
sdks:
|
||||
dart: ">=3.13.5 <4.0.0"
|
||||
flutter: ">=3.47.0"
|
||||
@@ -1,90 +0,0 @@
|
||||
name: dsh_workbench
|
||||
description: "DSH 工作台 Android 客户端:系统 WebView 壳。"
|
||||
# The following line prevents the package from being accidentally published to
|
||||
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
||||
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
|
||||
# The following defines the version and build number for your application.
|
||||
# A version number is three numbers separated by dots, like 1.2.43
|
||||
# followed by an optional build number separated by a +.
|
||||
# Both the version and the builder number may be overridden in flutter
|
||||
# build by specifying --build-name and --build-number, respectively.
|
||||
# In Android, build-name is used as versionName while build-number used as versionCode.
|
||||
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
|
||||
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
|
||||
# Read more about iOS versioning at
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 1.0.1+2
|
||||
|
||||
environment:
|
||||
sdk: ^3.13.5
|
||||
|
||||
# Dependencies specify other packages that your package needs in order to work.
|
||||
# To automatically upgrade your package dependencies to the latest versions
|
||||
# consider running `flutter pub upgrade --major-versions`. Alternatively,
|
||||
# dependencies can be manually updated by changing the version numbers below to
|
||||
# the latest version available on pub.dev. To see which dependencies have newer
|
||||
# versions available, run `flutter pub outdated`.
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
# DSH 面板 WebView 壳所需
|
||||
webview_flutter: ^4.14.1
|
||||
webview_flutter_android: ^4.14.1
|
||||
file_selector: ^1.1.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
# The "flutter_lints" package below contains a set of recommended lints to
|
||||
# encourage good coding practices. The lint set provided by the package is
|
||||
# activated in the `analysis_options.yaml` file located at the root of your
|
||||
# package. See that file for information about deactivating specific lint
|
||||
# rules and activating additional ones.
|
||||
flutter_lints: ^6.0.0
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
|
||||
# The following section is specific to Flutter packages.
|
||||
flutter:
|
||||
|
||||
# The following line ensures that the Material Icons font is
|
||||
# included with your application, so that you can use the icons in
|
||||
# the material Icons class.
|
||||
uses-material-design: true
|
||||
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
# assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
# - images/a_dot_ham.jpeg
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/to/resolution-aware-images
|
||||
|
||||
# For details regarding adding assets from package dependencies, see
|
||||
# https://flutter.dev/to/asset-from-package
|
||||
|
||||
# To add custom fonts to your application, add a fonts section here,
|
||||
# in this "flutter" section. Each entry in this list should have a
|
||||
# "family" key with the font family name, and a "fonts" key with a
|
||||
# list giving the asset and other descriptors for the font. For
|
||||
# example:
|
||||
# fonts:
|
||||
# - family: Schyler
|
||||
# fonts:
|
||||
# - asset: fonts/Schyler-Regular.ttf
|
||||
# - asset: fonts/Schyler-Italic.ttf
|
||||
# style: italic
|
||||
# - family: Trajan Pro
|
||||
# fonts:
|
||||
# - asset: fonts/TrajanPro.ttf
|
||||
# - asset: fonts/TrajanPro_Bold.ttf
|
||||
# weight: 700
|
||||
#
|
||||
# For details regarding fonts from package dependencies,
|
||||
# see https://flutter.dev/to/font-from-package
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"release_id": "runtime-7.11",
|
||||
"version": "0.2.1-rc.5",
|
||||
"deployment_schema": 7,
|
||||
"platforms": ["linux/amd64", "linux/arm64"]
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||