Skip to content

refactor(shell): Phase 3c——OCR 采集与浮层生命周期外置(OCR 簇收官) #232

refactor(shell): Phase 3c——OCR 采集与浮层生命周期外置(OCR 簇收官)

refactor(shell): Phase 3c——OCR 采集与浮层生命周期外置(OCR 簇收官) #232

Workflow file for this run

name: Desktop Build
# Builds desktop installers with the backend bundled in (Epic #18 T2):
# Spring Boot jar (per-platform javacpp natives) + jlink-trimmed JRE, so the
# installer works without a local Java/PostgreSQL environment.
on:
workflow_dispatch:
push:
tags: ['v*']
pull_request:
paths:
- '.github/workflows/desktop-build.yml'
- 'desktop/**'
- 'backend/**'
# Installers bundle the built frontend (frontend/dist), so frontend-only
# changes affect the installer too and must run the build + smoke test.
# Without this, the first-run wizard (PR #25, frontend-only) shipped to
# v0.2.0 having never been validated inside an installer. See issue #27.
- 'frontend/**'
# The "Attach to GitHub Release" step (softprops/action-gh-release) creates the
# release for a v* tag, which needs write access to repository contents. The
# default GITHUB_TOKEN is read-only, so without this the tag build fails with
# "403 Resource not accessible by integration".
permissions:
contents: write
jobs:
build:
name: Build (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
# macOS runners take 1h+ per build (jlink + dmg + notarization), so PRs
# only build Windows; macOS builds run on v* release tags and manual
# workflow_dispatch runs.
os: ${{ fromJSON((startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && '["macos-latest", "windows-latest"]' || '["windows-latest"]') }}
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- uses: actions/setup-java@v4
id: jdk
with:
distribution: temurin
java-version: '21'
cache: maven
- name: Build frontend
working-directory: frontend
run: |
npm ci || npm install
npm run build:h5
# Epic #43: build the embedded LibreOffice editor bundle (dist/zetaoffice),
# shipped via extraResources for the experimental "LibreOffice 验证" window.
npm run build:zetaoffice
- name: Bake LOWA runtime + OFL CJK font into the editor bundle (offline)
shell: bash
env:
# Self-built zh-CN LOWA engine (issue #66): native Chinese UI incl. Qt
# tooltips + FS/callMain exports. Hosted on the project site (versioned,
# immutable; wasm/data served brotli so the installer stays small).
# Unset -> upstream CDN (English UI). Build recipe: desktop/lowa-build/.
LOWA_BASE_URL: https://www.aiworkdeck.com/lowa-engine/24.2.8-zhcn-r2/
run: |
# Track A (Epic #43): download the LibreOffice WASM runtime (soffice.*)
# into dist/zetaoffice/lowa and an OFL-licensed CJK font (Noto Sans SC)
# as cjk.ttc, so the packaged app renders Chinese OFFLINE without
# reaching cdn.zetaoffice.net. Replaces the old best-effort copy of a
# system font (non-deterministic, absent offline). Adds ~60 MB to the
# installer. The local HTTP server (zetaoffice-server.js) serves these
# first and falls back to the CDN only for anything not bundled.
node desktop/scripts/fetch-lowa-assets.js
ls -la frontend/dist/zetaoffice/lowa frontend/dist/zetaoffice/cjk.ttc
- name: Install desktop dependencies
working-directory: desktop
run: npm ci
- name: Desktop unit tests
working-directory: desktop
run: npm test
- name: Bundle backend (macOS arm64)
if: runner.os == 'macOS'
env:
JAVA_HOME: ${{ steps.jdk.outputs.path }}
run: |
# Apple Silicon only(2026-07-03 决策放弃 Intel Mac):单 arm64 jar + arm64 JRE
mvn -B -q -DskipTests -Djavacpp.platform=macosx-arm64 -f backend/pom.xml package
node desktop/scripts/prepare-backend.js \
--jar backend/target/backend-0.0.1-SNAPSHOT.jar \
--out desktop/bundled/mac-arm64
- name: Bundle pptx-service (macOS arm64)
if: runner.os == 'macOS'
run: |
node desktop/scripts/prepare-python-service.js \
--service pptx-service \
--src pptx-service/backend \
--requirements pptx-service/requirements.lock \
--out desktop/bundled/mac-arm64
- name: Bundle mineru-service (macOS arm64)
if: runner.os == 'macOS'
run: |
# 纯 pip 包服务(无 --src);模型不进包,首启在「组件管理」下载
node desktop/scripts/prepare-python-service.js \
--service mineru-service \
--requirements mineru-service/requirements.lock \
--out desktop/bundled/mac-arm64
du -sh desktop/bundled/mac-arm64/pysvc/mineru-service/lib
- name: Bundle kokoro-service (macOS arm64)
if: runner.os == 'macOS'
run: |
# 本地 TTS 包装层(Phase 3):模型不进包,首启在「组件管理」下载
node desktop/scripts/prepare-python-service.js \
--service kokoro-service \
--src kokoro-service \
--requirements kokoro-service/requirements.lock \
--out desktop/bundled/mac-arm64
du -sh desktop/bundled/mac-arm64/pysvc/kokoro-service/lib
- name: Bundle backend (Windows x64)
if: runner.os == 'Windows'
shell: bash
env:
JAVA_HOME: ${{ steps.jdk.outputs.path }}
run: |
mvn -B -q -DskipTests -Djavacpp.platform=windows-x86_64 -f backend/pom.xml package
node desktop/scripts/prepare-backend.js \
--jar backend/target/backend-0.0.1-SNAPSHOT.jar \
--out desktop/bundled/win-x64
- name: Bundle pptx-service (Windows x64)
if: runner.os == 'Windows'
shell: bash
run: |
node desktop/scripts/prepare-python-service.js \
--service pptx-service \
--src pptx-service/backend \
--requirements pptx-service/requirements.lock \
--out desktop/bundled/win-x64
- name: Bundle mineru-service (Windows x64)
if: runner.os == 'Windows'
shell: bash
run: |
node desktop/scripts/prepare-python-service.js \
--service mineru-service \
--requirements mineru-service/requirements.lock \
--out desktop/bundled/win-x64
du -sh desktop/bundled/win-x64/pysvc/mineru-service/lib
- name: Bundle kokoro-service (Windows x64)
if: runner.os == 'Windows'
shell: bash
run: |
node desktop/scripts/prepare-python-service.js \
--service kokoro-service \
--src kokoro-service \
--requirements kokoro-service/requirements.lock \
--out desktop/bundled/win-x64
du -sh desktop/bundled/win-x64/pysvc/kokoro-service/lib
- name: Sign bundled backend natives (macOS)
if: runner.os == 'macOS'
env:
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
run: |
# Notarization rejects the adhoc-signed bytedeco dylibs inside the
# fat jar (and any unsigned JRE binaries), so they must carry our
# Developer ID signature before electron-builder seals the app.
if [ -z "$CSC_LINK" ]; then
echo "No signing cert (fork PR) — skipping native signing"
exit 0
fi
KC="$RUNNER_TEMP/awd-sign.keychain-db"
KCPASS="awd-tmp-keychain"
echo "$CSC_LINK" | base64 --decode > "$RUNNER_TEMP/awd-cert.p12"
security create-keychain -p "$KCPASS" "$KC"
security unlock-keychain -p "$KCPASS" "$KC"
security import "$RUNNER_TEMP/awd-cert.p12" -k "$KC" -P "$CSC_KEY_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KCPASS" "$KC" > /dev/null
security list-keychains -d user -s "$KC" login.keychain-db
IDENTITY=$(security find-identity -v -p codesigning "$KC" | awk 'NR==1{print $2}')
echo "Signing with identity: $IDENTITY"
bash desktop/scripts/sign-mac-natives.sh "$IDENTITY" desktop/bundled/mac-arm64 desktop/build/entitlements.mac.plist
- name: Smoke test bundled backend (macOS)
if: runner.os == 'macOS'
env:
# The backend's WebTools pre-warm downloads chromium/firefox/webkit
# from playwright.azureedge.net on every boot; the 2026-07-03 CDN
# outage (400 GatewayExceptionResponse) pushed startup past 120s and
# failed this step on both platforms. The smoke test only curls the
# wizard endpoint — no browser needed — so skip the download to
# remove the live network dependency (playwright-java's Driver
# honors this env var).
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
run: |
# Boot the signed jar on the trimmed JRE exactly as the packaged app
# will — catches runtime/JRE/signing breakage that packaging alone
# cannot. (Cannot be tested on SIP-disabled dev machines: JDK-8326663.)
mkdir -p "$RUNNER_TEMP/awd-home"
desktop/bundled/mac-arm64/jre/bin/java \
-Duser.home="$RUNNER_TEMP/awd-home" \
-jar desktop/bundled/mac-arm64/backend.jar \
--spring.profiles.active=desktop --server.port=9696 \
> "$RUNNER_TEMP/backend-smoke.log" 2>&1 &
BACK_PID=$!
for i in $(seq 1 60); do
sleep 2
if curl -sf -o /dev/null http://127.0.0.1:9696/api/admin/wizard; then
echo "backend up after ~$((i*2))s"
curl -s http://127.0.0.1:9696/api/admin/wizard; echo
kill $BACK_PID
exit 0
fi
done
echo "backend failed to start within 120s"
tail -80 "$RUNNER_TEMP/backend-smoke.log"
kill $BACK_PID 2>/dev/null || true
exit 1
- name: Smoke test bundled pptx-service (macOS)
if: runner.os == 'macOS'
run: |
# 用烙好的运行时按打包态同款方式拉起:先 alembic 迁移,后起服务,验 /health
export PPTX_DATA_DIR="$RUNNER_TEMP/pptx-data"
mkdir -p "$PPTX_DATA_DIR"
APP="$PWD/desktop/bundled/mac-arm64/pysvc/pptx-service/app"
PY="$PWD/desktop/bundled/mac-arm64/python/bin/python3.11"
export PYTHONPATH="$PWD/desktop/bundled/mac-arm64/pysvc/pptx-service/lib"
(cd "$APP" && "$PY" -m alembic -c alembic.ini upgrade head)
PORT=5099 BACKEND_PORT=5099 FLASK_ENV=production "$PY" "$APP/app.py" > "$RUNNER_TEMP/pptx-smoke.log" 2>&1 &
PPTX_PID=$!
for i in $(seq 1 30); do
sleep 2
if curl -sf http://127.0.0.1:5099/health; then
echo; echo "pptx-service up after ~$((i*2))s"
kill $PPTX_PID
exit 0
fi
done
echo "pptx-service failed to start within 60s"
tail -80 "$RUNNER_TEMP/pptx-smoke.log"
kill $PPTX_PID 2>/dev/null || true
exit 1
- name: Smoke test bundled mineru-service (macOS)
if: runner.os == 'macOS'
run: |
# 无模型拉起(模型解析时懒加载,/docs 不依赖模型——本地实测约 4s 就绪)
export MDIR="$RUNNER_TEMP/mineru-data"
mkdir -p "$MDIR"
PY="$PWD/desktop/bundled/mac-arm64/python/bin/python3.11"
export PYTHONPATH="$PWD/desktop/bundled/mac-arm64/pysvc/mineru-service/lib"
MINERU_DEVICE_MODE=cpu MINERU_MODEL_SOURCE=modelscope \
MODELSCOPE_CACHE="$MDIR" HF_HOME="$MDIR/hf" MINERU_TOOLS_CONFIG_JSON="$MDIR/mineru.json" \
"$PY" -m mineru.cli.fast_api --host 127.0.0.1 --port 8098 > "$RUNNER_TEMP/mineru-smoke.log" 2>&1 &
MINERU_PID=$!
for i in $(seq 1 30); do
sleep 2
if curl -sf -o /dev/null http://127.0.0.1:8098/docs; then
echo "mineru-service up after ~$((i*2))s"
kill $MINERU_PID
exit 0
fi
done
echo "mineru-service failed to start within 60s"
tail -80 "$RUNNER_TEMP/mineru-smoke.log"
kill $MINERU_PID 2>/dev/null || true
exit 1
- name: Smoke test bundled kokoro-service (macOS)
if: runner.os == 'macOS'
run: |
# 无模型拉起(模型懒加载,/health 与 voices 不依赖模型)
PY="$PWD/desktop/bundled/mac-arm64/python/bin/python3.11"
APP="$PWD/desktop/bundled/mac-arm64/pysvc/kokoro-service/app"
export PYTHONPATH="$PWD/desktop/bundled/mac-arm64/pysvc/kokoro-service/lib"
PORT=8881 "$PY" "$APP/app.py" > "$RUNNER_TEMP/kokoro-smoke.log" 2>&1 &
KOKORO_PID=$!
for i in $(seq 1 20); do
sleep 2
if curl -sf http://127.0.0.1:8881/health && curl -sf http://127.0.0.1:8881/v1/audio/voices | grep -q zf_001; then
echo; echo "kokoro-service up after ~$((i*2))s"
kill $KOKORO_PID
exit 0
fi
done
echo "kokoro-service failed to start within 40s"
tail -60 "$RUNNER_TEMP/kokoro-smoke.log"
kill $KOKORO_PID 2>/dev/null || true
exit 1
- name: Pack pysvc archive (macOS)
if: runner.os == 'macOS'
run: |
# 上万个 Python 小文件不再直接进 .app:签名(sign-mac-natives.sh 已给
# 每个 Mach-O 打 Developer ID + 时间戳——公证会扫嵌套压缩包,未签名照拒)
# 与冒烟测试之后整体打成单个 pysvc.tar.gz,electron-builder 的签名/公证
# 阶段从上万文件降到一个文件,消除时间戳服务抖动。首启由主进程解压。
node desktop/scripts/pack-pysvc.js --bundle desktop/bundled/mac-arm64
- name: Package installers (macOS, signed & notarized)
if: runner.os == 'macOS'
working-directory: desktop
env:
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# GitHub never exposes secrets to fork PRs, so CSC_LINK is empty
# there — and force-signing with an empty CSC_LINK makes
# electron-builder fail ("not a file"). Only force signing when the
# cert secret is actually present; fork PRs fall back to
# electron-builder's default unsigned PR build.
CSC_FOR_PULL_REQUEST: ${{ secrets.CSC_LINK != '' }}
run: |
# mineru lib(transformers 等)有数万个小文件,macOS 默认 fd 上限 256 会让
# electron-builder 在打包/装订阶段 EMFILE。65536 实测仍复现——内核级
# kern.maxfilesperproc(默认 61440)同样要抬,三层一起调并打印生效值
sudo sysctl -w kern.maxfiles=524288 kern.maxfilesperproc=524288 || true
sudo launchctl limit maxfiles 524288 524288 || true
ulimit -n 524288 || ulimit -n "$(ulimit -Hn)" || true
echo "fd soft=$(ulimit -n) hard=$(ulimit -Hn)"
npx electron-builder --publish never
- name: Notarization failure log (macOS)
if: failure() && runner.os == 'macOS'
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
# NOT the APPLE_TEAM_ID secret: notarytool history rejects it with
# 403 (it works for submission but not for the history API); the
# team id is public anyway (package.json mac.notarize.teamId)
TEAM_ID: X9B97KVA84
run: |
# Surface Apple's per-file rejection reasons; without this the build
# log only says "Invalid"
HISTORY=$(xcrun notarytool history --apple-id "$APPLE_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" --team-id "$TEAM_ID" \
--output-format json || true)
echo "$HISTORY" | head -c 2000; echo
for SUB_ID in $(echo "$HISTORY" | jq -r '.history[0:2][].id // empty'); do
echo "=== notarization log for $SUB_ID"
xcrun notarytool log "$SUB_ID" --apple-id "$APPLE_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" --team-id "$TEAM_ID" \
"$RUNNER_TEMP/notary-log-$SUB_ID.json" || true
cat "$RUNNER_TEMP/notary-log-$SUB_ID.json" || true
echo
done
- name: Smoke test bundled backend (Windows)
if: runner.os == 'Windows'
shell: bash
env:
# Skip the live Playwright browser download — see the macOS smoke
# test step for the full rationale.
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
run: |
mkdir -p "$RUNNER_TEMP/awd-home"
desktop/bundled/win-x64/jre/bin/java.exe \
-Duser.home="$RUNNER_TEMP/awd-home" \
-jar desktop/bundled/win-x64/backend.jar \
--spring.profiles.active=desktop --server.port=9696 \
> "$RUNNER_TEMP/backend-smoke.log" 2>&1 &
BACK_PID=$!
for i in $(seq 1 60); do
sleep 2
if curl -sf -o /dev/null http://127.0.0.1:9696/api/admin/wizard; then
echo "backend up after ~$((i*2))s"
curl -s http://127.0.0.1:9696/api/admin/wizard; echo
kill $BACK_PID
exit 0
fi
done
echo "backend failed to start within 120s"
tail -80 "$RUNNER_TEMP/backend-smoke.log"
kill $BACK_PID 2>/dev/null || true
exit 1
- name: Smoke test bundled pptx-service (Windows)
if: runner.os == 'Windows'
shell: bash
run: |
export PPTX_DATA_DIR="$RUNNER_TEMP/pptx-data"
mkdir -p "$PPTX_DATA_DIR"
APP="$PWD/desktop/bundled/win-x64/pysvc/pptx-service/app"
PY="$PWD/desktop/bundled/win-x64/python/python.exe"
export PYTHONPATH="$PWD/desktop/bundled/win-x64/pysvc/pptx-service/lib"
(cd "$APP" && "$PY" -m alembic -c alembic.ini upgrade head)
PORT=5099 BACKEND_PORT=5099 FLASK_ENV=production "$PY" "$APP/app.py" > "$RUNNER_TEMP/pptx-smoke.log" 2>&1 &
PPTX_PID=$!
for i in $(seq 1 30); do
sleep 2
if curl -sf http://127.0.0.1:5099/health; then
echo; echo "pptx-service up after ~$((i*2))s"
kill $PPTX_PID
exit 0
fi
done
echo "pptx-service failed to start within 60s"
tail -80 "$RUNNER_TEMP/pptx-smoke.log"
kill $PPTX_PID 2>/dev/null || true
exit 1
- name: Smoke test bundled mineru-service (Windows)
if: runner.os == 'Windows'
shell: bash
run: |
export MDIR="$RUNNER_TEMP/mineru-data"
mkdir -p "$MDIR"
PY="$PWD/desktop/bundled/win-x64/python/python.exe"
export PYTHONPATH="$PWD/desktop/bundled/win-x64/pysvc/mineru-service/lib"
MINERU_DEVICE_MODE=cpu MINERU_MODEL_SOURCE=modelscope \
MODELSCOPE_CACHE="$MDIR" HF_HOME="$MDIR/hf" MINERU_TOOLS_CONFIG_JSON="$MDIR/mineru.json" \
"$PY" -m mineru.cli.fast_api --host 127.0.0.1 --port 8098 > "$RUNNER_TEMP/mineru-smoke.log" 2>&1 &
MINERU_PID=$!
for i in $(seq 1 30); do
sleep 2
if curl -sf -o /dev/null http://127.0.0.1:8098/docs; then
echo "mineru-service up after ~$((i*2))s"
kill $MINERU_PID
exit 0
fi
done
echo "mineru-service failed to start within 60s"
tail -80 "$RUNNER_TEMP/mineru-smoke.log"
kill $MINERU_PID 2>/dev/null || true
exit 1
- name: Smoke test bundled kokoro-service (Windows)
if: runner.os == 'Windows'
shell: bash
run: |
PY="$PWD/desktop/bundled/win-x64/python/python.exe"
APP="$PWD/desktop/bundled/win-x64/pysvc/kokoro-service/app"
export PYTHONPATH="$PWD/desktop/bundled/win-x64/pysvc/kokoro-service/lib"
PORT=8881 "$PY" "$APP/app.py" > "$RUNNER_TEMP/kokoro-smoke.log" 2>&1 &
KOKORO_PID=$!
for i in $(seq 1 20); do
sleep 2
if curl -sf http://127.0.0.1:8881/health && curl -sf http://127.0.0.1:8881/v1/audio/voices | grep -q zf_001; then
echo; echo "kokoro-service up after ~$((i*2))s"
kill $KOKORO_PID
exit 0
fi
done
echo "kokoro-service failed to start within 40s"
tail -60 "$RUNNER_TEMP/kokoro-smoke.log"
kill $KOKORO_PID 2>/dev/null || true
exit 1
- name: Pack pysvc archive (Windows)
if: runner.os == 'Windows'
shell: bash
run: |
# 与 macOS 同构:安装包只带一个 pysvc.tar.gz(文件数大降、安装更快),首启解压
node desktop/scripts/pack-pysvc.js --bundle desktop/bundled/win-x64
- name: Package installers (Windows, unsigned)
if: runner.os == 'Windows'
working-directory: desktop
env:
# Windows code signing not set up yet (issue #12)
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
run: npx electron-builder --publish never
- uses: actions/upload-artifact@v4
with:
name: aiworkdeck-${{ matrix.os }}
path: |
desktop/release/*.dmg
desktop/release/*.exe
if-no-files-found: error
- name: Attach to GitHub Release
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2
with:
files: |
desktop/release/*.dmg
desktop/release/*.exe
# Auto-generate a "What's Changed" changelog from merged PRs since the
# last tag, appended below the bilingual body template. Without this a
# tag build creates a Release with EMPTY notes (had to `gh release edit`
# by hand every time). The template is version-agnostic so it stays
# correct on every release; per-release highlights can still be edited in.
generate_release_notes: true
body: |
## AI Workdeck — 律师的 AI 原生工作台 / The lawyer's AI-native workstation
**中文** · 桌面版「双击即用」:安装包已捆绑后端与精简 JRE,无需预装 PostgreSQL / Java。首次启动有向导,配置一项 AI 提供商即可解锁核心功能;可选「本地 Ollama」实现数据不出本机。
**English** · Desktop, double-click to run: the installer bundles the backend and a trimmed JRE — no PostgreSQL or Java setup. A first-run wizard gets you started; configure one AI provider to unlock core features. Pick local Ollama to keep all data on-device.
### 下载 / Downloads
- **macOS · Apple Silicon (M 系列)** — `AI Workdeck-<ver>-arm64.dmg`(已签名公证 / signed & notarized)
- **Windows · x64** — `AI Workdeck Setup <ver>.exe`(暂未签名,首次运行 SmartScreen 提示属正常 / not yet code-signed; a SmartScreen prompt on first run is expected)
> 自本版本起不再提供 Intel Mac 安装包(依赖生态已停发 x86_64 预编译组件)。/ Intel mac builds are discontinued as of this release.
官网 / Website: https://www.aiworkdeck.com
---