diff --git a/.dockerignore b/.dockerignore index 7c1057c..9decaca 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,6 +5,6 @@ bin coverage node_modules var -web/dist -web/node_modules +apps/web/dist +apps/web/node_modules *.log diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ada8cd2..26ad7bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,12 +42,60 @@ jobs: - run: go vet ./... - run: go test -race ./... - run: pnpm architecture + - run: pnpm docs:links - run: pnpm governance:v3 - run: pnpm governance:content - run: pnpm evaluate:plugin - run: pnpm test:plugin-signing - run: pnpm check:plugin - - run: pnpm --dir web typecheck - - run: pnpm --dir web test - - run: pnpm --dir web build + - run: pnpm --dir apps/web typecheck + - run: pnpm --dir apps/web test + - run: pnpm --dir apps/web build + - run: pnpm --dir apps/desktop typecheck + - run: pnpm --dir apps/desktop test + - run: pnpm --dir apps/desktop package + - run: xvfb-run -a pnpm --dir apps/desktop exec playwright test + - run: pnpm desktop:release:check + - run: pnpm test:desktop-release - run: node --check packages/contentcloud/bin/contentcloud.js + + desktop-package-matrix: + name: desktop package (${{ matrix.target }}) + strategy: + fail-fast: false + matrix: + include: + - target: darwin-arm64 + os: macos-14 + platform: darwin + arch: arm64 + - target: darwin-x64 + os: macos-14 + platform: darwin + arch: x64 + - target: win32-x64 + os: windows-latest + platform: win32 + arch: x64 + - target: linux-x64 + os: ubuntu-latest + platform: linux + arch: x64 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10.8.1 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm desktop:release:check + - run: pnpm --dir apps/desktop exec electron-forge package --platform ${{ matrix.platform }} --arch ${{ matrix.arch }} + - uses: actions/upload-artifact@v4 + with: + name: content-work-os-desktop-${{ matrix.target }} + path: apps/desktop/out/** + if-no-files-found: error diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml new file mode 100644 index 0000000..26349d6 --- /dev/null +++ b/.github/workflows/desktop-release.yml @@ -0,0 +1,434 @@ +name: Desktop Release + +on: + push: + tags: + - "desktop-v*" + workflow_dispatch: + inputs: + version: + description: "版本号或 desktop-v 标签,例如 0.28.0-beta.1" + required: true + source_ref: + description: "构建来源 ref;留空使用 main" + required: false + default: "" + channel: + description: "更新通道;稳定版只能使用已签名产物" + required: true + type: choice + options: + - beta + - stable + default: beta + publish: + description: "是否创建或更新 GitHub Release;手动运行默认只上传 CI artifact" + required: true + type: boolean + default: false + +permissions: + contents: write + actions: read + +concurrency: + group: desktop-release-${{ github.ref }} + cancel-in-progress: false + +env: + NODE_VERSION: "22" + PNPM_VERSION: "10.8.1" + ELECTRON_CACHE: ${{ github.workspace }}/.cache/electron + +jobs: + resolve: + name: Resolve desktop release identity + runs-on: ubuntu-22.04 + outputs: + tag: ${{ steps.identity.outputs.tag }} + version: ${{ steps.identity.outputs.version }} + channel: ${{ steps.identity.outputs.channel }} + source_ref: ${{ steps.identity.outputs.source_ref }} + publish: ${{ steps.identity.outputs.publish }} + steps: + - name: Resolve tag, version and channel + id: identity + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + EVENT_TAG: ${{ github.ref_name }} + INPUT_VERSION: ${{ github.event.inputs.version || '' }} + INPUT_CHANNEL: ${{ github.event.inputs.channel || 'beta' }} + INPUT_SOURCE_REF: ${{ github.event.inputs.source_ref || '' }} + INPUT_PUBLISH: ${{ github.event.inputs.publish || 'false' }} + run: | + set -euo pipefail + if [[ "$EVENT_NAME" == "push" ]]; then + tag="$EVENT_TAG" + source_ref="${GITHUB_SHA}" + publish=true + else + raw_version="${INPUT_VERSION#desktop-v}" + tag="desktop-v${raw_version}" + source_ref="${INPUT_SOURCE_REF:-main}" + publish="${INPUT_PUBLISH}" + fi + + if [[ ! "$tag" =~ ^desktop-v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$ ]]; then + echo "::error::invalid desktop release tag: $tag" + exit 1 + fi + version="${tag#desktop-v}" + if [[ "$version" == *-* ]]; then + channel=beta + elif [[ "$EVENT_NAME" == push ]]; then + channel=stable + else + channel="${INPUT_CHANNEL:-stable}" + fi + if [[ "$channel" == stable && "$version" == *-* ]]; then + echo "::error::a prerelease version cannot enter stable" + exit 1 + fi + if [[ "$publish" == true && "$channel" == beta && "$version" != *-* ]]; then + echo "::error::a published beta requires a prerelease version" + exit 1 + fi + if [[ "$publish" != true && "$publish" != false ]]; then + echo "::error::publish must be true or false" + exit 1 + fi + + { + echo "tag=$tag" + echo "version=$version" + echo "channel=$channel" + echo "source_ref=$source_ref" + echo "publish=$publish" + } >> "$GITHUB_OUTPUT" + + build: + name: Build ${{ matrix.name }} + needs: resolve + strategy: + fail-fast: false + matrix: + include: + - target: darwin-arm64 + name: macOS arm64 + runner: macos-14 + platform: darwin + arch: arm64 + forge_targets: dmg,zip + signing: required + - target: darwin-x64 + name: macOS x64 + runner: macos-15-intel + platform: darwin + arch: x64 + forge_targets: dmg,zip + signing: required + - target: win32-x64 + name: Windows x64 + runner: windows-2022 + platform: win32 + arch: x64 + forge_targets: squirrel + signing: required + - target: linux-x64 + name: Linux x64 + runner: ubuntu-22.04 + platform: linux + arch: x64 + forge_targets: deb,rpm + signing: preview + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + steps: + - name: Checkout source + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ needs.resolve.outputs.source_ref }} + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build macOS DMG native dependency + if: matrix.platform == 'darwin' + shell: bash + run: | + set -euo pipefail + for package_name in macos-alias fs-xattr; do + if [[ "$package_name" == macos-alias ]]; then + expected_output=volume.node + else + expected_output=xattr.node + fi + package_json="$(find node_modules/.pnpm -path "*/node_modules/$package_name/package.json" -print -quit)" + if [[ -z "$package_json" ]]; then + echo "::error::$package_name package is missing" + exit 1 + fi + package_dir="$(dirname "$package_json")" + pnpm exec node-gyp rebuild --directory "$package_dir" + test -f "$package_dir/build/Release/$expected_output" + done + + - name: Validate desktop release contract + run: pnpm desktop:release:check + + - name: Verify package version matches release tag + shell: bash + env: + RELEASE_VERSION: ${{ needs.resolve.outputs.version }} + run: | + set -euo pipefail + package_version="$(node -p "require('./apps/desktop/package.json').version")" + test "$package_version" = "$RELEASE_VERSION" || { + echo "::error::apps/desktop/package.json is $package_version, expected $RELEASE_VERSION" + exit 1 + } + + - name: Validate macOS signing secrets + if: matrix.platform == 'darwin' + shell: bash + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + set -euo pipefail + missing=() + for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_SIGNING_IDENTITY APPLE_TEAM_ID KEYCHAIN_PASSWORD; do + [[ -n "${!name:-}" ]] || missing+=("$name") + done + if (( ${#missing[@]} )); then + echo "::error::missing macOS signing/notarization secrets: ${missing[*]}" + exit 1 + fi + + - name: Import macOS signing certificate + if: matrix.platform == 'darwin' + shell: bash + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + set -euo pipefail + keychain="$RUNNER_TEMP/contentcloud-desktop-signing.keychain-db" + certificate="$RUNNER_TEMP/contentcloud-desktop-signing.p12" + echo "$APPLE_CERTIFICATE" | base64 --decode > "$certificate" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$keychain" + security set-keychain-settings -lut 21600 "$keychain" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$keychain" + security import "$certificate" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$keychain" + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$keychain" + security list-keychains -d user -s "$keychain" + echo "CONTENTCLOUD_DESKTOP_MAC_KEYCHAIN=$keychain" >> "$GITHUB_ENV" + + - name: Validate Windows signing secrets + if: matrix.platform == 'win32' + shell: bash + env: + WINDOWS_SIGNING_CERTIFICATE: ${{ secrets.WINDOWS_SIGNING_CERTIFICATE }} + WINDOWS_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_SIGNING_CERTIFICATE_PASSWORD }} + run: | + set -euo pipefail + [[ -n "${WINDOWS_SIGNING_CERTIFICATE:-}" ]] || { echo "::error::missing WINDOWS_SIGNING_CERTIFICATE"; exit 1; } + [[ -n "${WINDOWS_SIGNING_CERTIFICATE_PASSWORD:-}" ]] || { echo "::error::missing WINDOWS_SIGNING_CERTIFICATE_PASSWORD"; exit 1; } + + - name: Prepare Windows signing certificate + if: matrix.platform == 'win32' + shell: bash + env: + WINDOWS_SIGNING_CERTIFICATE: ${{ secrets.WINDOWS_SIGNING_CERTIFICATE }} + WINDOWS_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_SIGNING_CERTIFICATE_PASSWORD }} + run: | + set -euo pipefail + certificate="$RUNNER_TEMP/contentcloud-desktop-signing.pfx" + CERTIFICATE_PATH="$certificate" node --input-type=module <<'NODE' + import { writeFileSync } from 'node:fs'; + writeFileSync(process.env.CERTIFICATE_PATH, Buffer.from(process.env.WINDOWS_SIGNING_CERTIFICATE, 'base64')); + NODE + echo "CONTENTCLOUD_DESKTOP_WINDOWS_CERTIFICATE_FILE=$certificate" >> "$GITHUB_ENV" + echo "CONTENTCLOUD_DESKTOP_WINDOWS_CERTIFICATE_PASSWORD=$WINDOWS_SIGNING_CERTIFICATE_PASSWORD" >> "$GITHUB_ENV" + + - name: Build Electron packages + shell: bash + env: + CONTENTCLOUD_DESKTOP_SIGN: ${{ matrix.signing == 'required' && '1' || '' }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + set -euo pipefail + pnpm --dir apps/desktop exec electron-forge make \ + --platform "${{ matrix.platform }}" \ + --arch "${{ matrix.arch }}" \ + --targets "${{ matrix.forge_targets }}" + + - name: Verify macOS signatures and notarization + if: matrix.platform == 'darwin' + shell: bash + run: | + set -euo pipefail + apps=() + while IFS= read -r -d '' app; do apps+=("$app"); done < <(find apps/desktop/out -type d -name '*.app' -not -path '*.app/*' -print0) + if (( ${#apps[@]} == 0 )); then + echo "::error::Electron Forge produced no macOS app bundle" + exit 1 + fi + for app in "${apps[@]}"; do + codesign --verify --deep --strict --verbose=4 "$app" + spctl --assess --type execute --verbose=4 "$app" + done + + - name: Verify Windows Authenticode signature + if: matrix.platform == 'win32' + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $installers = Get-ChildItem "apps/desktop/out/make" -Recurse -File -Filter "*.exe" + if ($installers.Count -eq 0) { + throw "Electron Forge produced no Windows Squirrel installer" + } + foreach ($installer in $installers) { + $signature = Get-AuthenticodeSignature $installer.FullName + if ($signature.Status -ne "Valid") { + throw "Invalid Authenticode signature for $($installer.FullName): $($signature.Status)" + } + } + + - name: Stage release assets and generate metadata + shell: bash + env: + RELEASE_TAG: ${{ needs.resolve.outputs.tag }} + RELEASE_VERSION: ${{ needs.resolve.outputs.version }} + RELEASE_CHANNEL: ${{ needs.resolve.outputs.channel }} + RELEASE_REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + signing_flag=() + if [[ "${{ matrix.signing }}" == required ]]; then signing_flag+=(--signed); fi + node scripts/stage-desktop-release.mjs stage \ + --forge-dir apps/desktop/out \ + --out-dir "desktop-staged/${{ matrix.target }}" \ + --target "${{ matrix.target }}" \ + --version "$RELEASE_VERSION" \ + --channel "$RELEASE_CHANNEL" \ + --tag "$RELEASE_TAG" \ + --repository "$RELEASE_REPOSITORY" \ + "${signing_flag[@]}" + + - name: Upload staged desktop assets + uses: actions/upload-artifact@v4 + with: + name: desktop-release-${{ matrix.target }} + path: desktop-staged/${{ matrix.target }}/* + if-no-files-found: error + retention-days: 14 + + publish: + name: Publish GitHub desktop release + needs: [resolve, build] + if: needs.resolve.outputs.publish == 'true' && needs.build.result == 'success' + runs-on: ubuntu-22.04 + steps: + - name: Checkout source + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ needs.resolve.outputs.source_ref }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Download staged desktop assets + uses: actions/download-artifact@v4 + with: + pattern: desktop-release-* + path: desktop-staged + merge-multiple: true + + - name: Aggregate release metadata and checksums + shell: bash + env: + RELEASE_TAG: ${{ needs.resolve.outputs.tag }} + RELEASE_CHANNEL: ${{ needs.resolve.outputs.channel }} + RELEASE_REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + mkdir -p github-release-assets + node scripts/stage-desktop-release.mjs aggregate \ + --input-dir desktop-staged \ + --out-dir github-release-assets \ + --channel "$RELEASE_CHANNEL" \ + --tag "$RELEASE_TAG" \ + --repository "$RELEASE_REPOSITORY" \ + --require-all-targets + cp desktop-staged/* github-release-assets/ + find github-release-assets -maxdepth 1 -type f -print | sort + + - name: Create draft GitHub Release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.resolve.outputs.tag }} + SOURCE_REF: ${{ needs.resolve.outputs.source_ref }} + RELEASE_CHANNEL: ${{ needs.resolve.outputs.channel }} + shell: bash + run: | + set -euo pipefail + if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --target "$SOURCE_REF" --draft=true + else + gh release create "$RELEASE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --target "$SOURCE_REF" \ + --title "Content Work OS Desktop $RELEASE_TAG" \ + --generate-notes \ + --draft + fi + + - name: Upload desktop release assets + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.resolve.outputs.tag }} + shell: bash + run: | + set -euo pipefail + gh release upload "$RELEASE_TAG" github-release-assets/* --repo "$GITHUB_REPOSITORY" --clobber + + - name: Publish release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.resolve.outputs.tag }} + RELEASE_CHANNEL: ${{ needs.resolve.outputs.channel }} + shell: bash + run: | + set -euo pipefail + if [[ "$RELEASE_CHANNEL" == beta ]]; then + gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --draft=false --prerelease + else + gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --draft=false --latest + fi diff --git a/.gitignore b/.gitignore index 6195947..cd73c21 100644 --- a/.gitignore +++ b/.gitignore @@ -5,13 +5,16 @@ dist/ coverage/ node_modules/ -web/dist/ -web/node_modules/ +apps/*/dist/ +apps/*/node_modules/ +apps/*/.vite/ +apps/*/out/ +apps/*/test-results/ var/ .contentcloud/ *.tsbuildinfo -web/vite.config.js -web/vite.config.d.ts +apps/web/vite.config.js +apps/web/vite.config.d.ts *.log .playwright-mcp/ register-create.png diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..d67f374 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +node-linker=hoisted diff --git a/CHANGELOG.md b/CHANGELOG.md index b968700..19c380f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ ContentCloud 的重要变更记录在此文件中。 +## [0.28.0] - 2026-08-17 + +### Added + +- 增加 Provider Profile、Provider Binding 和管理员 Provider 管理页面,支持按租户配置出口策略、预算和服务商路由。 +- Provider 凭据引用只允许写入,不在 API 响应中回显,并增加对应 HTTP/应用层回归测试。 + +### Changed + +- Content Work OS 前端迁移到 `apps/web`,同步 Workspace、Docker、CI、治理脚本和开发文档路径。 +- 更新工作台、Studio、运营页和官网视觉层级,统一 Provider 状态与白标工作流文案。 +- 应用发布版本与独立签名的 Scene Plugin 版本解耦;应用升级到 `0.28.0`,生产插件继续固定在已验证的 `0.27.0`。 + +### Security + +- Provider Binding 查询不再泄漏 `credential_ref`,仅返回是否已配置凭据。 + ## [0.27.0] - 2026-08-15 ### Added diff --git a/DESIGN.md b/DESIGN.md index 3985743..6734804 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -9,7 +9,7 @@ ## Visual Baseline -- `web/src/marketing/marketing.css` 中当前首页是品牌视觉基准。首页以白色为主表面,用不对称的品牌叙事和真实任务工作面组成首屏;自然光创作空间只承载右侧场景氛围,不能压过产品品牌、任务状态和客户下一步。 +- `apps/web/src/marketing/marketing.css` 中当前首页是品牌视觉基准。首页首屏使用浅青绿像素场景、居中品牌叙事和真实任务摘要组成单一构图;产品品牌、任务状态和客户下一步保持主导。 - 工作台、平台后台、登录注册、文档和公开页面复用首页的中性灰白、清亮蓝焦点、Logo、字体与图标语言,但不复制营销页的大标题或内容编排。 - 工作面以白色、浅灰背景、中性边框和严格网格承载高频操作;近黑色用于主要命令,清亮蓝只用于品牌、选中态、链接和焦点,侧栏保持白色,不得形成大面积蓝色区域。 - 颜色用于区分品牌交互、对象类型和系统状态。品牌主题以白色为主、明亮蓝为辅,不使用粉色主题,也不把整个界面染成单一蓝色。 @@ -99,8 +99,8 @@ ### Public Website -- 首屏以白色为主表面,用品牌标题与真实任务工作面形成单一构图;继续使用现有 `/images/content-work-os-studio.webp` 自然光场景,不替换图片、不生成新背景,也不增加蓝色滤镜。 -- Hero 保持品牌优先、单一 CTA 组和下一段露出;任务画布承载固定输入、人工确认、版本状态和唯一下一步,不做漂浮节点或纯装饰流程图。 +- 首屏使用独立的浅青绿像素场景 `/images/content-work-os-pixel-field.webp`,采用居中品牌标题、双 CTA、四周白色像素片段和底部任务摘要露出的构图;登录注册继续使用 `/images/content-work-os-studio.webp` 自然光场景。 +- Hero 保持品牌优先,像素片段只承担轻量场景氛围;下一段任务摘要承载固定输入、人工确认、版本状态和唯一下一步,不使用与业务无关的漂浮节点或装饰流程图。 - 首页紧接客户创作流程、结果资产目录、资产复用闭环、三执行平面和能力状态表,视觉内容必须来自产品文档与当前代码事实。 - 首页内容状态来自 Registry 和文档目录,不静态承诺未实现能力。 @@ -154,3 +154,4 @@ | 2026-08-11 | 官网能力叙事以 Infra 文档和公开目录为事实源 | 明确区分本地可用、服务端可用、执行框架已接通与外部依赖,避免把底层实现写成真实账号或自动发布已接通。 | | 2026-08-12 | 对标参考站的中性工作室层级,蓝色只保留为焦点 | 用户否定大面积皇家蓝和高饱和天蓝;采用白色主表面、浅灰工作区、近黑命令、清亮蓝焦点,并将 Studio 调整为 `112px` 紧凑导航、约 `404px` 上下文栏和开放工作面。 | | 2026-08-12 | 采用参考站的白色留白、胶囊控件和柔和浮层,品牌色改为冰川蓝 | 用户再次否定普通企业蓝观感;颜色只替代参考站的粉色强调,不把页面整体染蓝,官网保留原自然光背景图,登录、Studio 与后台共享同一套冷白表面和墨色命令。 | +| 2026-08-17 | 首页改为 Catpaw 同类居中绿色像素场景 | 复用透明导航、居中 Hero、白色像素片段、双 CTA 和下一段露出的构图与动效机制;不复制对方品牌素材、Logo 或文案,继续使用 Content Work OS 的真实任务内容。 | diff --git a/Dockerfile.server b/Dockerfile.server index b24cdb5..c8d26e6 100644 --- a/Dockerfile.server +++ b/Dockerfile.server @@ -2,11 +2,11 @@ FROM node:22-bookworm-slim AS web-build WORKDIR /src RUN corepack enable COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ -COPY web/package.json web/package.json +COPY apps/web/package.json apps/web/package.json COPY packages/contentcloud/package.json packages/contentcloud/package.json RUN pnpm install --frozen-lockfile -COPY web web -RUN pnpm --dir web build +COPY apps/web apps/web +RUN pnpm --dir apps/web build FROM golang:1.24-bookworm AS go-build WORKDIR /src @@ -24,7 +24,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates RUN useradd --system --uid 10001 --create-home contentcloud WORKDIR /app COPY --from=go-build /out/contentcloud-server /usr/local/bin/contentcloud-server -COPY --from=web-build /src/web/dist /app/web/dist +COPY --from=web-build /src/apps/web/dist /app/web/dist USER contentcloud ENV CONTENTCLOUD_ADDR=:8080 CONTENTCLOUD_WEB_DIST=/app/web/dist CONTENTCLOUD_DEV_MODE=0 EXPOSE 8080 diff --git a/Makefile b/Makefile index eae006f..a420a01 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ -.PHONY: dev preview server web worker cli build test check check-plugin install-cli migrate-up +.PHONY: dev preview server web desktop worker cli build build-web package-desktop make-desktop test test-desktop test-desktop-e2e check check-plugin install-cli migrate-up CONTENTCLOUD_ADDR ?= :8080 -CONTENTCLOUD_WEB_DIST ?= web/dist +CONTENTCLOUD_WEB_DIST ?= apps/web/dist dev: ./scripts/dev.sh @@ -13,7 +13,10 @@ server: go run ./cmd/contentcloud-server web: - pnpm --dir web dev + pnpm --dir apps/web dev + +desktop: + pnpm --dir apps/desktop start worker: go run ./cmd/contentcloud-worker @@ -28,23 +31,42 @@ build: build-web go build -o bin/contentcloud ./cmd/contentcloud build-web: - pnpm --dir web build + pnpm --dir apps/web build + +package-desktop: + pnpm --dir apps/desktop package + +make-desktop: + pnpm --dir apps/desktop make test: go test ./... - pnpm --dir web test + pnpm --dir apps/web test + pnpm --dir apps/desktop test + +test-desktop: + pnpm --dir apps/desktop typecheck + pnpm --dir apps/desktop test + +test-desktop-e2e: + pnpm --dir apps/desktop test:e2e check: go fmt ./... go vet ./... go test ./... pnpm architecture + pnpm docs:links pnpm governance:v3 pnpm governance:content pnpm test:plugin-signing pnpm check:plugin - pnpm --dir web typecheck - pnpm --dir web build + pnpm --dir apps/web typecheck + pnpm --dir apps/web test + pnpm --dir apps/web build + pnpm --dir apps/desktop typecheck + pnpm --dir apps/desktop test + pnpm --dir apps/desktop package check-plugin: pnpm evaluate:plugin diff --git a/README.md b/README.md index 768c991..65171e1 100644 --- a/README.md +++ b/README.md @@ -17,12 +17,13 @@ Content Work OS 是面向 AI 内容营销团队的 Studio-first 创作与云端 ```text cmd/ Go Server、Worker、CLI/Daemon -internal/ Domain、Application Service、Transport 与 Adapter +internal/ Runtime、Local、Transport、Integration、Persistence 与业务模块 contracts/ OpenAPI 3.1 与 JSON Schema migrations/ PostgreSQL schema、RLS 与 runtime role skills/ 随 CLI 内嵌的本地 Agent Skill packages/contentcloud/ 零依赖 npm 校验安装器 -web/ React + TypeScript 工作台与客户审批页 +apps/web/ React + TypeScript 工作台与客户审批页 +apps/desktop/ Electron 持续项目工作面 docs/foundation/ 平台基线、分层规范、迁移与工程门禁 docs/product/ 客户产品需求与平台叙事 docs/roadmap/v8/ Agentic Job Runtime 专项路线图 @@ -50,6 +51,14 @@ make dev 开发模式下,演示账号默认具备平台管理员权限,并自动创建金陵古法线香演示项目;来源由内置确定性 Worker 处理。 +Desktop 通过本机 Go Daemon 的版本化 Desktop API 获取项目目录、同步/上传、审批和 Runtime/Delivery 投影,不复用 Codex 渲染面。启动开发版: + +```bash +pnpm dev:desktop +``` + +`pnpm package:desktop` 生成当前平台未签名安装目录;正式签名、notarization 和自动更新仍是发布门禁,不属于当前已发布能力。 + CLI 示例: ```bash @@ -150,7 +159,9 @@ project: "品牌 / 单品" ```bash make check go test -race ./... -pnpm --dir web test +pnpm --dir apps/web test +pnpm test:desktop +pnpm test:e2e:desktop pnpm check:plugin ``` @@ -160,7 +171,7 @@ PostgreSQL 集成测试必须使用可重建的专用开发库;推荐先创建 ```bash CONTENTCLOUD_TEST_DATABASE_URL='postgres://contentcloud@127.0.0.1:5432/<专用测试库>?sslmode=disable' \ -GOMAXPROCS=2 go test -race -p 1 -count=1 ./internal/store/postgres +GOMAXPROCS=2 go test -race -p 1 -count=1 ./internal/persistence/postgres ``` ## 当前实现事实 diff --git a/VERSION b/VERSION index 1b58cc1..697f087 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.27.0 +0.28.0 diff --git a/apps/desktop/forge.config.ts b/apps/desktop/forge.config.ts new file mode 100644 index 0000000..3eb0968 --- /dev/null +++ b/apps/desktop/forge.config.ts @@ -0,0 +1,120 @@ +import type { ForgeConfig } from "@electron-forge/shared-types"; +import { FusesPlugin } from "@electron-forge/plugin-fuses"; +import { VitePlugin } from "@electron-forge/plugin-vite"; +import { FuseV1Options, FuseVersion } from "@electron/fuses"; +import { MakerDeb } from "@electron-forge/maker-deb"; +import { MakerDMG } from "@electron-forge/maker-dmg"; +import { MakerRpm } from "@electron-forge/maker-rpm"; +import { MakerSquirrel } from "@electron-forge/maker-squirrel"; +import { MakerZIP } from "@electron-forge/maker-zip"; + +const releaseSigningEnabled = process.env.CONTENTCLOUD_DESKTOP_SIGN === "1"; + +function macSignOptions() { + if (process.platform !== "darwin" || !releaseSigningEnabled) { + return undefined; + } + + const identity = process.env.APPLE_SIGNING_IDENTITY?.trim(); + if (!identity) { + throw new Error( + "APPLE_SIGNING_IDENTITY is required for a signed desktop release", + ); + } + + return { + identity, + keychain: process.env.CONTENTCLOUD_DESKTOP_MAC_KEYCHAIN, + hardenedRuntime: true, + gatekeeperAssess: true, + identityValidation: true, + }; +} + +function macNotarizeOptions() { + if (process.platform !== "darwin" || !releaseSigningEnabled) { + return undefined; + } + + const appleId = process.env.APPLE_ID?.trim(); + const appleIdPassword = process.env.APPLE_APP_SPECIFIC_PASSWORD?.trim(); + const teamId = process.env.APPLE_TEAM_ID?.trim(); + if (!appleId || !appleIdPassword || !teamId) { + throw new Error( + "APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD and APPLE_TEAM_ID are required for notarization", + ); + } + + return { appleId, appleIdPassword, teamId }; +} + +function squirrelOptions() { + if (process.platform !== "win32" || !releaseSigningEnabled) { + return {}; + } + + const certificateFile = + process.env.CONTENTCLOUD_DESKTOP_WINDOWS_CERTIFICATE_FILE?.trim(); + const certificatePassword = + process.env.CONTENTCLOUD_DESKTOP_WINDOWS_CERTIFICATE_PASSWORD; + if (!certificateFile || !certificatePassword) { + throw new Error( + "CONTENTCLOUD_DESKTOP_WINDOWS_CERTIFICATE_FILE and CONTENTCLOUD_DESKTOP_WINDOWS_CERTIFICATE_PASSWORD are required for a signed desktop release", + ); + } + + return { certificateFile, certificatePassword }; +} + +const config: ForgeConfig = { + packagerConfig: { + asar: true, + name: "Content Work OS", + executableName: "content-work-os", + appBundleId: "run.zhongcao.contentcloud.desktop", + protocols: [{ name: "Content Work OS", schemes: ["contentcloud"] }], + osxSign: macSignOptions(), + osxNotarize: macNotarizeOptions(), + win32metadata: { + CompanyName: "ContentCloud", + ProductName: "Content Work OS", + FileDescription: "ContentCloud project workspace desktop", + }, + }, + rebuildConfig: {}, + makers: [ + new MakerSquirrel(squirrelOptions()), + new MakerZIP({}, ["darwin"]), + new MakerDMG({}), + new MakerDeb({}), + new MakerRpm({}), + ], + plugins: [ + new VitePlugin({ + build: [ + { + entry: "src/main/main.ts", + config: "vite.main.config.ts", + target: "main", + }, + { + entry: "src/preload/preload.ts", + config: "vite.preload.config.ts", + target: "preload", + }, + ], + renderer: [{ name: "main_window", config: "vite.renderer.config.ts" }], + }), + new FusesPlugin({ + version: FuseVersion.V1, + [FuseV1Options.RunAsNode]: false, + [FuseV1Options.EnableCookieEncryption]: true, + [FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false, + [FuseV1Options.EnableNodeCliInspectArguments]: false, + [FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true, + [FuseV1Options.OnlyLoadAppFromAsar]: true, + }), + ], +}; + +export default config; diff --git a/apps/desktop/index.html b/apps/desktop/index.html new file mode 100644 index 0000000..ea0f56c --- /dev/null +++ b/apps/desktop/index.html @@ -0,0 +1,13 @@ + + + + + + + Content Work OS Desktop + + +
+ + + diff --git a/apps/desktop/package.json b/apps/desktop/package.json new file mode 100644 index 0000000..64c7526 --- /dev/null +++ b/apps/desktop/package.json @@ -0,0 +1,44 @@ +{ + "name": "@limecloud/contentcloud-desktop", + "private": true, + "version": "0.28.0", + "main": ".vite/build/main.js", + "scripts": { + "start": "electron-forge start", + "package": "electron-forge package", + "make": "electron-forge make", + "typecheck": "tsc -b --pretty false", + "test": "vitest run", + "test:e2e": "electron-forge package && playwright test", + "release:check": "node ../../scripts/check-desktop-release.mjs" + }, + "dependencies": { + "@tanstack/react-query": "5.101.4", + "lucide-react": "0.468.0", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-router-dom": "7.18.1" + }, + "devDependencies": { + "@electron-forge/cli": "7.11.2", + "@electron-forge/maker-deb": "7.11.2", + "@electron-forge/maker-dmg": "7.11.2", + "@electron-forge/maker-rpm": "7.11.2", + "@electron-forge/maker-squirrel": "7.11.2", + "@electron-forge/maker-zip": "7.11.2", + "@electron-forge/plugin-fuses": "7.11.2", + "@electron-forge/plugin-vite": "7.11.2", + "@electron-forge/shared-types": "7.11.2", + "@electron/fuses": "1.8.0", + "@playwright/test": "1.62.1", + "@types/node": "22.15.21", + "@types/react": "18.3.18", + "@types/react-dom": "18.3.5", + "@vitejs/plugin-react": "4.7.0", + "electron": "43.4.0", + "lodash": "4.18.1", + "typescript": "5.7.2", + "vite": "6.4.3", + "vitest": "2.1.9" + } +} diff --git a/apps/desktop/playwright.config.ts b/apps/desktop/playwright.config.ts new file mode 100644 index 0000000..975f4c9 --- /dev/null +++ b/apps/desktop/playwright.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/e2e', + timeout: 30_000, + reporter: [['list']], +}); diff --git a/apps/desktop/src/main/desktopGateway.ts b/apps/desktop/src/main/desktopGateway.ts new file mode 100644 index 0000000..1bc0e34 --- /dev/null +++ b/apps/desktop/src/main/desktopGateway.ts @@ -0,0 +1,179 @@ +import { app } from 'electron'; +import { createHash, randomUUID } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import type { + DesktopReviewComment, + DesktopReviewCommentInput, + DesktopReviewDecisionInput, + DesktopReviewInbox, + DesktopReviewResult, + DesktopReviewRevisionDetail, + DesktopCommandResult, + DesktopEventStream, + DesktopEventStreamResult, + DesktopSnapshot, + DesktopSnapshotResult, + PublishWorkspaceInput, +} from '../shared/contracts'; +import { isCommandResponse, isEventStream, isPublishWorkspaceInput, isReviewInbox, isReviewRevision, isSnapshot } from '../shared/contracts'; + +const apiVersion = '1.0'; +const apiVersionHeader = 'X-ContentCloud-Desktop-API-Version'; + +interface DiscoveryFile { + schema_version: 'contentcloud.desktop-api-discovery/1.0'; + endpoint: string; + capability: string; + api_versions: string[]; +} + +interface Capabilities { + schema_version: 'contentcloud.desktop-api-capabilities/1.0'; + api_versions: string[]; + snapshot_schema: string; + command_schema: string; + event_schema: string; + commands: string[]; +} + +class DaemonResponseError extends Error { + constructor(readonly status: number, readonly code: string) { + super(`Daemon returned ${status}: ${code}`); + } +} + +function discoveryPath(): string { + return join(app.getPath('appData'), 'contentcloud', 'desktop-api.json'); +} + +async function loadDiscovery(): Promise { + const body = await readFile(discoveryPath(), 'utf8'); + const value: unknown = JSON.parse(body); + if (!value || typeof value !== 'object') throw new Error('Daemon discovery is invalid'); + const discovery = value as Partial; + if (discovery.schema_version !== 'contentcloud.desktop-api-discovery/1.0') throw new Error('Daemon discovery schema is unsupported'); + if (!discovery.endpoint?.startsWith('http://127.0.0.1:')) throw new Error('Daemon endpoint is not loopback'); + if (!discovery.capability || discovery.capability.length < 32) throw new Error('Daemon capability is missing'); + if (!Array.isArray(discovery.api_versions) || !discovery.api_versions.includes(apiVersion)) throw new Error('Daemon API version is unsupported'); + return discovery as DiscoveryFile; +} + +async function connect(): Promise { + const discovery = await loadDiscovery(); + const capabilities = await daemonFetch(discovery, '/v1/capabilities'); + if (capabilities.schema_version !== 'contentcloud.desktop-api-capabilities/1.0' + || !capabilities.api_versions.includes(apiVersion) + || capabilities.snapshot_schema !== 'contentcloud.desktop-snapshot/1.0' + || capabilities.command_schema !== 'contentcloud.desktop-command/1.0' + || capabilities.event_schema !== 'contentcloud.desktop-events/1.0') { + throw new Error('Daemon capabilities are incompatible'); + } + return discovery; +} + +async function daemonFetch(discovery: DiscoveryFile, path: string, init: RequestInit = {}): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 4000); + try { + const headers = new Headers(init.headers); + headers.set('Authorization', `Bearer ${discovery.capability}`); + if (path !== '/v1/capabilities') headers.set(apiVersionHeader, apiVersion); + const response = await fetch(`${discovery.endpoint}${path}`, { ...init, headers, signal: controller.signal }); + const value: unknown = await response.json(); + if (!response.ok) { + const code = value && typeof value === 'object' + ? String((value as { error?: { code?: unknown } }).error?.code ?? 'DESKTOP_API_ERROR') + : 'DESKTOP_API_ERROR'; + throw new DaemonResponseError(response.status, code); + } + return value as T; + } finally { + clearTimeout(timeout); + } +} + +export async function requestSnapshot(): Promise { + try { + const discovery = await connect(); + const value: unknown = await daemonFetch(discovery, '/v1/snapshot'); + if (!isSnapshot(value)) throw new Error('Daemon snapshot schema is unsupported'); + return { status: 'ready', snapshot: value }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Daemon is unavailable'; + return { status: 'offline', message }; + } +} + +export async function publishWorkspace(input: PublishWorkspaceInput): Promise { + if (!isPublishWorkspaceInput(input)) return { status: 'rejected', code: 'DESKTOP_COMMAND_INPUT_INVALID' }; + try { + const discovery = await connect(); + const identity = [input.workspace_id, input.project_id, input.base_revision, input.observed_digest].join('\n'); + const value: unknown = await daemonFetch(discovery, '/v1/commands/workspace-publish', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + schema_version: 'contentcloud.desktop-command/1.0', + request_id: `dreq_${randomUUID()}`, + workspace_id: input.workspace_id, + project_id: input.project_id, + subject_ref: 'workspace', + base_revision: input.base_revision, + observed_digest: input.observed_digest, + idempotency_key: `dpk_${createHash('sha256').update(identity).digest('hex')}`, + }), + }); + if (!isCommandResponse(value)) throw new Error('Daemon command response schema is unsupported'); + return { status: 'accepted', command: value }; + } catch (error) { + if (error instanceof DaemonResponseError && error.status >= 400 && error.status < 500) { + return { status: 'rejected', code: error.code }; + } + return { status: 'offline', message: error instanceof Error ? error.message : 'Daemon is unavailable' }; + } +} + +export async function requestProjectEvents(projectID: string, after: number): Promise { + try { + const discovery = await connect(); + const value: unknown = await daemonFetch(discovery, `/v1/projects/${encodeURIComponent(projectID)}/events?after=${after}&limit=100`); + if (!isEventStream(value)) throw new Error('Daemon event schema is unsupported'); + return { status: 'ready', stream: value }; + } catch (error) { + return { status: 'offline', message: error instanceof Error ? error.message : 'Daemon is unavailable' }; + } +} + +async function requestReview(path: string, validator: (value: unknown) => value is T, init?: RequestInit): Promise> { + try { + const discovery = await connect(); + const value: unknown = await daemonFetch(discovery, path, init); + if (!validator(value)) throw new Error('Daemon review schema is unsupported'); + return { status: 'ready', value }; + } catch (error) { + if (error instanceof DaemonResponseError && error.status >= 400 && error.status < 500) return { status: 'rejected', code: error.code }; + return { status: 'offline', message: error instanceof Error ? error.message : 'Daemon is unavailable' }; + } +} + +export function requestReviewInbox(projectID: string): Promise> { + return requestReview(`/v1/projects/${encodeURIComponent(projectID)}/review/inbox`, isReviewInbox); +} + +export function requestReviewRevision(projectID: string, revisionID: string): Promise> { + return requestReview(`/v1/projects/${encodeURIComponent(projectID)}/review/revisions/${encodeURIComponent(revisionID)}`, isReviewRevision); +} + +export function addReviewComment(projectID: string, input: DesktopReviewCommentInput): Promise> { + return requestReview(`/v1/projects/${encodeURIComponent(projectID)}/review/comments`, (value): value is DesktopReviewComment => Boolean(value && typeof value === 'object' && typeof (value as DesktopReviewComment).id === 'string'), { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), + }); +} + +export function decideReview(projectID: string, revisionID: string, action: 'approve' | 'reject' | 'request-changes', input: Omit): Promise> { + return requestReview(`/v1/projects/${encodeURIComponent(projectID)}/review/revisions/${encodeURIComponent(revisionID)}/${action}`, (_value: unknown): _value is unknown => true, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), + }); +} diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts new file mode 100644 index 0000000..5df1aed --- /dev/null +++ b/apps/desktop/src/main/main.ts @@ -0,0 +1,117 @@ +import { app, BrowserWindow, ipcMain, session } from 'electron'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { addReviewComment, decideReview, publishWorkspace, requestProjectEvents, requestReviewInbox, requestReviewRevision, requestSnapshot } from './desktopGateway'; +import type { DesktopSnapshotResult, PublishWorkspaceInput } from '../shared/contracts'; +import { isPublishWorkspaceInput, isReviewCommentRequest, isReviewDecisionRequest, isReviewRevisionRequest } from '../shared/contracts'; + +declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string | undefined; +declare const MAIN_WINDOW_VITE_NAME: string; + +let mainWindow: BrowserWindow | null = null; +let pollTimer: NodeJS.Timeout | undefined; +let latestResult: DesktopSnapshotResult | undefined; +const projectEventCursors = new Map(); + +function createWindow(): void { + mainWindow = new BrowserWindow({ + width: 1440, + height: 960, + minWidth: 1080, + minHeight: 720, + backgroundColor: '#f8faff', + webPreferences: { + preload: join(__dirname, 'preload.js'), + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + webSecurity: true, + }, + }); + + const rendererFile = join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`); + const allowedURL = MAIN_WINDOW_VITE_DEV_SERVER_URL ?? pathToFileURL(rendererFile).toString(); + mainWindow.webContents.on('will-navigate', (event, targetURL) => { + const target = new URL(targetURL); + const allowed = new URL(allowedURL); + if (target.origin !== allowed.origin || target.pathname !== allowed.pathname) event.preventDefault(); + }); + mainWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' })); + mainWindow.webContents.on('will-attach-webview', (event) => event.preventDefault()); + + if (MAIN_WINDOW_VITE_DEV_SERVER_URL) { + void mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL); + } else { + void mainWindow.loadURL(allowedURL); + } + void publishSnapshot(); + pollTimer = setInterval(() => void pollProjectEvents(), 2000); +} + +async function publishSnapshot(): Promise { + const result = await requestSnapshot(); + latestResult = result; + if (result.status === 'ready') { + for (const project of result.snapshot.projects) projectEventCursors.set(project.project_id, project.event_cursor); + } + if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('desktop.snapshotChanged', result); + return result; +} + +async function pollProjectEvents(): Promise { + if (latestResult?.status !== 'ready') { + await publishSnapshot(); + return; + } + const projects = latestResult.snapshot.projects; + const results = await Promise.all(projects.map((project) => requestProjectEvents(project.project_id, projectEventCursors.get(project.project_id) ?? project.event_cursor))); + if (results.some((result) => result.status === 'offline')) { + await publishSnapshot(); + return; + } + const changed = results.some((result) => result.status === 'ready' && (result.stream.events.length > 0 || result.stream.resync_required)); + if (changed) await publishSnapshot(); +} + +app.whenReady().then(() => { + session.defaultSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false)); + ipcMain.handle('desktop.snapshot', () => latestResult ?? publishSnapshot()); + ipcMain.handle('desktop.publishWorkspace', async (_event, input: unknown) => { + if (!isPublishWorkspaceInput(input)) return { status: 'rejected', code: 'DESKTOP_COMMAND_INPUT_INVALID' } as const; + const result = await publishWorkspace(input as PublishWorkspaceInput); + if (result.status === 'accepted') await publishSnapshot(); + return result; + }); + ipcMain.handle('desktop.appInfo', () => ({ + name: 'Content Work OS Desktop' as const, + version: app.getVersion(), + platform: process.platform, + electron: process.versions.electron, + })); + ipcMain.handle('desktop.reviewInbox', (_event, projectID: unknown) => typeof projectID === 'string' ? requestReviewInbox(projectID) : Promise.resolve({ status: 'rejected', code: 'DESKTOP_PROJECT_INVALID' } as const)); + ipcMain.handle('desktop.reviewRevision', (_event, input: unknown) => { + if (!isReviewRevisionRequest(input)) return Promise.resolve({ status: 'rejected', code: 'DESKTOP_REVIEW_INPUT_INVALID' } as const); + const value = input; + return requestReviewRevision(value.projectID, value.revisionID); + }); + ipcMain.handle('desktop.reviewComment', (_event, input: unknown) => { + if (!isReviewCommentRequest(input)) return Promise.resolve({ status: 'rejected', code: 'DESKTOP_REVIEW_INPUT_INVALID' } as const); + const value = input; + return addReviewComment(value.projectID, value.payload); + }); + ipcMain.handle('desktop.reviewDecision', (_event, input: unknown) => { + if (!isReviewDecisionRequest(input)) return Promise.resolve({ status: 'rejected', code: 'DESKTOP_REVIEW_INPUT_INVALID' } as const); + const value = input; + return decideReview(value.projectID, value.revisionID, value.action, value.payload ?? { reason: '' }); + }); + createWindow(); + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) createWindow(); + }); +}); + +app.on('window-all-closed', () => { + if (pollTimer) clearInterval(pollTimer); + if (process.platform !== 'darwin') app.quit(); +}); diff --git a/apps/desktop/src/preload/api.test.ts b/apps/desktop/src/preload/api.test.ts new file mode 100644 index 0000000..953eb87 --- /dev/null +++ b/apps/desktop/src/preload/api.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; + +import { createDesktopApi, type PreloadIPC } from './api'; + +class FakeIPC implements PreloadIPC { + calls: Array<{ channel: string; args: unknown[] }> = []; + listeners = new Map void>(); + + invoke(channel: string, ...args: unknown[]): Promise { + this.calls.push({ channel, args }); + return Promise.resolve({ status: 'ready' }); + } + + on(channel: string, listener: (...args: unknown[]) => void): void { + this.listeners.set(channel, listener); + } + + removeListener(channel: string, listener: (...args: unknown[]) => void): void { + if (this.listeners.get(channel) === listener) this.listeners.delete(channel); + } +} + +describe('preload desktop API bridge', () => { + it('exposes only typed channels and removes snapshot listeners', async () => { + const ipc = new FakeIPC(); + const api = createDesktopApi(ipc); + await api.getReviewInbox('project-1'); + await api.getReviewRevision('project-1', 'revision-1'); + await api.addReviewComment('project-1', { revision_id: 'revision-1', body: 'comment' }); + await api.decideReview('project-1', 'revision-1', 'request-changes', { reason: 'reason' }); + expect(ipc.calls.map((call) => call.channel)).toEqual([ + 'desktop.reviewInbox', + 'desktop.reviewRevision', + 'desktop.reviewComment', + 'desktop.reviewDecision', + ]); + expect(ipc.calls[1].args[0]).toEqual({ projectID: 'project-1', revisionID: 'revision-1' }); + + const events: unknown[] = []; + const unsubscribe = api.onSnapshotChanged((value) => events.push(value)); + ipc.listeners.get('desktop.snapshotChanged')?.({}, { status: 'offline', message: 'offline' }); + expect(events).toEqual([{ status: 'offline', message: 'offline' }]); + unsubscribe(); + expect(ipc.listeners.has('desktop.snapshotChanged')).toBe(false); + }); +}); diff --git a/apps/desktop/src/preload/api.ts b/apps/desktop/src/preload/api.ts new file mode 100644 index 0000000..a67d943 --- /dev/null +++ b/apps/desktop/src/preload/api.ts @@ -0,0 +1,32 @@ +import type { + DesktopApi, + DesktopAppInfo, + DesktopCommandResult, + DesktopReviewCommentInput, + DesktopReviewDecisionInput, + DesktopSnapshotResult, + PublishWorkspaceInput, +} from '../shared/contracts'; + +export interface PreloadIPC { + invoke(channel: string, ...args: unknown[]): Promise; + on(channel: string, listener: (...args: unknown[]) => void): void; + removeListener(channel: string, listener: (...args: unknown[]) => void): void; +} + +export function createDesktopApi(ipcRenderer: PreloadIPC): DesktopApi { + return { + getSnapshot: () => ipcRenderer.invoke('desktop.snapshot') as Promise, + publishWorkspace: (input: PublishWorkspaceInput) => ipcRenderer.invoke('desktop.publishWorkspace', input) as Promise, + getAppInfo: () => ipcRenderer.invoke('desktop.appInfo') as Promise, + onSnapshotChanged: (listener) => { + const handler = (...args: unknown[]) => listener(args[1] as DesktopSnapshotResult); + ipcRenderer.on('desktop.snapshotChanged', handler); + return () => ipcRenderer.removeListener('desktop.snapshotChanged', handler); + }, + getReviewInbox: (projectID) => ipcRenderer.invoke('desktop.reviewInbox', projectID) as ReturnType, + getReviewRevision: (projectID, revisionID) => ipcRenderer.invoke('desktop.reviewRevision', { projectID, revisionID }) as ReturnType, + addReviewComment: (projectID, input: DesktopReviewCommentInput) => ipcRenderer.invoke('desktop.reviewComment', { projectID, payload: input }) as ReturnType, + decideReview: (projectID, revisionID, action, input: Omit) => ipcRenderer.invoke('desktop.reviewDecision', { projectID, revisionID, action, payload: input }) as ReturnType, + }; +} diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts new file mode 100644 index 0000000..5c498d4 --- /dev/null +++ b/apps/desktop/src/preload/preload.ts @@ -0,0 +1,6 @@ +import { contextBridge, ipcRenderer } from 'electron'; + +import { createDesktopApi } from './api'; + +const api = createDesktopApi(ipcRenderer); +contextBridge.exposeInMainWorld('contentcloudDesktop', api); diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx new file mode 100644 index 0000000..fadedb1 --- /dev/null +++ b/apps/desktop/src/renderer/App.tsx @@ -0,0 +1,310 @@ +import { useEffect, useMemo, useState } from 'react'; +import { QueryClient, QueryClientProvider, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + Activity, + AlertTriangle, + Archive, + CheckCircle2, + ChevronRight, + CircleDashed, + ClipboardCheck, + Cloud, + CloudOff, + FileText, + FolderTree, + LayoutDashboard, + PackageCheck, + RefreshCw, + Send, + UploadCloud, + WifiOff, +} from 'lucide-react'; + +import type { DesktopCommandResult, DesktopReviewAction, DesktopReviewRevisionDetail, DesktopSnapshot, DesktopSnapshotResult, ProjectSnapshot } from '../shared/contracts'; + +type View = 'overview' | 'review' | 'transfers' | 'runs' | 'delivery'; + +const viewItems: Array<{ id: View; label: string; icon: typeof LayoutDashboard }> = [ + { id: 'overview', label: '内容目录', icon: FolderTree }, + { id: 'transfers', label: '同步与上传', icon: UploadCloud }, + { id: 'review', label: '审批收件箱', icon: ClipboardCheck }, + { id: 'runs', label: '任务运行', icon: Activity }, + { id: 'delivery', label: '交付状态', icon: PackageCheck }, +]; + +const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + +export function DesktopApp() { + return ; +} + +function DesktopWorkspace() { + const [selectedProjectID, setSelectedProjectID] = useState(); + const [view, setView] = useState('overview'); + const cache = useQueryClient(); + const query = useQuery({ + queryKey: ['desktop-snapshot'], + queryFn: () => window.contentcloudDesktop.getSnapshot(), + refetchInterval: false, + }); + const publish = useMutation({ + mutationFn: (target: ProjectSnapshot) => window.contentcloudDesktop.publishWorkspace({ + workspace_id: target.workspace_id, + project_id: target.project_id, + base_revision: target.cloud_revision, + observed_digest: target.observed_digest ?? '', + }), + }); + + useEffect(() => window.contentcloudDesktop.onSnapshotChanged((result) => { + cache.setQueryData(['desktop-snapshot'], result); + if (result.status === 'ready' && !selectedProjectID) setSelectedProjectID(result.snapshot.projects[0]?.project_id); + }), [cache, selectedProjectID]); + + const state = query.data; + const snapshot = state?.status === 'ready' ? state.snapshot : undefined; + const project = useMemo(() => selectProject(snapshot, selectedProjectID), [snapshot, selectedProjectID]); + const refresh = () => query.refetch().catch(() => undefined); + const publishProject = () => { + if (project?.observed_digest) publish.mutate(project); + }; + + return ( +
+ + +
+
+
+
持续项目工作面
+

{project?.name ?? 'Content Work OS Desktop'}

+
+
+ {snapshot?.daemon.version ? `Daemon ${snapshot.daemon.version}` : 'Daemon 未连接'} + +
+
+ + {state?.status === 'offline' ? : null} + {state?.status === 'ready' && snapshot ? <> + + {!project ? : } + : null} + {!state && query.isLoading ? : null} +
+
+ ); +} + +function selectProject(snapshot: DesktopSnapshot | undefined, selectedProjectID: string | undefined): ProjectSnapshot | undefined { + if (!snapshot?.projects.length) return undefined; + return snapshot.projects.find((item) => item.project_id === selectedProjectID) ?? snapshot.projects[0]; +} + +function ProjectPicker({ projects, selectedProjectID, onChange }: { projects: ProjectSnapshot[]; selectedProjectID?: string; onChange: (value: string) => void }) { + return
+
已绑定项目
+ + {projects.length} 个本地 Workspace +
; +} + +function ViewContent({ project, view, onPublish, publishing, publishResult }: { project: ProjectSnapshot; view: View; onPublish: () => void; publishing: boolean; publishResult?: DesktopCommandResult }) { + switch (view) { + case 'transfers': return ; + case 'review': return ; + case 'runs': return ; + case 'delivery': return ; + default: return ; + } +} + +function TransferView({ project, onPublish, publishing, result }: { project: ProjectSnapshot; onPublish: () => void; publishing: boolean; result?: DesktopCommandResult }) { + const allowed = project.allowed_actions.includes('workspace.publish') && Boolean(project.observed_digest); + return
+
+
+
项目工作面
+

同步与上传

+
{project.transfer_state}
+

{project.last_synced_at ? `最近同步 ${formatDate(project.last_synced_at)}` : `本地 Revision ${project.local_revision} · Cloud Revision ${project.cloud_revision}`}

+ + {result ? : null} +
+
; +} + +function OperationResult({ result }: { result: DesktopCommandResult }) { + if (result.status === 'accepted') return

命令已进入持久队列

; + if (result.status === 'rejected') return

{result.code}

; + return

{result.message}

; +} + +function ReviewView({ project }: { project: ProjectSnapshot }) { + const [selectedRevisionID, setSelectedRevisionID] = useState(); + const [comment, setComment] = useState(''); + const [reason, setReason] = useState(''); + const inbox = useQuery({ queryKey: ['desktop-review-inbox', project.project_id], queryFn: () => window.contentcloudDesktop.getReviewInbox(project.project_id) }); + const items = inbox.data?.status === 'ready' ? inbox.data.value.items : []; + const selectedID = selectedRevisionID ?? items[0]?.revision.id; + const detail = useQuery({ queryKey: ['desktop-review-revision', project.project_id, selectedID], queryFn: () => window.contentcloudDesktop.getReviewRevision(project.project_id, selectedID!), enabled: Boolean(selectedID) }); + const detailValue = detail.data?.status === 'ready' ? detail.data.value : undefined; + const mutation = useMutation({ + mutationFn: async (input: { action: 'approve' | 'reject' | 'request-changes'; reason: string }) => window.contentcloudDesktop.decideReview(project.project_id, detailValue!.revision.id, input.action, { reason: input.reason }), + onSuccess: () => { setReason(''); void inbox.refetch(); void detail.refetch(); }, + }); + const commentMutation = useMutation({ + mutationFn: () => window.contentcloudDesktop.addReviewComment(project.project_id, { revision_id: detailValue!.revision.id, body: comment }), + onSuccess: () => { setComment(''); void detail.refetch(); void inbox.refetch(); }, + }); + + if (inbox.isLoading) return ; + if (inbox.data?.status === 'offline') return

审批收件箱暂不可用

{inbox.data.message}

; + if (!items.length) return

审批收件箱为空

当前项目没有待处理的提交内容版本。

; + + return
+ +
+ {detailValue ? mutation.mutate({ action, reason })} onComment={() => commentMutation.mutate()} busy={mutation.isPending || commentMutation.isPending} /> : } +
+
; +} + +function ReviewDetail({ detail, reason, comment, setReason, setComment, onDecision, onComment, busy }: { detail: DesktopReviewRevisionDetail; reason: string; comment: string; setReason: (value: string) => void; setComment: (value: string) => void; onDecision: (action: 'approve' | 'reject' | 'request-changes') => void; onComment: () => void; busy: boolean }) { + const can = (action: DesktopReviewAction) => detail.allowed_actions.includes(action); + return <> +
{detail.revision.schema_version}

Revision {detail.revision.revision_no}

{detail.revision.content_hash} · {formatDate(detail.revision.created_at)}

{detail.submission.status}
+
{detail.diffs.map((diff) =>
{diff.path || diff.object_id}

{diff.object_type}

{diff.change}
{diff.current_content ?? diff.base_content ?? ''}
)}
+
+