diff --git a/.github/actions/desktop-setup/action.yml b/.github/actions/desktop-setup/action.yml
new file mode 100644
index 000000000..f3e84c62f
--- /dev/null
+++ b/.github/actions/desktop-setup/action.yml
@@ -0,0 +1,75 @@
+name: Desktop build setup
+description: Restore target-specific toolchains and caches, and consume the shared web/catalog artifacts.
+
+inputs:
+ target:
+ description: Rust target triple; also separates cross-compiled Go and Rust caches.
+ required: true
+ source-sha:
+ description: Immutable source commit used to refresh the Go compilation cache.
+ required: true
+
+runs:
+ using: composite
+ steps:
+ - name: Verify source commit
+ shell: bash
+ env:
+ SOURCE_SHA: ${{ inputs.source-sha }}
+ run: node apps/desktop/scripts/desktop-source.mjs
+ - uses: pnpm/action-setup@v5
+ with:
+ version: 10.17.0
+ - uses: actions/setup-node@v5
+ with:
+ node-version-file: .nvmrc
+ cache: pnpm
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ toolchain: 1.99.1
+ targets: ${{ inputs.target }}
+ - uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: apps/desktop/src-tauri
+ shared-key: desktop-release-${{ inputs.target }}
+ - uses: actions/setup-go@v6
+ id: go
+ with:
+ go-version-file: backend/go.mod
+ cache: false
+ - name: Locate Go caches
+ id: go-paths
+ shell: bash
+ run: |
+ echo "build=$(go env GOCACHE)" >> "$GITHUB_OUTPUT"
+ echo "modules=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT"
+ # Both macOS targets run on arm64, so key compiled packages by target
+ # triple; the source SHA refreshes builds while retaining dependency caches.
+ - name: Cache Go sidecar build
+ uses: actions/cache@v5
+ with:
+ path: |
+ ${{ steps.go-paths.outputs.build }}
+ ${{ steps.go-paths.outputs.modules }}
+ key: desktop-go-${{ runner.os }}-${{ inputs.target }}-${{ steps.go.outputs.go-version }}-${{ hashFiles('backend/go.sum') }}-${{ inputs.source-sha }}
+ restore-keys: |
+ desktop-go-${{ runner.os }}-${{ inputs.target }}-${{ steps.go.outputs.go-version }}-${{ hashFiles('backend/go.sum') }}-
+ desktop-go-${{ runner.os }}-${{ inputs.target }}-${{ steps.go.outputs.go-version }}-
+ - name: Install desktop CLI
+ shell: bash
+ run: pnpm --filter @deeix/desktop install --frozen-lockfile
+ - name: Download frontend
+ uses: actions/download-artifact@v7
+ with:
+ name: desktop-web
+ path: apps/web/out
+ - name: Download catalog snapshots
+ uses: actions/download-artifact@v7
+ with:
+ name: desktop-catalog
+ path: backend/internal/infra/catalogdata/data
+ - name: Check C compiler and frontend
+ shell: bash
+ run: |
+ gcc --version
+ test -f apps/web/out/index.html
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f9506c232..c197d54c6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -20,7 +20,6 @@ concurrency:
env:
PNPM_VERSION: 10.17.0
- NODE_VERSION: 24
# Incremental artefacts are useless across CI runs and bloat the Rust cache.
CARGO_INCREMENTAL: 0
@@ -36,9 +35,9 @@ jobs:
docker: ${{ steps.filter.outputs.docker }}
lockfiles: ${{ steps.filter.outputs.lockfiles }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- id: filter
- uses: dorny/paths-filter@v3
+ uses: dorny/paths-filter@v4
with:
filters: |
shared: &shared
@@ -77,7 +76,7 @@ jobs:
if: github.event_name == 'pull_request'
runs-on: ubuntu-24.04
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Check commit subjects
@@ -113,8 +112,8 @@ jobs:
run:
working-directory: backend
steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-go@v5
+ - uses: actions/checkout@v5
+ - uses: actions/setup-go@v6
with:
go-version-file: backend/go.mod
cache-dependency-path: backend/go.sum
@@ -140,15 +139,15 @@ jobs:
if: needs.changes.outputs.web == 'true'
runs-on: ubuntu-24.04
steps:
- - uses: actions/checkout@v4
- - uses: pnpm/action-setup@v4
+ - uses: actions/checkout@v5
+ - uses: pnpm/action-setup@v5
with:
version: ${{ env.PNPM_VERSION }}
- - uses: actions/setup-node@v4
+ - uses: actions/setup-node@v5
with:
- node-version: ${{ env.NODE_VERSION }}
+ node-version-file: .nvmrc
cache: pnpm
- - uses: actions/setup-go@v5
+ - uses: actions/setup-go@v6
with:
go-version-file: backend/go.mod
cache-dependency-path: backend/go.sum
@@ -159,7 +158,7 @@ jobs:
- name: Test
run: pnpm turbo run test --filter=./packages/*
- name: Cache Next.js build
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: apps/web/.next/cache
# Source hash excludes installed and generated trees, which exist by the time this step runs.
@@ -178,7 +177,7 @@ jobs:
run:
working-directory: apps/desktop/src-tauri
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
@@ -213,11 +212,11 @@ jobs:
if: needs.changes.outputs.python == 'true'
runs-on: ubuntu-24.04
steps:
- - uses: actions/checkout@v4
- - uses: astral-sh/ruff-action@v3
+ - uses: actions/checkout@v5
+ - uses: astral-sh/ruff-action@v4.1.0
with:
args: check deploy/services backend/tools
- - uses: astral-sh/ruff-action@v3
+ - uses: astral-sh/ruff-action@v4.1.0
with:
args: format --check deploy/services backend/tools
@@ -227,13 +226,13 @@ jobs:
if: needs.changes.outputs.docker == 'true'
runs-on: ubuntu-24.04
steps:
- - uses: actions/checkout@v4
- - uses: docker/setup-buildx-action@v3
+ - uses: actions/checkout@v5
+ - uses: docker/setup-buildx-action@v4
# Builds the default Dockerfile target, the path users take; release images use the prebuilt
# web assets (see .github/workflows/image.yml). mode=min keeps this job from filling the
# repository-wide cache quota with the multi-GB install and compile layers.
- name: Build image (no push)
- uses: docker/build-push-action@v6
+ uses: docker/build-push-action@v7
with:
context: .
push: false
@@ -246,13 +245,13 @@ jobs:
if: needs.changes.outputs.lockfiles == 'true' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-24.04
steps:
- - uses: actions/checkout@v4
- - uses: pnpm/action-setup@v4
+ - uses: actions/checkout@v5
+ - uses: pnpm/action-setup@v5
with:
version: ${{ env.PNPM_VERSION }}
- - uses: actions/setup-node@v4
+ - uses: actions/setup-node@v5
with:
- node-version: ${{ env.NODE_VERSION }}
+ node-version-file: .nvmrc
cache: pnpm
- uses: dtolnay/rust-toolchain@stable
- name: Install
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 01ed2dff0..8a294d559 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -35,7 +35,7 @@ jobs:
build-mode: none
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
diff --git a/.github/workflows/desktop-channel.yml b/.github/workflows/desktop-channel.yml
index 3bb207a19..6f06f36f6 100644
--- a/.github/workflows/desktop-channel.yml
+++ b/.github/workflows/desktop-channel.yml
@@ -24,7 +24,7 @@ jobs:
if: github.event.release.tag_name != 'desktop-beta'
runs-on: ubuntu-24.04
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- name: Refresh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/desktop-linux.yml b/.github/workflows/desktop-linux.yml
new file mode 100644
index 000000000..40bf966ec
--- /dev/null
+++ b/.github/workflows/desktop-linux.yml
@@ -0,0 +1,93 @@
+name: Desktop · Linux x64
+
+on:
+ workflow_call:
+ inputs:
+ source-sha:
+ required: true
+ type: string
+ channel:
+ required: true
+ type: string
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+ runs-on: ubuntu-24.04
+ timeout-minutes: 45
+ env:
+ CARGO_INCREMENTAL: 0
+ TARGET: x86_64-unknown-linux-gnu
+ CHANNEL: ${{ inputs.channel }}
+ defaults:
+ run:
+ shell: bash
+ steps:
+ - uses: actions/checkout@v5
+ with:
+ ref: ${{ github.sha }}
+ - uses: ./.github/actions/desktop-setup
+ with:
+ target: ${{ env.TARGET }}
+ source-sha: ${{ inputs.source-sha }}
+ - name: Install system dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
+ - name: Build server sidecar
+ run: node apps/desktop/scripts/build-sidecar.mjs --target "$TARGET"
+ - name: Configure desktop build
+ run: |
+ node apps/desktop/scripts/release-config.mjs --channel "$CHANNEL" --repository "$GITHUB_REPOSITORY" \
+ --prebuilt-frontend --output "$RUNNER_TEMP/desktop-config.json"
+ - name: Compile desktop executable
+ run: |
+ pnpm --filter @deeix/desktop exec tauri build --no-bundle --target "$TARGET" \
+ --config "$RUNNER_TEMP/desktop-config.json" -- --locked --timings
+ - name: Exclude Wayland libraries from AppImage
+ run: bash apps/desktop/scripts/prepare-linuxdeploy-wrapper.sh
+ - name: Bundle deb and AppImage
+ env:
+ LINUXDEPLOY_DEBUG_LOG: ${{ runner.temp }}/linuxdeploy.log
+ TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
+ TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
+ run: |
+ pnpm --filter @deeix/desktop exec tauri bundle --target "$TARGET" --bundles deb,appimage \
+ --config "$RUNNER_TEMP/desktop-config.json"
+ - name: Show linuxdeploy log
+ if: always()
+ run: cat "$RUNNER_TEMP/linuxdeploy.log" 2>/dev/null || echo "(no linuxdeploy log)"
+ - name: Verify AppImage
+ run: |
+ shopt -s nullglob
+ images=(apps/desktop/src-tauri/target/"$TARGET"/release/bundle/appimage/*.AppImage)
+ ((${#images[@]} == 1)) || { echo "::error::expected one AppImage"; exit 1; }
+ workdir="$(mktemp -d)"
+ trap 'rm -rf "$workdir"' EXIT
+ (cd "$workdir" && "$GITHUB_WORKSPACE/${images[0]}" --appimage-extract >/dev/null 2>&1)
+ test -d "$workdir/squashfs-root/usr/lib"
+ if find "$workdir/squashfs-root/usr/lib" -name 'libwayland*' -print -quit | grep -q .; then
+ echo "::error::AppImage still bundles Wayland libraries"
+ exit 1
+ fi
+ - name: Stage release assets
+ run: |
+ node apps/desktop/scripts/collect-release-assets.mjs --platform linux-x64 \
+ --bundle "apps/desktop/src-tauri/target/$TARGET/release/bundle" --out "$RUNNER_TEMP/release-assets"
+ - uses: actions/upload-artifact@v6
+ with:
+ name: desktop-release-linux-x64
+ path: ${{ runner.temp }}/release-assets
+ compression-level: 0
+ if-no-files-found: error
+ overwrite: true
+ - name: Upload compilation timings
+ if: always()
+ uses: actions/upload-artifact@v6
+ with:
+ name: desktop-timings-linux-x64
+ path: apps/desktop/src-tauri/target/cargo-timings
+ overwrite: true
+ retention-days: 7
diff --git a/.github/workflows/desktop-macos.yml b/.github/workflows/desktop-macos.yml
new file mode 100644
index 000000000..fe5f019eb
--- /dev/null
+++ b/.github/workflows/desktop-macos.yml
@@ -0,0 +1,114 @@
+name: Desktop · macOS
+
+on:
+ workflow_call:
+ inputs:
+ source-sha:
+ required: true
+ type: string
+ channel:
+ required: true
+ type: string
+ arch:
+ description: arm64 or x64
+ required: true
+ type: string
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+ runs-on: macos-26
+ timeout-minutes: 60
+ env:
+ CARGO_INCREMENTAL: 0
+ ARCH: ${{ inputs.arch }}
+ TARGET: ${{ inputs.arch == 'arm64' && 'aarch64-apple-darwin' || 'x86_64-apple-darwin' }}
+ CHANNEL: ${{ inputs.channel }}
+ defaults:
+ run:
+ shell: bash
+ steps:
+ - uses: actions/checkout@v5
+ with:
+ ref: ${{ github.sha }}
+ - name: Validate architecture
+ run: '[[ "$ARCH" == arm64 || "$ARCH" == x64 ]]'
+ - uses: ./.github/actions/desktop-setup
+ with:
+ target: ${{ env.TARGET }}
+ source-sha: ${{ inputs.source-sha }}
+ - name: Build server sidecar
+ run: node apps/desktop/scripts/build-sidecar.mjs --target "$TARGET"
+ - name: Configure Apple signing
+ env:
+ APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
+ APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
+ APPLE_ID: ${{ secrets.APPLE_ID }}
+ APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
+ SIGN_AND_NOTARIZE_SCRIPT_URL: ${{ secrets.SIGN_AND_NOTARIZE_SCRIPT_URL }}
+ SIGN_AND_NOTARIZE_BASE_URL: ${{ secrets.SIGN_AND_NOTARIZE_BASE_URL }}
+ SIGN_AND_NOTARIZE_API_TOKEN: ${{ secrets.SIGN_AND_NOTARIZE_API_TOKEN }}
+ SIGN_AND_NOTARIZE_HELPER_URL: ${{ secrets.SIGN_AND_NOTARIZE_HELPER_URL }}
+ run: bash apps/desktop/scripts/configure-apple-signing.sh
+ - name: Configure desktop build
+ run: |
+ node apps/desktop/scripts/release-config.mjs --channel "$CHANNEL" --repository "$GITHUB_REPOSITORY" \
+ --prebuilt-frontend --defer-updater --output "$RUNNER_TEMP/desktop-config.json"
+ - name: Compile desktop executable
+ run: |
+ pnpm --filter @deeix/desktop exec tauri build --no-bundle --target "$TARGET" \
+ --config "$RUNNER_TEMP/desktop-config.json" -- --locked --timings
+ - name: Bundle app
+ # With Apple credentials Tauri also signs/notarizes here. The external
+ # mode uses ad-hoc signing until the next step, avoiding two notarizations.
+ run: |
+ pnpm --filter @deeix/desktop exec tauri bundle --target "$TARGET" --bundles app \
+ --config "$RUNNER_TEMP/desktop-config.json"
+ - name: Sign and notarize app (external service)
+ if: env.DESKTOP_SIGNING_MODE == 'external'
+ timeout-minutes: 20
+ env:
+ SIGN_AND_NOTARIZE_SCRIPT_URL: ${{ secrets.SIGN_AND_NOTARIZE_SCRIPT_URL }}
+ SIGN_AND_NOTARIZE_BASE_URL: ${{ secrets.SIGN_AND_NOTARIZE_BASE_URL }}
+ SIGN_AND_NOTARIZE_API_TOKEN: ${{ secrets.SIGN_AND_NOTARIZE_API_TOKEN }}
+ SIGN_AND_NOTARIZE_HELPER_URL: ${{ secrets.SIGN_AND_NOTARIZE_HELPER_URL }}
+ run: bash apps/desktop/scripts/package-macos.sh sign-app "$TARGET"
+ - name: Create final DMG
+ run: bash apps/desktop/scripts/package-macos.sh dmg "$TARGET"
+ - name: Sign and notarize DMG
+ if: env.DESKTOP_SIGNING_MODE != 'adhoc'
+ timeout-minutes: 20
+ env:
+ SIGN_AND_NOTARIZE_SCRIPT_URL: ${{ secrets.SIGN_AND_NOTARIZE_SCRIPT_URL }}
+ SIGN_AND_NOTARIZE_BASE_URL: ${{ secrets.SIGN_AND_NOTARIZE_BASE_URL }}
+ SIGN_AND_NOTARIZE_API_TOKEN: ${{ secrets.SIGN_AND_NOTARIZE_API_TOKEN }}
+ SIGN_AND_NOTARIZE_HELPER_URL: ${{ secrets.SIGN_AND_NOTARIZE_HELPER_URL }}
+ run: bash apps/desktop/scripts/package-macos.sh sign-dmg "$TARGET"
+ - name: Create signed updater archive
+ env:
+ TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
+ TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
+ run: bash apps/desktop/scripts/package-macos.sh updater "$TARGET"
+ - name: Verify DMG and updater archive
+ run: bash apps/desktop/scripts/package-macos.sh verify "$TARGET"
+ - name: Stage release assets
+ run: |
+ node apps/desktop/scripts/collect-release-assets.mjs --platform "macos-$ARCH" \
+ --bundle "apps/desktop/src-tauri/target/$TARGET/release/bundle" --out "$RUNNER_TEMP/release-assets"
+ - uses: actions/upload-artifact@v6
+ with:
+ name: desktop-release-macos-${{ inputs.arch }}
+ path: ${{ runner.temp }}/release-assets
+ compression-level: 0
+ if-no-files-found: error
+ overwrite: true
+ - name: Upload compilation timings
+ if: always()
+ uses: actions/upload-artifact@v6
+ with:
+ name: desktop-timings-macos-${{ inputs.arch }}
+ path: apps/desktop/src-tauri/target/cargo-timings
+ overwrite: true
+ retention-days: 7
diff --git a/.github/workflows/desktop-prepare.yml b/.github/workflows/desktop-prepare.yml
new file mode 100644
index 000000000..328395692
--- /dev/null
+++ b/.github/workflows/desktop-prepare.yml
@@ -0,0 +1,109 @@
+name: Desktop · Shared inputs
+
+on:
+ workflow_call:
+ inputs:
+ release-tag:
+ type: string
+ default: ""
+ outputs:
+ source-sha:
+ value: ${{ jobs.resolve.outputs.source-sha }}
+ release-tag:
+ value: ${{ jobs.resolve.outputs.release-tag }}
+ channel:
+ value: ${{ jobs.resolve.outputs.channel }}
+
+permissions:
+ contents: read
+
+jobs:
+ resolve:
+ name: Resolve source and version
+ runs-on: ubuntu-24.04
+ outputs:
+ source-sha: ${{ steps.source.outputs.sha }}
+ release-tag: ${{ steps.source.outputs.release-tag }}
+ channel: ${{ steps.source.outputs.channel }}
+ steps:
+ - uses: actions/checkout@v5
+ with:
+ ref: ${{ github.sha }}
+ - name: Verify source
+ id: source
+ env:
+ RELEASE_TAG: ${{ inputs.release-tag }}
+ run: |
+ if [[ ! -f .github/actions/desktop-setup/action.yml || ! -f apps/desktop/scripts/collect-release-assets.mjs ]]; then
+ echo "::error::This source predates the reusable desktop build. Rerun its original workflow, or choose a tag containing the new build scripts."
+ exit 1
+ fi
+ node --input-type=module <<'JS'
+ import { appendFileSync, readFileSync } from 'node:fs';
+ import { execFileSync } from 'node:child_process';
+ import { currentDesktopSource } from './apps/desktop/scripts/desktop-source.mjs';
+ const { sha, releaseTag: tag } = currentDesktopSource();
+ execFileSync(process.execPath, ['scripts/sync-version.mjs', '--check'], { stdio: 'inherit' });
+ const version = readFileSync('VERSION', 'utf8').trim();
+ if (tag && tag !== `v${version}`) throw new Error(`Tag ${tag} does not match VERSION ${version}`);
+ const channel = version.includes('-') ? 'beta' : 'stable';
+ appendFileSync(process.env.GITHUB_OUTPUT, `sha=${sha}\nrelease-tag=${tag}\nchannel=${channel}\n`);
+ appendFileSync(process.env.GITHUB_STEP_SUMMARY, `Source: ${sha}\n\nVersion: ${version} (${channel})\n`);
+ JS
+
+ web:
+ name: Build frontend once
+ needs: resolve
+ runs-on: ubuntu-24.04
+ steps:
+ - uses: actions/checkout@v5
+ with:
+ ref: ${{ github.sha }}
+ - uses: pnpm/action-setup@v5
+ with:
+ version: 10.17.0
+ - uses: actions/setup-node@v5
+ with:
+ node-version-file: .nvmrc
+ cache: pnpm
+ - run: pnpm install --frozen-lockfile
+ # Restore compatible default-branch CI caches; new entries stay ref-scoped.
+ - name: Cache Next.js build
+ uses: actions/cache@v5
+ with:
+ path: apps/web/.next/cache
+ key: next-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('apps/web/**/*.[jt]s', 'apps/web/**/*.[jt]sx', 'packages/**/*.ts', '!**/node_modules/**', '!apps/web/.next/**', '!apps/web/out/**') }}
+ restore-keys: |
+ next-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
+ - run: pnpm turbo run build --filter=@deeix/web
+ - uses: actions/upload-artifact@v6
+ with:
+ name: desktop-web
+ path: apps/web/out
+ include-hidden-files: true
+ if-no-files-found: error
+ overwrite: true
+ retention-days: 7
+
+ catalog:
+ name: Refresh catalog once
+ needs: resolve
+ runs-on: ubuntu-24.04
+ steps:
+ - uses: actions/checkout@v5
+ with:
+ ref: ${{ github.sha }}
+ - uses: actions/setup-go@v6
+ with:
+ go-version-file: backend/go.mod
+ cache-dependency-path: backend/go.sum
+ - name: Refresh bundled catalog snapshots
+ working-directory: backend
+ run: go run ./cmd/catalog-snapshot -keep-on-error
+ - uses: actions/upload-artifact@v6
+ with:
+ name: desktop-catalog
+ path: backend/internal/infra/catalogdata/data
+ if-no-files-found: error
+ overwrite: true
+ retention-days: 7
diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml
index e6e6439d0..e7c311dad 100644
--- a/.github/workflows/desktop-release.yml
+++ b/.github/workflows/desktop-release.yml
@@ -1,574 +1,130 @@
name: Desktop Release
-# Builds and publishes the Tauri desktop app for macOS, Windows and Linux.
-#
-# Repository secrets:
-#
-# Updater (required — the build fails without them)
-# TAURI_SIGNING_PRIVATE_KEY output of `tauri signer generate`
-# TAURI_SIGNING_PRIVATE_KEY_PASSWORD its password ("" if none)
-#
-# macOS signing + notarization (optional; set all of them or none)
-# APPLE_CERTIFICATE base64 of the Developer ID Application .p12
-# APPLE_CERTIFICATE_PASSWORD .p12 password
-# APPLE_ID Apple ID used for notarization
-# APPLE_PASSWORD app-specific password for that Apple ID
-# (signing identity and team id are read from the certificate)
-#
-# Windows code signing (optional; unsigned builds when absent)
-# WINDOWS_CERTIFICATE base64 of the code-signing .pfx
-# WINDOWS_CERTIFICATE_PASSWORD .pfx password
-#
-# Repository variables (optional):
-# WEBVIEW2_FIXED_RUNTIME_URL x64 WebView2 Fixed Version .cab from
-# Microsoft; when set, the Windows leg also
-# builds the -portable-offline.zip
-# WEBVIEW2_FIXED_RUNTIME_SHA256 expected SHA-256 of that .cab (pinning)
-#
-# Signing switches on by itself once the secrets exist. Without them the
-# installers are unsigned: macOS users must allow the app under
-# System Settings → Privacy & Security, Windows shows a SmartScreen prompt.
-# A partial set of the APPLE_* secrets fails the build instead.
-#
-# Windows ships three forms from one set of binaries: the per-user NSIS setup
-# (auto-updates), the per-machine MSI for IT (no in-app updates; its
-# upgradeCode in tauri.conf.json must never change) and a portable zip
-# (scripts/package-portable.mjs). Every release also gets SHA256SUMS, and the
-# Windows files get GitHub build provenance (`gh attestation verify`).
-
-# Release channels are decided by the tag:
-# v1.2.3 stable → normal release; the app polls releases/latest/download/latest.json
-# v1.2.3-beta.1 beta → prerelease; the app polls releases/download/desktop-beta/latest.json,
-# which desktop-channel.yml refreshes when a prerelease is published.
-# Both channels are signed with the same updater key: a channel is a distribution
-# lane, not a trust boundary. A beta build only differs in the endpoint it polls.
-
+# Coordinate shared inputs, native builds and publication in the source ref's
+# context. Tag code must never write caches in the default branch's scope.
on:
push:
tags:
- "v*.*.*"
- "v*.*.*-*"
- # Manual runs build every target and upload the installers as workflow
- # artifacts; only tag pushes create a GitHub Release.
workflow_dispatch:
+ inputs:
+ release_tag:
+ description: "Existing tag to dispatch; leave empty to build the selected ref"
+ type: string
+ default: ""
permissions:
- contents: write
+ contents: read
-env:
- # Incremental artefacts are useless across CI runs and bloat the Rust cache.
- CARGO_INCREMENTAL: 0
+concurrency:
+ group: desktop-release-${{ inputs.release_tag || github.ref_name }}
+ cancel-in-progress: false
jobs:
- # The draft is created exactly once, here, before any leg starts. Each build
- # leg then uploads into it by id. Letting the legs create it is unsafe: a
- # draft is invisible to a tag lookup, so two legs finishing together both see
- # "no release" and each create their own, which is how one tag ends up with
- # two half-filled drafts.
- release:
- name: Create draft release
- if: github.ref_type == 'tag'
+ dispatch:
+ name: Dispatch release on its tag
+ if: inputs.release_tag != '' && github.ref_type != 'tag'
runs-on: ubuntu-24.04
permissions:
- contents: write
- outputs:
- id: ${{ steps.release.outputs.id }}
+ actions: write
steps:
- - name: Create draft release
- id: release
+ - name: Dispatch
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
+ RELEASE_TAG: ${{ inputs.release_tag }}
run: |
- set -euo pipefail
- tag="${{ github.ref_name }}"
- # tauri-action needs the numeric REST id, so the REST API is used throughout.
- # Drafts have no tag yet, so they are matched on tag_name in the list.
- ids="$(gh api "repos/$GH_REPO/releases?per_page=100" \
- --jq ".[] | select(.tag_name == \"$tag\") | .id")"
- count="$(printf '%s' "$ids" | grep -c . || true)"
- if [[ "$count" -gt 1 ]]; then
- echo "::error::$tag already has $count releases ($(echo $ids)); delete the extra drafts and re-run"
- exit 1
- fi
- id="$ids"
- if [[ -z "$id" ]]; then
- # Release notes list the asset names the assets job produces.
- notes="$RUNNER_TEMP/release-notes.md"
- cat > "$notes" <<'BODY'
- ### ⏬ Download
-
- | Platform | Download | Verified | Available |
- | --- | --- | --- | --- |
- | macOS · Apple silicon | `macos-arm64.dmg` | ✅ Verified | ✅ Available |
- | macOS · Intel | `macos-x64.dmg` | ✅ Verified | ✅ Available |
- | Linux · x86_64 | `linux-x64.AppImage` or `.deb` | ✅ Verified | ✅ Available |
- | Windows · x86_64 | `windows-x64-setup.exe` | ✅ Verified | ✅ Available |
- | Windows · x86_64 · MSI | `windows-x64.msi` | ✅ Verified | ✅ Available |
- | Windows · x86_64 · Portable | `windows-x64-portable.zip` | ✅ Verified | ✅ Available |
-
- Checksums are in `SHA256SUMS`. `-updater.tar.gz`, `.sig` and `latest.json` belong to the built-in updater.
- BODY
- # Beta tags (v1.2.3-beta.1) are prereleases; stable tags are not.
- if [[ "$tag" == *-* ]]; then prerelease=true; else prerelease=false; fi
- # Draft until a human publishes it: publishing is the act that ships.
- id="$(gh api -X POST "repos/$GH_REPO/releases" \
- -f tag_name="$tag" -f name="$tag" -F draft=true -F prerelease="$prerelease" \
- -F body=@"$notes" --jq .id)"
- fi
- echo "id=$id" >> "$GITHUB_OUTPUT"
- echo "Draft release $tag has id $id"
-
- build:
- name: Build (${{ matrix.label }})
- needs: release
- # On a manual run (no tag) no release is created and the legs only upload
- # workflow artifacts, so a skipped release job is fine.
- if: ${{ !cancelled() && needs.release.result != 'failure' }}
- runs-on: ${{ matrix.runner }}
+ gh workflow run desktop-release.yml --ref "refs/tags/$RELEASE_TAG"
+ echo "Release $RELEASE_TAG dispatched in its tag context." >> "$GITHUB_STEP_SUMMARY"
+
+ prepare:
+ name: Prepare shared inputs
+ if: inputs.release_tag == '' || github.ref_type == 'tag'
+ uses: ./.github/workflows/desktop-prepare.yml
+ with:
+ release-tag: ${{ inputs.release_tag || (github.ref_type == 'tag' && github.ref_name) || '' }}
+
+ macos-arm64:
+ name: macOS arm64
+ needs: prepare
+ uses: ./.github/workflows/desktop-macos.yml
+ with:
+ source-sha: ${{ needs.prepare.outputs.source-sha }}
+ channel: ${{ needs.prepare.outputs.channel }}
+ arch: arm64
+ secrets: inherit
+
+ macos-x64:
+ name: macOS x64
+ needs: prepare
+ uses: ./.github/workflows/desktop-macos.yml
+ with:
+ source-sha: ${{ needs.prepare.outputs.source-sha }}
+ channel: ${{ needs.prepare.outputs.channel }}
+ arch: x64
+ secrets: inherit
+
+ linux-x64:
+ name: Linux x64
+ needs: prepare
+ uses: ./.github/workflows/desktop-linux.yml
+ with:
+ source-sha: ${{ needs.prepare.outputs.source-sha }}
+ channel: ${{ needs.prepare.outputs.channel }}
+ secrets: inherit
+
+ windows-x64:
+ name: Windows x64
+ needs: prepare
permissions:
- contents: write # upload assets into the draft release
- id-token: write # sign build provenance (Sigstore)
- attestations: write # store build provenance
- strategy:
- fail-fast: false
- matrix:
- include:
- - label: macOS arm64
- platform: macOS
- runner: macos-15
- args: "--target aarch64-apple-darwin"
- - label: macOS x64
- platform: macOS
- runner: macos-15
- args: "--target x86_64-apple-darwin"
- - label: Linux x64
- platform: Linux
- runner: ubuntu-24.04
- args: ""
- - label: Windows x64
- platform: Windows
- runner: windows-2022
- args: ""
-
+ contents: read
+ id-token: write
+ attestations: write
+ uses: ./.github/workflows/desktop-windows.yml
+ with:
+ source-sha: ${{ needs.prepare.outputs.source-sha }}
+ channel: ${{ needs.prepare.outputs.channel }}
+ secrets: inherit
+
+ publish:
+ name: Assemble draft release
+ needs: [prepare, macos-arm64, macos-x64, linux-x64, windows-x64]
+ if: needs.prepare.outputs.release-tag != ''
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: write
steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Set up pnpm
- uses: pnpm/action-setup@v4
- with:
- version: 10.17.0
-
- - name: Set up Node.js
- uses: actions/setup-node@v4
- with:
- node-version: 24
- cache: pnpm
-
- - name: Set up Rust
- uses: dtolnay/rust-toolchain@stable
- with:
- targets: ${{ matrix.platform == 'macOS' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
-
- - name: Cache Rust build
- uses: Swatinem/rust-cache@v2
- with:
- workspaces: apps/desktop/src-tauri
-
- - name: Install Linux system dependencies
- if: matrix.platform == 'Linux'
- run: |
- sudo apt-get update
- sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
-
- - name: Set up Go
- uses: actions/setup-go@v5
- with:
- go-version-file: backend/go.mod
- cache-dependency-path: backend/go.sum
-
- - name: Install workspace dependencies
- run: pnpm install --frozen-lockfile
-
- # Windows needs a C compiler for cgo; fail early with a clear message
- # rather than inside the Go build.
- - name: Check C compiler
- shell: bash
- run: gcc --version | head -1 || { echo "::error::no C compiler for cgo"; exit 1; }
-
- # Version drift between VERSION, package.json, tauri.conf.json and
- # Cargo.toml must fail the release rather than ship a mislabelled build.
- - name: Verify version consistency
- run: node scripts/sync-version.mjs --check
-
- # The Go server ships inside the app as a sidecar (local mode). It links
- # SQLite through cgo, so it is built natively on each matrix leg; macOS
- # x86_64 is cross-built from the arm64 runner with Apple's toolchain
- # (verify-macos-bundle.sh below checks the architecture it ends up with).
- - name: Build server sidecar
- shell: bash
- # Refresh the bundled catalog snapshots (models.dev, OpenRouter pricing)
- # before compiling; a failed fetch keeps the committed snapshot.
- env:
- DEEIX_REFRESH_CATALOG_SNAPSHOTS: "1"
- run: |
- target="${{ matrix.args }}"
- target="${target#--target }"
- if [[ -n "$target" ]]; then
- node apps/desktop/scripts/build-sidecar.mjs --target "$target"
- else
- node apps/desktop/scripts/build-sidecar.mjs
- fi
-
- # Tauri treats a signing variable that merely exists as configured, so
- # the Apple credentials are exported only when the secrets are set;
- # identity and team id are then read from the certificate. A partial set
- # fails here: falling back to ad-hoc would ship an unnotarized app from
- # what looks like a signed release.
- - name: Configure Apple signing
- if: matrix.platform == 'macOS'
- shell: bash
- env:
- APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
- APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
- APPLE_ID: ${{ secrets.APPLE_ID }}
- APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
- run: |
- set -euo pipefail
- # The .p12 password may legitimately be empty, so it only counts
- # towards "something is configured".
- present=() missing=()
- for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_ID APPLE_PASSWORD; do
- if [[ -n "${!name}" ]]; then
- present+=("$name")
- elif [[ "$name" != APPLE_CERTIFICATE_PASSWORD ]]; then
- missing+=("$name")
- fi
- done
- if ((${#present[@]} == 0)); then
- # Ad-hoc: Tauri skips codesign without an identity, and the linker's
- # own signature is not a valid bundle signature ("damaged" on macOS).
- echo "APPLE_SIGNING_IDENTITY=-" >> "$GITHUB_ENV"
- echo "::warning::APPLE_* secrets not set; the macOS app is ad-hoc signed and not notarized"
- exit 0
- fi
- if ((${#missing[@]} > 0)); then
- echo "::error::Apple signing is only partly configured: ${present[*]} set, ${missing[*]} missing. Set them all to sign and notarize, or remove them all for an ad-hoc build."
- exit 1
- fi
- for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_ID APPLE_PASSWORD; do
- printf '%s<<__EOF__\n%s\n__EOF__\n' "$name" "${!name}" >> "$GITHUB_ENV"
- done
- bash apps/desktop/scripts/apple-signing-env.sh >> "$GITHUB_ENV"
-
- # Tauri signs Windows binaries with a certificate from the user store,
- # selected by thumbprint. tauri-cli only reads it from
- # bundle.windows.certificateThumbprint (no environment variable), so the
- # next step passes it with --config.
- - name: Import Windows code-signing certificate
- if: matrix.platform == 'Windows'
- shell: pwsh
- env:
- WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
- WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
- run: |
- if (-not $env:WINDOWS_CERTIFICATE) {
- Write-Host "::warning::WINDOWS_CERTIFICATE not set; building an unsigned Windows installer"
- exit 0
- }
- $pfx = Join-Path $env:RUNNER_TEMP "codesign.pfx"
- [IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE))
- $secure = ConvertTo-SecureString $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force
- $cert = Import-PfxCertificate -FilePath $pfx -CertStoreLocation Cert:\CurrentUser\My -Password $secure
- Remove-Item $pfx
- # A thumbprint is a public hash of the certificate, not a secret.
- "WINDOWS_CERTIFICATE_THUMBPRINT=$($cert.Thumbprint)" | Out-File -FilePath $env:GITHUB_ENV -Append
-
- # Build-time config (scripts/release-config.mjs): the beta updater
- # endpoint, the MSI's numeric ProductVersion and the signing thumbprint.
- - name: Resolve release channel
- id: channel
- shell: bash
- run: |
- set -euo pipefail
- tag="${{ github.ref_type == 'tag' && github.ref_name || '' }}"
- if [[ "$tag" == *-* ]]; then channel=beta; else channel=stable; fi
- echo "name=$channel" >> "$GITHUB_OUTPUT"
- args=(--channel "$channel" --repository "${{ github.repository }}")
- if [[ -n "${WINDOWS_CERTIFICATE_THUMBPRINT:-}" ]]; then
- args+=(--thumbprint "$WINDOWS_CERTIFICATE_THUMBPRINT")
- fi
- # One `--config ` without spaces: tauri-action splits `args` on whitespace.
- echo "tauri_args=$(node apps/desktop/scripts/release-config.mjs "${args[@]}")" >> "$GITHUB_OUTPUT"
-
- # Swap tauri-cli's cached linuxdeploy for a wrapper that excludes
- # libwayland-* from the AppImage bundle (blank window on newer
- # Wayland distros otherwise); see prepare-linuxdeploy-wrapper.sh.
- - name: Exclude Wayland libraries from the AppImage bundle
- if: matrix.platform == 'Linux'
- shell: bash
- run: bash apps/desktop/scripts/prepare-linuxdeploy-wrapper.sh
-
- - name: Build desktop bundles
- uses: tauri-apps/tauri-action@v0
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
- TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
- # Captured by the linuxdeploy wrapper so failures are diagnosable.
- LINUXDEPLOY_DEBUG_LOG: ${{ runner.temp }}/linuxdeploy.log
- # APPLE_* and the Windows certificate thumbprint arrive through
- # GITHUB_ENV from the steps above, only when configured.
+ - uses: actions/checkout@v5
with:
- projectPath: apps/desktop
- tauriScript: pnpm tauri
- # Beta endpoint, MSI version and signing thumbprint; see the channel step.
- args: ${{ matrix.args }} ${{ steps.channel.outputs.tauri_args }}
- # Upload into the one draft the release job created. Without a
- # releaseId each leg looks the draft up by tag and creates it when it
- # finds none; legs that finish together all miss and each create one,
- # leaving duplicate drafts that each hold only part of the assets.
- releaseId: ${{ needs.release.outputs.id }}
- # Still needed with a releaseId: it is not used to create anything here
- # (that is skipped when a releaseId is present) but it builds the
- # updater's asset URLs, which a prerelease must point at its own tag.
- tagName: ${{ github.ref_type == 'tag' && github.ref_name || '' }}
- includeUpdaterJson: true
- # With both NSIS and MSI built, point the generic `windows-x86_64`
- # entry at the per-user installer (the auto-updating form). Installed
- # apps look up `windows-x86_64-nsis` / `-msi` first by bundle type.
- updaterJsonPreferNsis: true
-
- # tauri-action swallows linuxdeploy's output; print it so bundling
- # failures are diagnosable.
- - name: Show linuxdeploy log
- if: ${{ always() && matrix.platform == 'Linux' }}
- shell: bash
- run: cat "$RUNNER_TEMP/linuxdeploy.log" 2>/dev/null || echo "(no linuxdeploy log)"
-
- # Fail loudly if the AppImage still bundles Wayland libraries (e.g. a
- # future tauri-cli stopped reusing the cached wrapper).
- - name: Verify no bundled Wayland libraries in the AppImage
- if: matrix.platform == 'Linux'
- shell: bash
- run: |
- set -euo pipefail
- shopt -s nullglob
- for appimage in apps/desktop/src-tauri/target/release/bundle/appimage/*.AppImage; do
- workdir="$(mktemp -d)"
- (cd "$workdir" && "$GITHUB_WORKSPACE/$appimage" --appimage-extract >/dev/null 2>&1)
- if ls "$workdir/squashfs-root/usr/lib" 2>/dev/null | grep -q '^libwayland'; then
- echo "::error::$appimage still bundles libwayland-*; the linuxdeploy wrapper was not used"
- exit 1
- fi
- rm -rf "$workdir"
- done
- echo "AppImage does not bundle Wayland libraries"
-
- # Same post-processing as a local build (scripts/package-dmg.sh). The
- # app is then checked as it ships, inside the final DMG and inside the
- # updater archive (scripts/verify-macos-bundle.sh), and only a verified
- # DMG replaces the asset tauri-action uploaded. tauri-action has already
- # put its own files into the draft, but a failed leg also skips the
- # assets job, so the draft never gets final names or SHA256SUMS.
- - name: Package and verify macOS bundles
- if: matrix.platform == 'macOS'
- shell: bash
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- SIGN_AND_NOTARIZE_SCRIPT_URL: ${{ secrets.SIGN_AND_NOTARIZE_SCRIPT_URL }}
- SIGN_AND_NOTARIZE_BASE_URL: ${{ secrets.SIGN_AND_NOTARIZE_BASE_URL }}
- SIGN_AND_NOTARIZE_API_TOKEN: ${{ secrets.SIGN_AND_NOTARIZE_API_TOKEN }}
- SIGN_AND_NOTARIZE_HELPER_URL: ${{ secrets.SIGN_AND_NOTARIZE_HELPER_URL }}
- run: |
- set -euo pipefail
- shopt -s nullglob
- target="${{ matrix.args }}"
- target="${target#--target }"
- case "$target" in
- aarch64-apple-darwin) arch=arm64 ;;
- x86_64-apple-darwin) arch=x86_64 ;;
- *) echo "::error::unexpected macOS target '$target'"; exit 1 ;;
- esac
- # This leg's target only, not a target/* glob that could also pick up
- # leftovers of the other macOS target from a restored cache.
- bundle="apps/desktop/src-tauri/target/$target/release/bundle"
- dmgs=("$bundle"/dmg/*.dmg)
- archives=("$bundle"/macos/*.app.tar.gz)
- if ((${#dmgs[@]} != 1 || ${#archives[@]} != 1)); then
- echo "::error::expected one DMG and one updater archive under $bundle, found ${#dmgs[@]} and ${#archives[@]}"
- exit 1
- fi
- bash apps/desktop/scripts/package-dmg.sh "${dmgs[0]}"
- bash apps/desktop/scripts/verify-macos-bundle.sh --arch "$arch" "${dmgs[0]}" "${archives[0]}"
- if [[ "${{ github.ref_type }}" == "tag" ]]; then
- gh release upload "${{ github.ref_name }}" "${dmgs[0]}" --clobber --repo "${{ github.repository }}"
- fi
-
- # ---------- Windows: portable zip, signatures, provenance ----------
-
- # The bundler signs (and patches the bundle type into) the copy of the
- # app it packs into each installer, then restores the original unsigned
- # binary in target/release. That original is what the portable zip
- # ships, so it is signed here with the same certificate and settings.
- - name: Sign portable executable
- if: matrix.platform == 'Windows' && env.WINDOWS_CERTIFICATE_THUMBPRINT != ''
- shell: pwsh
- run: |
- $ErrorActionPreference = 'Stop'
- $conf = Get-Content apps/desktop/src-tauri/tauri.conf.json -Raw | ConvertFrom-Json
- $signtool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin\*\x64\signtool.exe" |
- Where-Object { $_.Directory.Parent.Name -as [version] } |
- Sort-Object { [version]$_.Directory.Parent.Name } -Descending |
- Select-Object -First 1
- if (-not $signtool) { throw "signtool.exe not found" }
- $exe = "apps/desktop/src-tauri/target/release/deeix-chat-desktop.exe"
- & $signtool.FullName sign /fd $conf.bundle.windows.digestAlgorithm /sha1 $env:WINDOWS_CERTIFICATE_THUMBPRINT `
- /t $conf.bundle.windows.timestampUrl /d "DEEIX Chat" $exe
- if ($LASTEXITCODE -ne 0) { throw "signtool failed with $LASTEXITCODE" }
-
- # Optional offline variant: Microsoft's WebView2 Fixed Version runtime,
- # checked for Microsoft's Authenticode signature (and a pinned hash when
- # configured) before it is expanded into the package.
- - name: Fetch WebView2 Fixed Version runtime
- id: webview2
- if: matrix.platform == 'Windows' && vars.WEBVIEW2_FIXED_RUNTIME_URL != ''
- shell: pwsh
- env:
- WEBVIEW2_URL: ${{ vars.WEBVIEW2_FIXED_RUNTIME_URL }}
- WEBVIEW2_SHA256: ${{ vars.WEBVIEW2_FIXED_RUNTIME_SHA256 }}
- run: |
- $ErrorActionPreference = 'Stop'
- $cab = Join-Path $env:RUNNER_TEMP "webview2-fixed.cab"
- $dir = Join-Path $env:RUNNER_TEMP "webview2-fixed"
- Invoke-WebRequest -Uri $env:WEBVIEW2_URL -OutFile $cab -UseBasicParsing
- $hash = (Get-FileHash $cab -Algorithm SHA256).Hash
- Write-Host "WebView2 runtime .cab SHA-256: $hash"
- if ($env:WEBVIEW2_SHA256 -and $hash -ne $env:WEBVIEW2_SHA256.ToUpper()) {
- throw "WebView2 runtime hash mismatch: expected $($env:WEBVIEW2_SHA256), got $hash"
- }
- $sig = Get-AuthenticodeSignature $cab
- if ($sig.Status -ne 'Valid' -or $sig.SignerCertificate.Subject -notmatch 'O=Microsoft Corporation') {
- throw "WebView2 runtime .cab is not validly signed by Microsoft: $($sig.Status) $($sig.SignerCertificate.Subject)"
- }
- New-Item -ItemType Directory -Force -Path $dir | Out-Null
- expand.exe $cab -F:* $dir | Out-Null
- if ($LASTEXITCODE -ne 0) { throw "expand failed with $LASTEXITCODE" }
- "dir=$dir" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
-
- - name: Package portable
- id: portable
- if: matrix.platform == 'Windows'
- shell: bash
+ ref: ${{ github.sha }}
+ - name: Verify publication source
env:
- WEBVIEW2_DIR: ${{ steps.webview2.outputs.dir }}
- run: |
- set -euo pipefail
- out=apps/desktop/src-tauri/target/release/bundle/portable
- rm -rf "$out"
- node apps/desktop/scripts/package-portable.mjs --out-dir "$out"
- if [[ -n "$WEBVIEW2_DIR" ]]; then
- node apps/desktop/scripts/package-portable.mjs --out-dir "$out" --webview2 "$WEBVIEW2_DIR"
- fi
- echo "dir=$out" >> "$GITHUB_OUTPUT"
-
- # With a certificate every shipped Windows binary must carry a valid
- # signature; without one, report what is unsigned.
- - name: Verify Windows signatures
- if: matrix.platform == 'Windows'
- shell: pwsh
- run: |
- $ErrorActionPreference = 'Stop'
- $root = "apps/desktop/src-tauri"
- $files = @(
- "$root/target/release/deeix-chat-desktop.exe"
- "$root/binaries/deeix-chat-server-x86_64-pc-windows-msvc.exe"
- )
- $files += Get-ChildItem "$root/target/release/bundle/nsis/*-setup.exe", "$root/target/release/bundle/msi/*.msi" |
- ForEach-Object FullName
- $required = [bool]$env:WINDOWS_CERTIFICATE_THUMBPRINT
- $failed = $false
- foreach ($file in $files) {
- $sig = Get-AuthenticodeSignature -FilePath $file
- $line = "$($sig.Status): $file ($($sig.SignerCertificate.Subject))"
- if ($sig.Status -eq 'Valid') {
- Write-Host $line
- } elseif ($required) {
- Write-Host "::error::$line"
- $failed = $true
- } else {
- Write-Host "::warning::$line"
- }
- }
- if ($failed) { exit 1 }
-
- - name: Attest Windows build provenance
- if: matrix.platform == 'Windows'
- uses: actions/attest-build-provenance@v4
+ SOURCE_SHA: ${{ needs.prepare.outputs.source-sha }}
+ RELEASE_TAG: ${{ needs.prepare.outputs.release-tag }}
+ run: node apps/desktop/scripts/desktop-source.mjs
+ - uses: actions/setup-node@v5
with:
- subject-path: |
- apps/desktop/src-tauri/target/release/bundle/nsis/*-setup.exe
- apps/desktop/src-tauri/target/release/bundle/msi/*.msi
- apps/desktop/src-tauri/target/release/bundle/portable/*.zip
-
- # Uploaded under their final names; the assets job leaves them alone.
- - name: Upload portable packages to the release
- if: matrix.platform == 'Windows' && github.ref_type == 'tag'
- shell: bash
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: gh release upload "${{ github.ref_name }}" "${{ steps.portable.outputs.dir }}"/*.zip --clobber --repo "${{ github.repository }}"
-
- - name: Upload bundles as workflow artifacts
- if: github.ref_type != 'tag'
- uses: actions/upload-artifact@v4
+ node-version-file: .nvmrc
+ package-manager-cache: false
+ - name: Download verified release assets
+ uses: actions/download-artifact@v7
with:
- name: desktop-${{ matrix.label }}
- path: |
- apps/desktop/src-tauri/target/**/release/bundle/**/*.dmg
- apps/desktop/src-tauri/target/**/release/bundle/**/*.AppImage
- apps/desktop/src-tauri/target/**/release/bundle/**/*.deb
- apps/desktop/src-tauri/target/**/release/bundle/**/*.exe
- apps/desktop/src-tauri/target/**/release/bundle/**/*.msi
- apps/desktop/src-tauri/target/**/release/bundle/portable/*.zip
- if-no-files-found: warn
-
- assets:
- name: Rename release assets
- # Tauri's asset names do not say which platform a file belongs to. Runs
- # once every leg has uploaded; the updater manifest references assets by
- # file name and is rewritten to match. SHA256SUMS is then written over
- # the final names.
- needs: build
- if: github.ref_type == 'tag'
- runs-on: ubuntu-24.04
- steps:
- - uses: actions/checkout@v4
- - name: Rename
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- GH_REPO: ${{ github.repository }}
- run: node apps/desktop/scripts/rename-release-assets.mjs "${{ github.ref_name }}"
- - name: Checksums
+ pattern: desktop-release-*
+ path: release-assets
+ merge-multiple: true
+ # One writer creates the draft, latest.json and SHA256SUMS only after all
+ # four targets pass verification. Publishing the draft remains a human step.
+ - name: Upload assets and updater manifest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
- run: node apps/desktop/scripts/release-checksums.mjs "${{ github.ref_name }}"
-
- channel:
- name: Refresh beta manifest
- # A release created from the Releases page is already published when the
- # tag arrives, so Desktop Channel ran before latest.json existed. Now that
- # every leg has uploaded and the assets carry their final names, point the
- # beta channel at it if applicable.
- needs: [build, assets]
- if: github.ref_type == 'tag'
- runs-on: ubuntu-24.04
- steps:
- - uses: actions/checkout@v4
- - name: Refresh
+ RELEASE_TAG: ${{ needs.prepare.outputs.release-tag }}
+ SOURCE_SHA: ${{ needs.prepare.outputs.source-sha }}
+ run: node apps/desktop/scripts/publish-desktop-release.mjs --tag "$RELEASE_TAG" --sha "$SOURCE_SHA" --dir release-assets
+ - name: Refresh beta manifest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
- run: bash apps/desktop/scripts/refresh-beta-manifest.sh "${{ github.ref_name }}"
+ RELEASE_TAG: ${{ needs.prepare.outputs.release-tag }}
+ run: bash apps/desktop/scripts/refresh-beta-manifest.sh "$RELEASE_TAG"
diff --git a/.github/workflows/desktop-windows.yml b/.github/workflows/desktop-windows.yml
new file mode 100644
index 000000000..2aa0f8708
--- /dev/null
+++ b/.github/workflows/desktop-windows.yml
@@ -0,0 +1,188 @@
+name: Desktop · Windows x64
+
+on:
+ workflow_call:
+ inputs:
+ source-sha:
+ required: true
+ type: string
+ channel:
+ required: true
+ type: string
+
+permissions:
+ contents: read
+ id-token: write
+ attestations: write
+
+jobs:
+ build:
+ runs-on: windows-2022
+ timeout-minutes: 45
+ env:
+ CARGO_INCREMENTAL: 0
+ TARGET: x86_64-pc-windows-msvc
+ CHANNEL: ${{ inputs.channel }}
+ defaults:
+ run:
+ shell: bash
+ steps:
+ - uses: actions/checkout@v5
+ with:
+ ref: ${{ github.sha }}
+ - uses: ./.github/actions/desktop-setup
+ with:
+ target: ${{ env.TARGET }}
+ source-sha: ${{ inputs.source-sha }}
+ - name: Build server sidecar
+ run: node apps/desktop/scripts/build-sidecar.mjs --target "$TARGET"
+ - name: Import Windows code-signing certificate
+ shell: pwsh
+ env:
+ WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
+ WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
+ run: |
+ if (-not $env:WINDOWS_CERTIFICATE) {
+ Write-Host "::warning::WINDOWS_CERTIFICATE not set; building an unsigned Windows installer"
+ exit 0
+ }
+ $pfx = Join-Path $env:RUNNER_TEMP "codesign.pfx"
+ [IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE))
+ $secure = ConvertTo-SecureString $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force
+ $cert = Import-PfxCertificate -FilePath $pfx -CertStoreLocation Cert:\CurrentUser\My -Password $secure
+ Remove-Item $pfx
+ # A thumbprint is a public hash of the certificate, not a secret.
+ "WINDOWS_CERTIFICATE_THUMBPRINT=$($cert.Thumbprint)" | Out-File -FilePath $env:GITHUB_ENV -Append
+
+ - name: Configure desktop build
+ run: |
+ args=(--channel "$CHANNEL" --repository "$GITHUB_REPOSITORY" --prebuilt-frontend)
+ if [[ -n "${WINDOWS_CERTIFICATE_THUMBPRINT:-}" ]]; then
+ args+=(--thumbprint "$WINDOWS_CERTIFICATE_THUMBPRINT")
+ fi
+ node apps/desktop/scripts/release-config.mjs "${args[@]}" --output "$RUNNER_TEMP/desktop-config.json"
+ - name: Compile desktop executable
+ run: |
+ pnpm --filter @deeix/desktop exec tauri build --no-bundle --target "$TARGET" \
+ --config "$RUNNER_TEMP/desktop-config.json" -- --locked --timings
+ - name: Bundle NSIS and MSI installers
+ env:
+ TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
+ TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
+ run: |
+ pnpm --filter @deeix/desktop exec tauri bundle --target "$TARGET" --bundles nsis,msi \
+ --config "$RUNNER_TEMP/desktop-config.json"
+ - name: Sign portable executable
+ if: env.WINDOWS_CERTIFICATE_THUMBPRINT != ''
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = 'Stop'
+ $conf = Get-Content apps/desktop/src-tauri/tauri.conf.json -Raw | ConvertFrom-Json
+ $signtool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin\*\x64\signtool.exe" |
+ Where-Object { $_.Directory.Parent.Name -as [version] } |
+ Sort-Object { [version]$_.Directory.Parent.Name } -Descending |
+ Select-Object -First 1
+ if (-not $signtool) { throw "signtool.exe not found" }
+ $exe = "apps/desktop/src-tauri/target/x86_64-pc-windows-msvc/release/deeix-chat-desktop.exe"
+ & $signtool.FullName sign /fd $conf.bundle.windows.digestAlgorithm /sha1 $env:WINDOWS_CERTIFICATE_THUMBPRINT `
+ /t $conf.bundle.windows.timestampUrl /d "DEEIX Chat" $exe
+ if ($LASTEXITCODE -ne 0) { throw "signtool failed with $LASTEXITCODE" }
+ - name: Fetch WebView2 Fixed Version runtime
+ id: webview2
+ if: vars.WEBVIEW2_FIXED_RUNTIME_URL != ''
+ shell: pwsh
+ env:
+ WEBVIEW2_URL: ${{ vars.WEBVIEW2_FIXED_RUNTIME_URL }}
+ WEBVIEW2_SHA256: ${{ vars.WEBVIEW2_FIXED_RUNTIME_SHA256 }}
+ run: |
+ $ErrorActionPreference = 'Stop'
+ $cab = Join-Path $env:RUNNER_TEMP "webview2-fixed.cab"
+ $dir = Join-Path $env:RUNNER_TEMP "webview2-fixed"
+ Invoke-WebRequest -Uri $env:WEBVIEW2_URL -OutFile $cab -UseBasicParsing
+ $hash = (Get-FileHash $cab -Algorithm SHA256).Hash
+ Write-Host "WebView2 runtime .cab SHA-256: $hash"
+ if ($env:WEBVIEW2_SHA256 -and $hash -ne $env:WEBVIEW2_SHA256.ToUpper()) {
+ throw "WebView2 runtime hash mismatch: expected $($env:WEBVIEW2_SHA256), got $hash"
+ }
+ $sig = Get-AuthenticodeSignature $cab
+ if ($sig.Status -ne 'Valid' -or $sig.SignerCertificate.Subject -notmatch 'O=Microsoft Corporation') {
+ throw "WebView2 runtime .cab is not validly signed by Microsoft: $($sig.Status) $($sig.SignerCertificate.Subject)"
+ }
+ New-Item -ItemType Directory -Force -Path $dir | Out-Null
+ expand.exe $cab -F:* $dir | Out-Null
+ if ($LASTEXITCODE -ne 0) { throw "expand failed with $LASTEXITCODE" }
+ "dir=$dir" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
+ - name: Package portable
+ id: portable
+ shell: bash
+ env:
+ WEBVIEW2_DIR: ${{ steps.webview2.outputs.dir }}
+ run: |
+ set -euo pipefail
+ out=apps/desktop/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/portable
+ rm -rf "$out"
+ node apps/desktop/scripts/package-portable.mjs --target "$TARGET" --out-dir "$out"
+ if [[ -n "$WEBVIEW2_DIR" ]]; then
+ node apps/desktop/scripts/package-portable.mjs --target "$TARGET" --out-dir "$out" --webview2 "$WEBVIEW2_DIR"
+ fi
+ echo "dir=$out" >> "$GITHUB_OUTPUT"
+ - name: Verify Windows signatures
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = 'Stop'
+ $root = "apps/desktop/src-tauri"
+ $files = @(
+ "$root/target/x86_64-pc-windows-msvc/release/deeix-chat-desktop.exe"
+ "$root/binaries/deeix-chat-server-x86_64-pc-windows-msvc.exe"
+ )
+ $files += Get-ChildItem "$root/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe", "$root/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi" |
+ ForEach-Object FullName
+ $required = [bool]$env:WINDOWS_CERTIFICATE_THUMBPRINT
+ $failed = $false
+ foreach ($file in $files) {
+ $sig = Get-AuthenticodeSignature -FilePath $file
+ $line = "$($sig.Status): $file ($($sig.SignerCertificate.Subject))"
+ if ($sig.Status -eq 'Valid') {
+ Write-Host $line
+ } elseif ($required) {
+ Write-Host "::error::$line"
+ $failed = $true
+ } else {
+ Write-Host "::warning::$line"
+ }
+ }
+ if ($failed) { exit 1 }
+
+ - name: Stage release assets
+ run: |
+ node apps/desktop/scripts/collect-release-assets.mjs --platform windows-x64 \
+ --bundle "apps/desktop/src-tauri/target/$TARGET/release/bundle" --out "$RUNNER_TEMP/release-assets"
+ - name: Record workflow and source commits
+ env:
+ SOURCE_SHA: ${{ inputs.source-sha }}
+ run: node apps/desktop/scripts/desktop-provenance.mjs > "$RUNNER_TEMP/desktop-provenance.json"
+ - name: Attest Windows build provenance
+ # The workflow and checked-out source share the run's immutable commit.
+ uses: actions/attest-build-provenance@v4
+ with:
+ predicate-type: https://slsa.dev/provenance/v1
+ predicate-path: ${{ runner.temp }}/desktop-provenance.json
+ subject-path: |
+ ${{ runner.temp }}/release-assets/*.exe
+ ${{ runner.temp }}/release-assets/*.msi
+ ${{ runner.temp }}/release-assets/*.zip
+ - uses: actions/upload-artifact@v6
+ with:
+ name: desktop-release-windows-x64
+ path: ${{ runner.temp }}/release-assets
+ compression-level: 0
+ if-no-files-found: error
+ overwrite: true
+ - name: Upload compilation timings
+ if: always()
+ uses: actions/upload-artifact@v6
+ with:
+ name: desktop-timings-windows-x64
+ path: apps/desktop/src-tauri/target/cargo-timings
+ overwrite: true
+ retention-days: 7
diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml
index 96bfa4f9b..6701ff7d9 100644
--- a/.github/workflows/image.yml
+++ b/.github/workflows/image.yml
@@ -44,11 +44,11 @@ jobs:
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
- name: Set up Buildx
id: buildx
- uses: docker/setup-buildx-action@v3
+ uses: docker/setup-buildx-action@v4
# The download and compile caches are the expensive part of a cold build (measured without
# them: pnpm install 157s, go mod download 104s, go build 32s). The Turbopack cache is left out:
@@ -57,7 +57,7 @@ jobs:
# content-addressed, so restoring an older one still saves most of the compile.
- name: Cache BuildKit cache mounts
id: mounts
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: |
buildkit-cache/pnpm-store
@@ -105,7 +105,7 @@ jobs:
- name: Docker metadata
id: meta
- uses: docker/metadata-action@v5
+ uses: docker/metadata-action@v6
with:
images: ${{ steps.image.outputs.images }}
tags: |
@@ -129,7 +129,7 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and publish
- uses: docker/build-push-action@v6
+ uses: docker/build-push-action@v7
with:
context: .
file: ./Dockerfile
@@ -151,7 +151,7 @@ jobs:
steps:
- name: Set up Buildx
- uses: docker/setup-buildx-action@v3
+ uses: docker/setup-buildx-action@v4
- name: Resolve image names
id: image
@@ -176,7 +176,7 @@ jobs:
- name: Docker metadata
id: meta
- uses: docker/metadata-action@v5
+ uses: docker/metadata-action@v6
with:
images: ${{ steps.image.outputs.images }}
tags: |
diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml
index 1757b8e80..a17e2c861 100644
--- a/.github/workflows/release-tag.yml
+++ b/.github/workflows/release-tag.yml
@@ -25,7 +25,7 @@ jobs:
name: Tag and dispatch
runs-on: ubuntu-24.04
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
fetch-depth: 0
@@ -53,7 +53,8 @@ jobs:
if: steps.tag.outputs.created == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_REPO: ${{ github.repository }}
+ RELEASE_TAG: ${{ steps.tag.outputs.tag }}
run: |
- for workflow in desktop-release.yml image.yml; do
- gh workflow run "$workflow" --ref "${{ steps.tag.outputs.tag }}" --repo "${{ github.repository }}"
- done
+ gh workflow run desktop-release.yml --ref "refs/tags/$RELEASE_TAG"
+ gh workflow run image.yml --ref "$RELEASE_TAG"
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 000000000..63f87c20e
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+26.12.1
diff --git a/Dockerfile b/Dockerfile
index 5151480eb..b84e13c34 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -4,13 +4,12 @@
# so `docker build .` and the release workflow (.github/workflows/image.yml) produce the same image.
# Dependency and compiler caches live in BuildKit cache mounts; CI persists them between runs.
-FROM node:24-bookworm-slim AS frontend-builder
+FROM node:26.12.1-bookworm-slim AS frontend-builder
WORKDIR /src
ENV PNPM_HOME=/pnpm
ENV PATH=$PNPM_HOME:$PATH
-ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
ARG NEXT_PUBLIC_API_BASE_URL=""
ENV NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL}
@@ -23,10 +22,11 @@ COPY packages/core/package.json ./packages/core/package.json
COPY apps/web/scripts ./apps/web/scripts
COPY apps/web/public/pwa ./apps/web/public/pwa
-RUN corepack enable
+# Node 26 images no longer bundle Corepack, so pnpm comes from npm and its version
+# still comes from the workspace's `packageManager` field.
+RUN npm install -g "$(node -p "require('./package.json').packageManager")"
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
- --mount=type=cache,id=corepack,target=/root/.cache/node/corepack \
pnpm config set store-dir /pnpm/store \
&& pnpm install --frozen-lockfile --prefer-offline --filter @deeix/web
@@ -40,11 +40,10 @@ WORKDIR /src/apps/web
# Turbopack keeps its build cache in .next/cache (enabled by default since Next.js 16).
RUN --mount=type=cache,id=next-cache,target=/src/apps/web/.next/cache \
- --mount=type=cache,id=corepack,target=/root/.cache/node/corepack \
pnpm build
-FROM golang:1.26.8-bookworm AS backend-builder
+FROM golang:1.26.10-bookworm AS backend-builder
WORKDIR /src/backend
diff --git a/Makefile b/Makefile
index 490b025c4..84148cfad 100644
--- a/Makefile
+++ b/Makefile
@@ -68,11 +68,11 @@ fmt: ## Format Go and JS sources
api-docs: ## Regenerate Swagger docs and the TypeScript API contract
$(PNPM) api:generate
-version: ## Propagate VERSION to every package manifest
- node scripts/sync-version.mjs
+version: ## Synchronize VERSION, manifests, Cargo.lock and generated API contracts
+ $(PNPM) api:generate
-version-check: ## Fail if any manifest drifted from VERSION
- node scripts/sync-version.mjs --check
+version-check: ## Check version consistency and generated API contracts
+ $(PNPM) api:check
clean: ## Remove build outputs
$(PNPM) clean
diff --git a/README.md b/README.md
index 16b1db662..ae57f3f16 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
- English | 简体中文
+ English | 简体中文
@@ -152,9 +152,9 @@ The backend keeps clear internal boundaries: `backend/cmd/server` is the executa
## Prerequisites
-- Node.js 20.9 or newer (the repository Docker image and CI use Node.js 24).
-- pnpm 10.17.0, normally enabled through Corepack.
-- Go 1.26.8 for backend development.
+- Node.js 22.23.4, 24.22.1 or 26.12.1 — the first patched release of each maintained line (`engines` allows all three; Node 20 and older are end-of-life). `.nvmrc`, CI and the Docker image all use 26.12.1.
+- pnpm 10.17.0 (`npm install -g pnpm@10.17.0`; Node 26 no longer bundles Corepack).
+- Go 1.26.10 for backend development.
- Docker Engine with Docker Compose v2 for container deployments or local PostgreSQL/Redis services.
The SQLite profile does not require a separately managed database or cache. The default development configuration uses PostgreSQL and Redis; the full Compose profile starts both services for you.
@@ -225,13 +225,42 @@ repository root and routes to pnpm / turbo / cargo / go underneath.
| `make build-desktop` / `make release-desktop` | Quick local `.app` / signed release bundle. |
| `make verify` | check + test + build, what CI runs. |
| `make api-docs` | Regenerate Swagger artifacts and TypeScript API types. |
-| `make version` | Propagate `VERSION` to every package manifest. |
+| `make version` / `make version-check` | Synchronize/check versions, Cargo.lock and generated Swagger/TypeScript API contracts. |
The same verbs exist as pnpm scripts (`pnpm dev:api`, `pnpm check`, …) for
tooling that prefers them; CI uses those directly.
The frontend is exported as static files, so `pnpm build` writes the browser artifact to `apps/web/out`. The Go service can serve that directory in the production image or through `server.frontend_dist_dir`.
+### Desktop Releases
+
+Merging a `VERSION` bump into `main` creates `v` and starts
+[`desktop-release.yml`](.github/workflows/desktop-release.yml). Tag pushes also
+trigger it. The parent workflow delegates to reusable workflows:
+
+| Workflow | Responsibility |
+| --- | --- |
+| [`desktop-prepare.yml`](.github/workflows/desktop-prepare.yml) | Resolve the source commit, validate versions, build the frontend and refresh catalog snapshots once. |
+| [`desktop-macos.yml`](.github/workflows/desktop-macos.yml) | Separate arm64 and x64 invocations build, sign, notarize and verify the DMG/updater packages. |
+| [`desktop-linux.yml`](.github/workflows/desktop-linux.yml) | Build x64 deb/AppImage packages and verify Wayland library exclusions. |
+| [`desktop-windows.yml`](.github/workflows/desktop-windows.yml) | Build x64 NSIS/MSI/portable packages, verify signatures and attest their provenance. |
+
+The four native jobs run in parallel after preparation and reuse the same
+frontend/catalog artifacts. Releases run in their tag's context and every job
+checks out the run's immutable commit. Tag caches remain isolated from the
+default branch and other tags, while compatible default-branch CI caches can
+still be restored. Rust is pinned, Rust/Go caches are separated by
+target, and the fat LTO release profile is unchanged. macOS packages are created
+from the same final signed app, avoiding DMG recompression.
+
+Only after every target passes does the parent assemble `latest.json` and
+`SHA256SUMS` and upload a **draft** release. Publishing the draft ships the update.
+Manual branch runs with an empty `release_tag` only upload workflow artifacts;
+manual tag runs create a draft release. A branch run with `release_tag` set
+dispatches the build onto that tag. See the
+[desktop release guide](apps/desktop/README.md#release-workflows) for manual tag
+builds, signing secrets, cache behavior and compilation timing reports.
+
### Docker Deployment
All Docker assets live in [`deploy/`](deploy/README.md); copy that directory to your server, the application source is not needed. Run the following commands from inside `deploy/`. Choose one installation profile first, then copy the matching config template to `deploy/config.yaml`. All profiles expose the app at `http://localhost:8080` by default and mount `./config.yaml` (relative to `deploy/`) to `/app/config.yaml` inside the container.
diff --git a/docs/README.zh-CN.md b/README.zh-CN.md
similarity index 85%
rename from docs/README.zh-CN.md
rename to README.zh-CN.md
index 95a4d1205..8cde2b1cc 100644
--- a/docs/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -1,7 +1,7 @@
-
-
+
+
@@ -10,7 +10,7 @@
- English | 简体中文
+ English | 简体中文
@@ -30,7 +30,7 @@ DEEIX Chat 是一款开源可部署的 AI 平台,面向需要长期、稳定
系统围绕简单部署、高效静态分发和低资源的运行时占用设计,轻量而不简陋、克制而不缺能力、开放而不失秩序。
-
+
## 核心能力
@@ -48,14 +48,14 @@ DEEIX Chat 是一款开源可部署的 AI 平台,面向需要长期、稳定
| 部署与运维 | 支持单运行时托管前端与 API、Docker 部署、SQLite 或 PostgreSQL、内存缓存或 Redis、S3 兼容存储、Swagger、结构化日志、版本接口、GeoIP 和 OpenTelemetry。 |
-
-
+
+
-
-
-
+
+
+
## 系统架构与技术栈
@@ -148,13 +148,13 @@ flowchart TB
└── Dockerfile # 前后端生产镜像
```
-`apps/web/` 和 `backend/` 是由 pnpm 与 Turborepo 管理的独立工作区。`packages/api-contract/` 通过生成的 Swagger 类型连接两者;修改 HTTP 契约时先更新 Go 传输层,再重新生成共享包。`packages/core/` 存放必须在各端保持一致的客户端逻辑,禁止引入任何 UI 或平台框架。平台边界与规则见 [docs/ARCHITECTURE.md](ARCHITECTURE.md)。
+`apps/web/` 和 `backend/` 是由 pnpm 与 Turborepo 管理的独立工作区。`packages/api-contract/` 通过生成的 Swagger 类型连接两者;修改 HTTP 契约时先更新 Go 传输层,再重新生成共享包。`packages/core/` 存放必须在各端保持一致的客户端逻辑,禁止引入任何 UI 或平台框架。平台边界与规则见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)。
## 开发前置条件
-- Node.js 20.9 或更高版本(仓库 Docker 镜像和 CI 使用 Node.js 24)。
-- pnpm 10.17.0,通常通过 Corepack 启用。
-- Go 1.26.8,用于后端开发。
+- Node.js 22.23.4、24.22.1 或 26.12.1,即每条维护线第一个修复安全公告的版本(`engines` 允许这三条线;Node 20 及更早已停止维护)。`.nvmrc`、CI 与 Docker 镜像统一使用 26.12.1。
+- pnpm 10.17.0(`npm install -g pnpm@10.17.0`;Node 26 起不再随附 Corepack)。
+- Go 1.26.10,用于后端开发。
- Docker Engine 与 Docker Compose v2,用于容器部署或启动本地 PostgreSQL/Redis。
SQLite 方案不要求单独管理数据库或缓存。默认开发配置使用 PostgreSQL 和 Redis;完整 Compose 方案会自动启动这两个服务。
@@ -223,15 +223,40 @@ NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8080
| `make build-desktop` / `make release-desktop` | 本地快速 `.app` / 签名发版包。 |
| `make verify` | check + test + build,即 CI 跑的内容。 |
| `make api-docs` | 重新生成 Swagger 与 TypeScript API 类型。 |
-| `make version` | 把 `VERSION` 同步到所有包清单。 |
+| `make version` / `make version-check` | 同步或检查版本、Cargo.lock 和生成的 Swagger/TypeScript API 契约。 |
同一组动作也以 pnpm 脚本存在(`pnpm dev:api`、`pnpm check` …),CI 直接用它们。
前端以静态资源形式导出,`pnpm build` 会把浏览器产物写入 `apps/web/out`。生产镜像中的 Go 服务,或配置了 `server.frontend_dist_dir` 的 Go 服务,可以直接托管该目录。
+### 桌面端发布
+
+把 `VERSION` 的版本更新合并到 `main` 后,[`release-tag.yml`](.github/workflows/release-tag.yml) 创建 `v` 标签并启动桌面端发布。直接推送同名格式的标签也会触发构建。[`desktop-release.yml`](.github/workflows/desktop-release.yml) 负责总编排,平台逻辑拆在可复用的子 workflow 中:
+
+| 子 workflow | 职责 |
+| --- | --- |
+| [`desktop-prepare.yml`](.github/workflows/desktop-prepare.yml) | 校验版本、解析标签对应的提交;并行构建静态前端和刷新模型目录快照,各执行一次。 |
+| [`desktop-macos.yml`](.github/workflows/desktop-macos.yml) | arm64、x64 分别调用,各自编译内置 Go 服务和桌面程序,完成签名、公证、DMG 与更新包验证。 |
+| [`desktop-linux.yml`](.github/workflows/desktop-linux.yml) | 构建 Linux x64 的 deb、AppImage,验证 AppImage 不携带会与宿主冲突的 Wayland 库。 |
+| [`desktop-windows.yml`](.github/workflows/desktop-windows.yml) | 构建 Windows x64 的 NSIS、MSI 和便携包,完成签名检查及构建来源证明。 |
+
+公共准备完成后,四个 OS + arch 任务并行运行,Node.js 侧只安装桌面 CLI 所需的依赖,并下载同一份前端和模型目录产物。四个平台验证通过后,主 workflow 统一生成 `latest.json`、`SHA256SUMS`,上传安装包和更新签名到一个 **GitHub Release 草稿**。手动发布草稿后,已安装的客户端才会收到更新。
+
+发布任务在目标标签的运行上下文中执行,所有任务始终 checkout 本次运行的固定提交,并在上传前再次确认标签没有移动。标签写入的缓存与默认分支及其他标签隔离,仍可恢复默认分支 CI 生成的兼容缓存;同一标签重跑可以复用自己的缓存,不同标签首次运行可能需要重新编译。公共 [desktop-setup action](.github/actions/desktop-setup/action.yml) 固定 Rust 版本,并按目标平台和架构隔离 Rust、Go 缓存,避免两个 macOS 任务争用缓存。Rust 的 fat LTO 等发布优化参数保持不变。
+
+macOS 先完成 `.app` 的签名和公证,再从同一份最终应用生成 ULFO DMG 和更新压缩包,避免先生成 DMG 再解压、替换应用和重新压缩。编译、打包、签名、公证与验证分别显示为独立步骤;`desktop-timings-*` 附件保留七天,可用于查看 Rust 编译耗时。外部签名服务和 Apple 公证的等待时间仍计入相应步骤。
+
+手动构建已有标签(目标标签需包含这些 workflow 与构建脚本):
+
+```bash
+gh workflow run desktop-release.yml --ref refs/tags/v1.2.3
+```
+
+在 Actions 页面选择分支且留空 `release_tag` 时,只构建所选分支并上传 workflow 附件;选择标签则创建发布草稿。在分支上填写 `release_tag` 会把构建调度到该标签,标签运行中填写的值必须与当前标签一致。旧标签沿用其原始 workflow 的重跑方式。公共产物保留七天,期间可以重跑失败的平台任务。签名密钥、更新通道和平台打包细节见 [桌面端说明](apps/desktop/README.md#release-workflows)。
+
### Docker 部署
-所有 Docker 部署资源都在 [`deploy/`](../deploy/README.md) 目录;把这个目录整体拷到服务器即可,不需要应用源码。以下命令均在 `deploy/` 目录内执行。先选择安装方案,再把对应的配置模板复制为 `deploy/config.yaml`。三套 compose 方案都默认将应用暴露在 `http://localhost:8080`,并把 `./config.yaml`(相对 `deploy/`)挂载到容器内 `/app/config.yaml`。
+所有 Docker 部署资源都在 [`deploy/`](deploy/README.md) 目录;把这个目录整体拷到服务器即可,不需要应用源码。以下命令均在 `deploy/` 目录内执行。先选择安装方案,再把对应的配置模板复制为 `deploy/config.yaml`。三套 compose 方案都默认将应用暴露在 `http://localhost:8080`,并把 `./config.yaml`(相对 `deploy/`)挂载到容器内 `/app/config.yaml`。
| 方案 | 适合场景 | 配置文件 | Compose 文件 | 内置依赖 |
| --- | --- | --- | --- | --- |
@@ -392,7 +417,7 @@ docker compose logs app
后端启动时会按运行目录解析默认配置文件:从仓库根目录启动读取 `config.yaml`,从 `backend/` 目录启动读取 `../config.yaml`。Docker 部署将 `deploy/config.yaml` 只读挂载到容器内 `/app/config.yaml`;如果配置文件放在其他位置,请使用 `CONFIG_FILE` 指向实际运行环境可访问的路径。最终生效优先级是 `环境变量 > config.yaml > 代码内置默认值`。
-前端品牌同样属于运行时配置。在 `config.yaml` 中设置 `branding` 后重启应用即可生效,无需重新构建前端或 Docker 镜像。详见[自定义品牌资源](./BRANDING.md)。
+前端品牌同样属于运行时配置。在 `config.yaml` 中设置 `branding` 后重启应用即可生效,无需重新构建前端或 Docker 镜像。详见[自定义品牌资源](./docs/BRANDING.md)。
静态配置环境变量:
@@ -469,7 +494,7 @@ docker compose logs app
生产环境启用 SSRF 防护后,管理员保存的模型、MCP、Embedding、OIDC/OAuth2 和自定义 Turnstile endpoint 均按精确 origin(协议、主机和端口)获得局部授权,不需要加入全局白名单。模型、MCP 与 Embedding 保留标准重定向兼容性:跨 origin 的公网目标可以继续访问,跨 origin 的私网目标必须命中 `SSRF_ALLOWED_HOSTS` 或 `SSRF_ALLOWED_CIDRS`;OIDC/OAuth2 与 Turnstile 继续维持更严格的身份边界。模型生成的图片或视频由后端下载、校验并转存:私网制品 URL 只有与本次选中的模型 endpoint 同 origin 时才继承该局部信任;跨 origin 的公网制品仍按严格公网策略下载,跨 origin 的私网制品会被拦截。全局白名单也继续用于无法绑定管理员保存 endpoint 的部署级集成,例如部分 GeoIP 或提取服务部署。链路本地、组播、未指定地址和已知云元数据目标始终禁止。白名单配置不合法会阻止后端启动;全局白名单修改后需重启生效。
-出站请求遵守标准代理环境变量 `HTTP_PROXY`、`HTTPS_PROXY` 和 `NO_PROXY`,开启 SSRF 防护时同样生效。代理地址本身视为部署方授权;选择代理前仍会按 SSRF 策略校验目标 URL,元数据地址等禁止目标照样拒绝;不走代理的请求(命中 `NO_PROXY` 或回环地址)仍走完整的 DNS 校验拨号流程。请求经代理发出时,目标域名由代理解析;交给代理前,后端会拒绝 `127.1`、`2130706433` 这类数字主机名,并在本地预解析一次域名,任一解析结果被策略禁止即拒绝,本地解析失败则交由代理处理。这无法防御代理侧的 DNS rebinding,DNS 层面的出口控制仍由部署方的代理负责。`config.yaml` 中没有代理配置项,代理地址与凭据只放在部署环境变量里。Go 不读取 `ALL_PROXY`,使用 SOCKS 代理时请把 `HTTP_PROXY` 和 `HTTPS_PROXY` 设为 `socks5://` 地址;只设置了 `ALL_PROXY` 时,后端启动会记录 `outbound_proxy_ignored` 告警并直连。修改代理环境变量需要重启后端。桌面端本地模式下,这些变量由桌面应用根据「设置 › 网络」中的选择(默认跟随系统代理)为内置服务设置,详见 [apps/desktop/README.md](../apps/desktop/README.md#network-proxy)。
+出站请求遵守标准代理环境变量 `HTTP_PROXY`、`HTTPS_PROXY` 和 `NO_PROXY`,开启 SSRF 防护时同样生效。代理地址本身视为部署方授权;选择代理前仍会按 SSRF 策略校验目标 URL,元数据地址等禁止目标照样拒绝;不走代理的请求(命中 `NO_PROXY` 或回环地址)仍走完整的 DNS 校验拨号流程。请求经代理发出时,目标域名由代理解析;交给代理前,后端会拒绝 `127.1`、`2130706433` 这类数字主机名,并在本地预解析一次域名,任一解析结果被策略禁止即拒绝,本地解析失败则交由代理处理。这无法防御代理侧的 DNS rebinding,DNS 层面的出口控制仍由部署方的代理负责。`config.yaml` 中没有代理配置项,代理地址与凭据只放在部署环境变量里。Go 不读取 `ALL_PROXY`,使用 SOCKS 代理时请把 `HTTP_PROXY` 和 `HTTPS_PROXY` 设为 `socks5://` 地址;只设置了 `ALL_PROXY` 时,后端启动会记录 `outbound_proxy_ignored` 告警并直连。修改代理环境变量需要重启后端。桌面端本地模式下,这些变量由桌面应用根据「设置 › 网络」中的选择(默认跟随系统代理)为内置服务设置,详见 [apps/desktop/README.md](apps/desktop/README.md#network-proxy)。
### Web、App 与桌面端 OAuth 回调
@@ -504,12 +529,13 @@ Web、App 与桌面端的登录、注册和账号身份绑定都走这一个回
- [用户指南](https://deeix.com/zh/docs/deeix-chat/new-chat)
- [管理指南](https://deeix.com/zh/docs/deeix-chat/admin-accounts)
- [进阶指南](https://deeix.com/zh/docs/deeix-chat/advanced-capabilities-passthrough-tools)
-- 后端说明:[backend/README.md](../backend/README.md)
-- API 文档索引:[backend/docs/README.md](../backend/docs/README.md)
-- 前端说明:[apps/web/README.md](../apps/web/README.md)
-- API 契约包:[packages/api-contract/README.md](../packages/api-contract/README.md)
-- 贡献指南:[CONTRIBUTING.md](../.github/CONTRIBUTING.md)
-- 安全策略:[SECURITY.md](../.github/SECURITY.md)
+- 后端说明:[backend/README.md](backend/README.md)
+- API 文档索引:[backend/docs/README.md](backend/docs/README.md)
+- 前端说明:[apps/web/README.md](apps/web/README.md)
+- 桌面端说明:[apps/desktop/README.md](apps/desktop/README.md)
+- API 契约包:[packages/api-contract/README.md](packages/api-contract/README.md)
+- 贡献指南:[CONTRIBUTING.md](.github/CONTRIBUTING.md)
+- 安全策略:[SECURITY.md](.github/SECURITY.md)
- Swagger UI(仅开发环境):`http://localhost:8080/swagger/index.html`
## 鸣谢
@@ -530,4 +556,4 @@ DEEIX Chat 基于开源生态构建,感谢所有 AI 工具生态中的维护
## 开源协议
-DEEIX Chat 使用 [Apache License 2.0](../LICENSE) 授权。
+DEEIX Chat 使用 [Apache License 2.0](LICENSE) 授权。
diff --git a/VERSION b/VERSION
index 0bfccb080..ef52a6480 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.4.5
+0.4.6
diff --git a/apps/desktop/README.md b/apps/desktop/README.md
index 2aa5bb695..1e9a3938b 100644
--- a/apps/desktop/README.md
+++ b/apps/desktop/README.md
@@ -316,25 +316,86 @@ The JS updater permission is not in the static capability: it is granted at
runtime only in `auto` mode, so a portable or MSI build cannot install updates
even if the web layer asked.
-Release assets are renamed by `scripts/rename-release-assets.mjs` to
+Release assets are staged by `scripts/collect-release-assets.mjs` as
`DEEIX-Chat---[-setup|-updater].`, e.g.
`DEEIX-Chat-0.4.4-beta.1-macos-arm64.dmg`, `-linux-x64.AppImage`,
-`-windows-x64-setup.exe`, `-macos-arm64-updater.tar.gz`. The plan is
-validated for collisions before anything is renamed, and the updater manifest,
-which references assets by file name, is reconciled from its own URLs, so the
-job can be re-run after an interruption. Only the artifact bytes are signed, so
-verification is unaffected. `pnpm test` covers the mapping.
+`-windows-x64-setup.exe`, `-macos-arm64-updater.tar.gz`. Every target must have
+exactly one of each required package and its updater signature before staging.
+After all targets pass, `scripts/publish-desktop-release.mjs` assembles
+`latest.json` and `SHA256SUMS` from those final files and uploads them to one
+draft. It preserves the updater platform keys used by existing installs,
+resumes a single existing draft and refuses to replace a published release.
+Only the artifact bytes are signed, so staging under a new name does not change
+their signatures. `pnpm test` covers staging, updater compatibility and hashes.
Release flow: merging a `VERSION` bump into `main` creates the tag
`v` (`release-tag.yml`), which builds every target and opens a
-**draft** GitHub Release with the installers and a signed `latest.json`.
+**draft** GitHub Release with the installers and `latest.json` containing their signatures.
Publishing the draft is the step that ships the update; until then existing
installs see nothing. Pushing the tag by hand does the same.
+Before tagging, update `VERSION`, run `make version` and commit the synchronized
+version files, including the desktop root package entry in `Cargo.lock` and the
+generated Swagger/TypeScript API contracts. This uses the same generation
+pipeline as `pnpm api:generate`; Go and workspace JS dependencies are required.
+`make version-check` runs the full API contract drift check as well as version
+checks, so stale manifests, lock metadata or generated API versions fail before
+release. Dependency versions stay pinned.
+
Windows assets: `-windows-x64-setup.exe`, `-windows-x64.msi`,
`-windows-x64-portable.zip` (and `-portable-offline.zip` when built). Every
release also carries `SHA256SUMS` and GitHub build provenance attestations.
+### Release workflows
+
+[`desktop-release.yml`](../../.github/workflows/desktop-release.yml) coordinates
+the following reusable workflows. Each native job receives the same immutable
+source commit and channel; platform-specific installation, signing and
+verification live in that platform's file.
+
+| Workflow | Responsibility |
+| --- | --- |
+| [`desktop-prepare.yml`](../../.github/workflows/desktop-prepare.yml) | Verify the run's source commit and tag, check version consistency, then build the frontend and refresh catalog snapshots once, in parallel. |
+| [`desktop-macos.yml`](../../.github/workflows/desktop-macos.yml) | Build arm64 natively and cross-compile x64 on ARM `macos-26` runners; sign the app, create the final DMG and updater archive, then verify both. |
+| [`desktop-linux.yml`](../../.github/workflows/desktop-linux.yml) | Build x64 sidecar/executable, bundle deb/AppImage and verify the Wayland library exclusion. |
+| [`desktop-windows.yml`](../../.github/workflows/desktop-windows.yml) | Build x64 sidecar/executable, sign and verify NSIS/MSI/portable packages, and attest their provenance. |
+
+The four native jobs run in parallel after preparation. Shared toolchain/cache
+setup lives in [the desktop-setup action](../../.github/actions/desktop-setup/action.yml).
+The frontend and catalog are downloaded as workflow artifacts; native jobs
+disable Tauri's frontend build hook, so they never rebuild the shared frontend.
+Compilation, bundling, notarization and verification have separate steps. Rust
+`--timings` reports are retained as `desktop-timings-*` artifacts for seven days.
+The shipped Rust optimization profile, including fat LTO, is unchanged.
+
+Release-tag runs dispatch `desktop-release.yml` on the **release tag**. Every
+job checks out the run's immutable `github.sha`; source inputs are verified
+against that commit and cannot select code from a different ref. Tag code writes
+only to its own cache scope, keeping the default branch and other tags isolated.
+Compatible caches from default-branch CI can still be restored, and reruns of
+the same tag reuse its caches. Successive tags cannot restore caches written by
+each other, so their first native builds may need recompilation. Publication
+verifies the tag has not moved, and Windows provenance records the matching
+workflow and desktop source commits. Rust is pinned in the setup action, and
+Rust/Go cache keys include the target triple so the two macOS targets sharing
+ARM runners cannot overwrite one another's cache. Dependency changes or cache
+eviction can also cause rebuilds.
+
+To build an existing tag containing these workflows and scripts:
+
+```bash
+gh workflow run desktop-release.yml --ref refs/tags/v1.2.3
+```
+
+The tag must contain the reusable workflows, build scripts and setup action;
+rerun the original workflow for historical tags. A manual branch run with an
+empty `release_tag` only uploads workflow artifacts; a manual tag run creates a
+draft. Setting `release_tag` on a branch dispatches the workflow onto that tag
+without building it in the branch's context. On a tag run, a nonempty input must
+match the current tag. A release run creates the draft only after all four
+targets succeed; publishing the draft remains a separate human step. Failed
+jobs can be retried while the shared artifacts are retained (seven days).
+
### Channels
| Tag | Channel | What the app polls |
@@ -435,8 +496,13 @@ What a local `pnpm build` costs once caches are warm, and where it goes:
| `.app` | ~1 s | | |
| `.dmg` | ~25 s | | `hdiutil` + Finder layout; `scripts/package-dmg.sh` then re-encodes with lzfse and (when signing is configured) re-signs and notarizes |
-`pnpm build:signed` produces exactly what CI ships. Use `pnpm build:app` while
-iterating: it relaxes LTO (~17 s link) and stops at the `.app`.
+`pnpm build:signed` uses the local Tauri DMG/repackaging path above. CI uses
+`scripts/package-macos.sh`: it creates the `.app` first, completes its signing
+and notarization, then creates an ULFO DMG directly from that app. It avoids
+building a compressed DMG only to decompress, replace the app and recompress it.
+The updater tarball is created from the same final app and then signed with the
+updater key. Both packages go through `verify-macos-bundle.sh` before staging.
+Use `pnpm build:app` while iterating: it relaxes LTO (~17 s link) and stops at the `.app`.
Every `no*` tag has an `*_off.go` counterpart that returns a clear error if the
config selects a driver that was compiled out.
@@ -457,6 +523,7 @@ app icon.
| Platform | What is needed | CI secrets |
| --- | --- | --- |
| macOS | Developer ID Application certificate (.p12) + Apple ID app-specific password for notarization; identity and team id are derived from the certificate (`scripts/apple-signing-env.sh`) | `APPLE_CERTIFICATE`, `APPLE_CERTIFICATE_PASSWORD`, `APPLE_ID`, `APPLE_PASSWORD` |
+| macOS, external service | Alternative app/DMG signing and notarization provider | `SIGN_AND_NOTARIZE_SCRIPT_URL`, `SIGN_AND_NOTARIZE_BASE_URL`, `SIGN_AND_NOTARIZE_API_TOKEN`, `SIGN_AND_NOTARIZE_HELPER_URL` |
| Windows | Code-signing certificate (.pfx) | `WINDOWS_CERTIFICATE`, `WINDOWS_CERTIFICATE_PASSWORD` |
| All | Updater keypair (`tauri signer generate`); the private key is kept git-ignored in `deploy/secrets/` (see its README) | `TAURI_SIGNING_PRIVATE_KEY`, `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` |
@@ -468,6 +535,15 @@ signs the exe inside each installer and the sidecar; the portable exe is signed
separately with signtool. A CI step then requires a valid Authenticode signature
on the setup, MSI, portable exe and sidecar whenever a certificate is configured.
+CI chooses the external macOS provider when all four `SIGN_AND_NOTARIZE_*`
+secrets are set, even if Apple credentials also exist. Tauri only ad-hoc signs
+in that mode; the external service signs/notarizes the app once and then the
+final DMG. The updater archive is created afterwards, from the signed app.
+A partial external configuration fails immediately. Without the external
+provider, the existing `APPLE_*` credentials enable Tauri app signing and
+notarization; DMG signing imports its own temporary keychain because Tauri
+removes its keychain when app bundling finishes.
+
On macOS, `scripts/verify-macos-bundle.sh` checks the app as it ships, inside the
final DMG and inside the updater archive, before CI uploads the DMG: the app
binary and the sidecar must be built for the leg's architecture and must not
@@ -475,6 +551,9 @@ require a newer macOS than the app declares, and the bundle must pass
`codesign --verify --deep --strict`. With a Developer ID it also requires the
certificate's team id on the app and the DMG, a stapled notarization ticket on
both, and a Gatekeeper assessment of `Notarized Developer ID`.
+The DMG signer is checked against Apple's Developer ID Application certificate
+chain and its certificate's Team ID (`subject.OU`), because a signed disk image
+can report `TeamIdentifier=not set` in `codesign -dv` output.
`pnpm build:signed` runs the same check.
Only the updater key is mandatory. Platform signing switches on by itself once
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index 739aec93e..16742e4a3 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -1,6 +1,6 @@
{
"name": "@deeix/desktop",
- "version": "0.4.5",
+ "version": "0.4.6",
"private": true,
"type": "module",
"description": "DEEIX Chat desktop shell (Tauri). Loads the apps/web static build and adds tray, deep links and OS keychain storage.",
diff --git a/apps/desktop/scripts/build-sidecar.mjs b/apps/desktop/scripts/build-sidecar.mjs
index 1fc0c9bc9..c91a16313 100644
--- a/apps/desktop/scripts/build-sidecar.mjs
+++ b/apps/desktop/scripts/build-sidecar.mjs
@@ -2,10 +2,10 @@
//
// Tauri's `bundle.externalBin` expects `-` next to
// the config (e.g. binaries/deeix-chat-server-aarch64-apple-darwin) and strips
-// the suffix when bundling. The server links SQLite through cgo, so it is
-// always built natively on the platform that bundles it; CI runs this once per
-// matrix leg. Pass --target when cross-building on macOS (x86_64 on an
-// arm64 runner works because Apple ships both toolchains).
+// the suffix when bundling. The server links SQLite through cgo, so builds
+// need a C toolchain for the target. CI builds both macOS targets on arm64
+// runners. Pass --target for cross-builds on macOS (x86_64 on an
+// arm64 host works because Apple ships both toolchains).
//
// Usage: node scripts/build-sidecar.mjs [--target ]
diff --git a/apps/desktop/scripts/collect-release-assets.mjs b/apps/desktop/scripts/collect-release-assets.mjs
new file mode 100644
index 000000000..0e2b62a76
--- /dev/null
+++ b/apps/desktop/scripts/collect-release-assets.mjs
@@ -0,0 +1,89 @@
+// Stage one target's verified files under their final release names. Publication
+// consumes these artifacts, so no platform uploads or edits latest.json itself.
+import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
+import { dirname, join, resolve } from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+
+import { msiVersion } from "./release-config.mjs";
+
+const specifications = {
+ "macos-arm64": [
+ ["dmg", ".dmg", ".dmg"],
+ ["macos", ".app.tar.gz", "-updater.tar.gz", ["darwin-aarch64", "darwin-aarch64-app"]],
+ ],
+ "macos-x64": [
+ ["dmg", ".dmg", ".dmg"],
+ ["macos", ".app.tar.gz", "-updater.tar.gz", ["darwin-x86_64", "darwin-x86_64-app"]],
+ ],
+ "linux-x64": [
+ ["deb", ".deb", ".deb", ["linux-x86_64-deb"]],
+ ["appimage", ".AppImage", ".AppImage", ["linux-x86_64", "linux-x86_64-appimage"]],
+ ],
+ "windows-x64": [
+ ["nsis", "-setup.exe", "-setup.exe", ["windows-x86_64", "windows-x86_64-nsis"]],
+ ["msi", ".msi", ".msi", ["windows-x86_64-msi"]],
+ ["portable", "-portable.zip", "-portable.zip"],
+ ["portable", "-portable-offline.zip", "-portable-offline.zip", [], true],
+ ],
+};
+
+export const releasePlatforms = Object.keys(specifications);
+
+export function releaseAssets(platform, version) {
+ msiVersion(version);
+ const specs = specifications[platform];
+ if (!specs) throw new Error(`unsupported release platform: ${platform}`);
+ return specs.map(([directory, extension, suffix, updater = [], optional = false]) => ({
+ directory, extension, updater, optional,
+ name: `DEEIX-Chat-${version}-${platform}${suffix}`,
+ }));
+}
+
+export function requireFile(path) {
+ if (!existsSync(path) || !statSync(path).isFile() || statSync(path).size === 0) {
+ throw new Error(`missing or empty release asset: ${path}`);
+ }
+}
+
+export function collectReleaseAssets({ platform, version, bundle, out }) {
+ const plan = [];
+ for (const spec of releaseAssets(platform, version)) {
+ const directory = join(bundle, spec.directory);
+ const matches = existsSync(directory)
+ ? readdirSync(directory).filter((name) => name.endsWith(spec.extension)) : [];
+ if (spec.optional && matches.length === 0) continue;
+ if (matches.length !== 1) throw new Error(`expected one ${spec.extension} in ${directory}, found ${matches.length}`);
+ const source = matches[0];
+ // The updater archive is the only Tauri filename without a version.
+ if (spec.extension !== ".app.tar.gz" && source !== spec.name && !source.includes(`_${version}_`)) {
+ throw new Error(`asset ${source} does not match version ${version}`);
+ }
+ plan.push({ from: join(directory, source), to: join(out, spec.name) });
+ if (spec.updater.length) plan.push({ from: join(directory, `${source}.sig`), to: join(out, `${spec.name}.sig`) });
+ }
+ // Validate the whole target before copying any of it.
+ for (const { from, to } of plan) {
+ requireFile(from);
+ if (existsSync(to)) throw new Error(`release asset already staged: ${to}`);
+ }
+ mkdirSync(out, { recursive: true });
+ for (const { from, to } of plan) copyFileSync(from, to);
+ return plan.map(({ to }) => to);
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ try {
+ const args = process.argv.slice(2);
+ const value = (flag) => args[args.indexOf(flag) + 1];
+ for (const flag of ["--platform", "--bundle", "--out"]) {
+ if (!args.includes(flag) || !value(flag)) throw new Error(`missing ${flag}`);
+ }
+ const here = dirname(fileURLToPath(import.meta.url));
+ const version = readFileSync(resolve(here, "../../../VERSION"), "utf8").trim();
+ const files = collectReleaseAssets({ platform: value("--platform"), version, bundle: value("--bundle"), out: value("--out") });
+ console.log(files.join("\n"));
+ } catch (error) {
+ console.error(error.message);
+ process.exitCode = 1;
+ }
+}
diff --git a/apps/desktop/scripts/configure-apple-signing.sh b/apps/desktop/scripts/configure-apple-signing.sh
new file mode 100644
index 000000000..29b0eef00
--- /dev/null
+++ b/apps/desktop/scripts/configure-apple-signing.sh
@@ -0,0 +1,48 @@
+#!/usr/bin/env bash
+# Select one signing provider and export only the credentials Tauri should use.
+set -euo pipefail
+
+: "${GITHUB_ENV:?GITHUB_ENV is required}"
+script_dir="$(cd "$(dirname "$0")" && pwd)"
+
+remote_count=0
+for name in SIGN_AND_NOTARIZE_SCRIPT_URL SIGN_AND_NOTARIZE_BASE_URL SIGN_AND_NOTARIZE_API_TOKEN SIGN_AND_NOTARIZE_HELPER_URL; do
+ [[ -z "${!name:-}" ]] || remote_count=$((remote_count + 1))
+done
+if ((remote_count > 0 && remote_count < 4)); then
+ echo "::error::External signing is partly configured; set all four SIGN_AND_NOTARIZE_* secrets."
+ exit 1
+fi
+if ((remote_count == 4)); then
+ # The remote service signs the final app. Tauri only supplies an ad-hoc app,
+ # even when local Apple credentials also exist, so it does not notarize twice.
+ printf '%s\n' 'DESKTOP_SIGNING_MODE=external' 'APPLE_SIGNING_IDENTITY=-' >> "$GITHUB_ENV"
+ exit 0
+fi
+
+present=0
+missing=()
+for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_ID APPLE_PASSWORD; do
+ if [[ -n "${!name:-}" ]]; then
+ present=$((present + 1))
+ elif [[ "$name" != APPLE_CERTIFICATE_PASSWORD ]]; then
+ missing+=("$name")
+ fi
+done
+if ((present == 0)); then
+ printf '%s\n' 'DESKTOP_SIGNING_MODE=adhoc' 'APPLE_SIGNING_IDENTITY=-' >> "$GITHUB_ENV"
+ echo "::warning::No Apple signing provider configured; building an ad-hoc signed app."
+ exit 0
+fi
+if ((${#missing[@]} > 0)); then
+ echo "::error::Apple signing is partly configured; missing ${missing[*]}."
+ exit 1
+fi
+
+echo 'DESKTOP_SIGNING_MODE=apple' >> "$GITHUB_ENV"
+for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_ID APPLE_PASSWORD; do
+ delimiter=__DESKTOP_SIGNING__
+ while [[ "${!name:-}" == *"$delimiter"* ]]; do delimiter+=_; done
+ printf '%s<<%s\n%s\n%s\n' "$name" "$delimiter" "${!name:-}" "$delimiter" >> "$GITHUB_ENV"
+done
+bash "$script_dir/apple-signing-env.sh" >> "$GITHUB_ENV"
diff --git a/apps/desktop/scripts/desktop-provenance.mjs b/apps/desktop/scripts/desktop-provenance.mjs
new file mode 100644
index 000000000..ea2ac7c83
--- /dev/null
+++ b/apps/desktop/scripts/desktop-provenance.mjs
@@ -0,0 +1,43 @@
+// Record the workflow and source at the same immutable branch or tag commit.
+import { pathToFileURL } from "node:url";
+import { currentDesktopSource, desktopSource } from "./desktop-source.mjs";
+
+export function desktopProvenance({ env, sourceSha, releaseTag = "" }) {
+ const { releaseTag: tag } = desktopSource({ env, sourceSha, releaseTag });
+ const repository = `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}`;
+ const prefix = `${env.GITHUB_REPOSITORY}/`;
+ const suffix = `@${env.GITHUB_REF}`;
+ const workflowRef = env.GITHUB_WORKFLOW_REF;
+ if (!workflowRef?.startsWith(prefix) || !workflowRef.endsWith(suffix)) throw new Error("invalid workflow reference");
+ return {
+ buildDefinition: {
+ buildType: "https://actions.github.io/buildtypes/workflow/v1",
+ externalParameters: {
+ workflow: { repository, ref: env.GITHUB_REF, path: workflowRef.slice(prefix.length, -suffix.length) },
+ },
+ internalParameters: {
+ github: {
+ event_name: env.GITHUB_EVENT_NAME,
+ repository_id: env.GITHUB_REPOSITORY_ID,
+ repository_owner_id: env.GITHUB_REPOSITORY_OWNER_ID,
+ runner_environment: env.RUNNER_ENVIRONMENT,
+ },
+ },
+ resolvedDependencies: [
+ { name: "workflow", uri: `git+${repository}@${env.GITHUB_REF}`, digest: { gitCommit: env.GITHUB_SHA } },
+ { name: "desktop-source", uri: `git+${repository}@${tag ? `refs/tags/${tag}` : sourceSha}`, digest: { gitCommit: sourceSha } },
+ ],
+ },
+ runDetails: {
+ // This reusable workflow is called locally from desktop-release.yml, so
+ // its definition is at the same ref as the calling workflow.
+ builder: { id: `${repository}/.github/workflows/desktop-windows.yml@${env.GITHUB_REF}` },
+ metadata: { invocationId: `${repository}/actions/runs/${env.GITHUB_RUN_ID}/attempts/${env.GITHUB_RUN_ATTEMPT}` },
+ },
+ };
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ const { sha: sourceSha, releaseTag } = currentDesktopSource();
+ console.log(JSON.stringify(desktopProvenance({ env: process.env, sourceSha, releaseTag })));
+}
diff --git a/apps/desktop/scripts/desktop-provenance.test.mjs b/apps/desktop/scripts/desktop-provenance.test.mjs
new file mode 100644
index 000000000..20ab39e2b
--- /dev/null
+++ b/apps/desktop/scripts/desktop-provenance.test.mjs
@@ -0,0 +1,42 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { desktopProvenance } from "./desktop-provenance.mjs";
+
+const sourceSha = "a".repeat(40);
+function environment(ref) {
+ return {
+ GITHUB_SERVER_URL: "https://github.com", GITHUB_REPOSITORY: "o/r",
+ GITHUB_SHA: sourceSha, GITHUB_REF: ref,
+ GITHUB_WORKFLOW_REF: `o/r/.github/workflows/desktop-release.yml@${ref}`,
+ GITHUB_EVENT_NAME: "workflow_dispatch", GITHUB_REPOSITORY_ID: "123", GITHUB_REPOSITORY_OWNER_ID: "456",
+ RUNNER_ENVIRONMENT: "github-hosted", GITHUB_RUN_ID: "789", GITHUB_RUN_ATTEMPT: "2",
+ };
+}
+
+test("tag provenance binds the workflow and release source to the same commit", () => {
+ const env = environment("refs/tags/v1.2.3");
+ // Direct tag dispatches have no release_tag input; the ref supplies identity.
+ const predicate = desktopProvenance({ env, sourceSha });
+ assert.deepEqual(predicate.buildDefinition.resolvedDependencies, [
+ { name: "workflow", uri: "git+https://github.com/o/r@refs/tags/v1.2.3", digest: { gitCommit: sourceSha } },
+ { name: "desktop-source", uri: "git+https://github.com/o/r@refs/tags/v1.2.3", digest: { gitCommit: sourceSha } },
+ ]);
+ assert.equal(predicate.buildDefinition.externalParameters.workflow.path, ".github/workflows/desktop-release.yml");
+ assert.equal(predicate.runDetails.builder.id, "https://github.com/o/r/.github/workflows/desktop-windows.yml@refs/tags/v1.2.3");
+ assert.equal(predicate.runDetails.metadata.invocationId, "https://github.com/o/r/actions/runs/789/attempts/2");
+});
+
+test("branch artifact provenance preserves branch names containing at signs", () => {
+ const env = environment("refs/heads/release@next");
+ const predicate = desktopProvenance({ env, sourceSha });
+ assert.equal(predicate.buildDefinition.externalParameters.workflow.path, ".github/workflows/desktop-release.yml");
+ assert.equal(predicate.buildDefinition.resolvedDependencies[1].uri, `git+https://github.com/o/r@${sourceSha}`);
+});
+
+test("provenance rejects cross-ref source commits and misleading release tags", () => {
+ const env = environment("refs/tags/v1.2.3");
+ assert.throws(() => desktopProvenance({ env, sourceSha: "b".repeat(40) }), /workflow commit/);
+ assert.throws(() => desktopProvenance({ env, sourceSha, releaseTag: "v9.9.9" }), /workflow ref/);
+ assert.throws(() => desktopProvenance({ env: environment("refs/heads/dev"), sourceSha, releaseTag: "v1.2.3" }), /workflow ref/);
+ assert.throws(() => desktopProvenance({ env, sourceSha: "dev" }), /immutable/);
+});
diff --git a/apps/desktop/scripts/desktop-source.mjs b/apps/desktop/scripts/desktop-source.mjs
new file mode 100644
index 000000000..babe90fb5
--- /dev/null
+++ b/apps/desktop/scripts/desktop-source.mjs
@@ -0,0 +1,24 @@
+import { execFileSync } from "node:child_process";
+import { pathToFileURL } from "node:url";
+
+// Source inputs describe the run's commit; they must never select another ref
+// whose code would execute with this run's cache and signing capabilities.
+export function desktopSource({ env, sourceSha, releaseTag = env.RELEASE_TAG ?? "" }) {
+ if ([env.GITHUB_SHA, sourceSha].some((sha) => !/^[0-9a-f]{40}$/.test(sha ?? ""))) throw new Error("workflow requires an immutable source commit");
+ if (sourceSha !== env.GITHUB_SHA) throw new Error("checkout does not match the workflow commit");
+ if (env.SOURCE_SHA !== undefined && env.SOURCE_SHA !== sourceSha) throw new Error("requested source does not match the workflow commit");
+ const ref = env.GITHUB_REF ?? "";
+ if (!ref.startsWith("refs/heads/") && !ref.startsWith("refs/tags/")) throw new Error("expected a branch or tag workflow ref");
+ const tag = ref.startsWith("refs/tags/") ? ref.slice("refs/tags/".length) : "";
+ if (releaseTag && releaseTag !== tag) throw new Error("release tag does not match the workflow ref; dispatch the workflow on that tag");
+ return { sha: sourceSha, releaseTag: tag };
+}
+
+export function currentDesktopSource(env = process.env) {
+ const sourceSha = execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim();
+ return desktopSource({ env, sourceSha });
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ currentDesktopSource();
+}
diff --git a/apps/desktop/scripts/desktop-workflow.test.mjs b/apps/desktop/scripts/desktop-workflow.test.mjs
new file mode 100644
index 000000000..2183115ed
--- /dev/null
+++ b/apps/desktop/scripts/desktop-workflow.test.mjs
@@ -0,0 +1,163 @@
+import assert from "node:assert/strict";
+import { execFileSync, spawnSync } from "node:child_process";
+import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, join, resolve } from "node:path";
+import { test as nodeTest } from "node:test";
+import { fileURLToPath } from "node:url";
+import { runInNewContext } from "node:vm";
+
+const root = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
+const workflow = (name) => readFileSync(join(root, ".github", "workflows", `${name}.yml`), "utf8");
+const release = workflow("desktop-release");
+const prepare = workflow("desktop-prepare");
+const shellTest = process.platform === "win32" ? nodeTest.skip : nodeTest;
+
+// Read the actual workflow blocks, so the integration tests exercise the shell
+// steps and routing conditions that Actions runs without duplicating them here.
+function block(text, marker) {
+ const lines = text.split("\n");
+ const start = lines.findIndex((line) => line.trim() === marker);
+ assert.notEqual(start, -1, `missing workflow block: ${marker}`);
+ const indent = lines[start].search(/\S/);
+ let end = start + 1;
+ while (end < lines.length && (!lines[end].trim() || lines[end].search(/\S/) > indent)) end++;
+ return lines.slice(start + 1, end);
+}
+
+function shellStep(text, name) {
+ return block(block(text, `- name: ${name}`).join("\n"), "run: |").map((line) => line.slice(10)).join("\n");
+}
+
+function evaluate(expression, context) {
+ const source = expression.replace(/^\$\{\{\s*|\s*\}\}$/g, "")
+ .replace(/\.([\w]+-[\w-]+)/g, (_, name) => `[${JSON.stringify(name)}]`);
+ return runInNewContext(source, context, { timeout: 1000 });
+}
+
+function jobCondition(name, context) {
+ const line = block(release, `${name}:`).find((value) => value.trim().startsWith("if:"));
+ assert.ok(line, `missing ${name} condition`);
+ return evaluate(line.trim().slice(3).trim(), context);
+}
+
+nodeTest("tag releases build in their own context; branch requests dispatch without building", () => {
+ for (const [refType, refName, input, dispatch, build, tag] of [
+ ["tag", "v1.2.3", "", false, true, "v1.2.3"],
+ ["tag", "v1.2.3-beta.4", "", false, true, "v1.2.3-beta.4"],
+ ["tag", "v1.2.3", "v1.2.3", false, true, "v1.2.3"],
+ ["branch", "dev", "", false, true, ""],
+ ["branch", "feature/desktop", "", false, true, ""],
+ ["branch", "dev", "v1.2.3", true, false, "v1.2.3"],
+ ["branch", "main", "v1.2.3-beta.4", true, false, "v1.2.3-beta.4"],
+ ]) {
+ const context = { github: { ref_type: refType, ref_name: refName }, inputs: { release_tag: input } };
+ assert.equal(jobCondition("dispatch", context), dispatch, `${refType} ${refName} ${input}`);
+ assert.equal(jobCondition("prepare", context), build, `${refType} ${refName} ${input}`);
+ const expression = block(release, "prepare:").find((line) => line.trim().startsWith("release-tag:")).trim().slice("release-tag:".length).trim();
+ assert.equal(evaluate(expression, context), tag);
+ const needs = { prepare: { outputs: { "release-tag": build ? tag : "" } } };
+ assert.equal(jobCondition("publish", { needs }), build && tag !== "");
+ }
+});
+
+nodeTest("every desktop checkout uses the event commit, never a supplied tag or job output", () => {
+ for (const name of ["desktop-release", "desktop-prepare", "desktop-macos", "desktop-linux", "desktop-windows"]) {
+ const text = workflow(name);
+ const checkouts = text.split("\n").filter((line) => line.trim().startsWith("- uses: actions/checkout@"));
+ assert.ok(checkouts.length > 0, name);
+ const refs = [...text.matchAll(/- uses: actions\/checkout@[^\n]+\n\s+with:\n\s+ref: ([^\n]+)/g)].map((match) => match[1]);
+ assert.equal(refs.length, checkouts.length, name);
+ assert.ok(refs.every((ref) => ref === "${{ github.sha }}"), name);
+ }
+});
+
+function fixture(t) {
+ const directory = mkdtempSync(join(tmpdir(), "desktop-workflow-"));
+ t.after(() => rmSync(directory, { recursive: true, force: true }));
+ const write = (path, content, options) => {
+ mkdirSync(dirname(join(directory, path)), { recursive: true });
+ writeFileSync(join(directory, path), content, options);
+ };
+ write("VERSION", "1.2.3-beta.4\n");
+ write(".github/actions/desktop-setup/action.yml", "name: fixture\n");
+ write("apps/desktop/scripts/collect-release-assets.mjs", "");
+ copyFileSync(join(root, "apps/desktop/scripts/desktop-source.mjs"), join(directory, "apps/desktop/scripts/desktop-source.mjs"));
+ write("scripts/sync-version.mjs", "import { writeFileSync } from 'node:fs'; writeFileSync('sync-ran', 'yes');\n");
+ const git = (args) => execFileSync("git", args, { cwd: directory, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
+ git(["init", "-q"]);
+ git(["add", "."]);
+ git(["-c", "user.name=Test", "-c", "user.email=test@example.invalid", "-c", "commit.gpgsign=false", "commit", "-qm", "fixture"]);
+ const sha = git(["rev-parse", "HEAD"]);
+ const env = { ...process.env, GITHUB_SHA: sha, GITHUB_REF: "refs/tags/v1.2.3-beta.4", RELEASE_TAG: "",
+ GITHUB_OUTPUT: join(directory, "outputs"), GITHUB_STEP_SUMMARY: join(directory, "summary") };
+ delete env.SOURCE_SHA;
+ const run = (script, extra = {}) => spawnSync("bash", ["-e", "-o", "pipefail", "-c", script], {
+ cwd: directory, env: { ...env, ...extra }, encoding: "utf8",
+ });
+ return { directory, sha, env, write, run };
+}
+
+shellTest("source verification resolves stable, beta and artifact-only runs from their actual ref", (t) => {
+ for (const [version, ref, requested, tag, channel] of [
+ ["1.2.3", "refs/tags/v1.2.3", "", "v1.2.3", "stable"],
+ ["1.2.3-beta.4", "refs/tags/v1.2.3-beta.4", "v1.2.3-beta.4", "v1.2.3-beta.4", "beta"],
+ ["1.2.3-beta.4", "refs/heads/dev", "", "", "beta"],
+ ]) {
+ const f = fixture(t);
+ f.write("VERSION", `${version}\n`);
+ const result = f.run(shellStep(prepare, "Verify source"), { GITHUB_REF: ref, RELEASE_TAG: requested });
+ assert.equal(result.status, 0, result.stderr);
+ assert.equal(existsSync(join(f.directory, "sync-ran")), true);
+ assert.equal(readFileSync(f.env.GITHUB_OUTPUT, "utf8"), `sha=${f.sha}\nrelease-tag=${tag}\nchannel=${channel}\n`);
+ }
+});
+
+shellTest("cross-ref inputs and source commits fail before running selected-source scripts", (t) => {
+ for (const extra of [
+ { GITHUB_REF: "refs/heads/dev", RELEASE_TAG: "v1.2.3-beta.4" },
+ { RELEASE_TAG: "v9.9.9" },
+ { RELEASE_TAG: "v1.2.3-beta.4%2Fother" },
+ { GITHUB_SHA: "b".repeat(40) },
+ { SOURCE_SHA: "b".repeat(40) },
+ ]) {
+ const f = fixture(t);
+ const result = f.run(shellStep(prepare, "Verify source"), extra);
+ assert.notEqual(result.status, 0);
+ assert.match(result.stderr, /does not match the workflow/);
+ assert.equal(existsSync(join(f.directory, "sync-ran")), false);
+ assert.equal(existsSync(f.env.GITHUB_OUTPUT), false);
+ }
+});
+
+shellTest("native setup and publication reject a mismatched source before consuming it", (t) => {
+ const f = fixture(t);
+ const action = readFileSync(join(root, ".github/actions/desktop-setup/action.yml"), "utf8");
+ for (const [text, name] of [[action, "Verify source commit"], [release, "Verify publication source"]]) {
+ const command = block(text, `- name: ${name}`).find((line) => line.trim().startsWith("run:")).trim().slice(4).trim();
+ assert.equal(f.run(command, { SOURCE_SHA: f.sha }).status, 0);
+ const result = f.run(command, { SOURCE_SHA: "b".repeat(40) });
+ assert.notEqual(result.status, 0);
+ assert.match(result.stderr, /requested source does not match/);
+ }
+});
+
+shellTest("both dispatchers target the tag without forwarding an input that could redispatch", (t) => {
+ const f = fixture(t);
+ f.write("bin/gh", `#!${process.execPath}\nimport('node:fs').then(fs => {\n const args = process.argv.slice(2);\n fs.appendFileSync(process.env.TEST_GH_CALLS, JSON.stringify(args) + '\\n');\n if (process.env.TEST_MISSING_TAG === args[args.indexOf('--ref') + 1]) process.exit(1);\n});\n`, { mode: 0o755 });
+ const calls = join(f.directory, "gh-calls");
+ const env = { PATH: `${join(f.directory, "bin")}:${process.env.PATH}`, TEST_GH_CALLS: calls, RELEASE_TAG: "v1.2.3-beta.4" };
+ for (const [text, name] of [[release, "Dispatch"], [workflow("release-tag"), "Start release builds for the tag"]]) {
+ f.write("gh-calls", "");
+ const result = f.run(shellStep(text, name), env);
+ assert.equal(result.status, 0, result.stderr);
+ const commands = readFileSync(calls, "utf8").trim().split("\n").map(JSON.parse);
+ assert.deepEqual(commands[0], ["workflow", "run", "desktop-release.yml", "--ref", `refs/tags/${env.RELEASE_TAG}`]);
+ if (commands.length > 1) assert.deepEqual(commands[1], ["workflow", "run", "image.yml", "--ref", env.RELEASE_TAG]);
+ }
+ // A branch-only input must request a missing tag, never fall back to running
+ // that branch. GitHub rejects the fully qualified tag ref when it is absent.
+ f.write("gh-calls", "");
+ assert.notEqual(f.run(shellStep(release, "Dispatch"), { ...env, RELEASE_TAG: "dev", TEST_MISSING_TAG: "refs/tags/dev" }).status, 0);
+ assert.deepEqual(JSON.parse(readFileSync(calls, "utf8")), ["workflow", "run", "desktop-release.yml", "--ref", "refs/tags/dev"]);
+});
diff --git a/apps/desktop/scripts/package-macos.sh b/apps/desktop/scripts/package-macos.sh
new file mode 100644
index 000000000..2e812b1af
--- /dev/null
+++ b/apps/desktop/scripts/package-macos.sh
@@ -0,0 +1,121 @@
+#!/usr/bin/env bash
+# CI packaging phases, each exposed as a separate workflow step. The app is
+# signed once; both the DMG and updater are made from that same final app.
+# Usage: package-macos.sh sign-app|dmg|sign-dmg|updater|verify [src-tauri]
+set -euo pipefail
+
+phase="${1:?a packaging phase is required}"
+target="${2:?a Rust target triple is required}"
+script_dir="$(cd "$(dirname "$0")" && pwd)"
+tauri_dir="${3:-$script_dir/../src-tauri}"
+tauri_dir="$(cd "$tauri_dir" && pwd)"
+case "$target" in
+ aarch64-apple-darwin) arch=arm64; dmg_arch=aarch64; binary_arch=arm64 ;;
+ x86_64-apple-darwin) arch=x64; dmg_arch=x64; binary_arch=x86_64 ;;
+ *) echo "unsupported macOS target: $target" >&2; exit 1 ;;
+esac
+product="$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).productName' "$tauri_dir/tauri.conf.json")"
+version="$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).version' "$tauri_dir/tauri.conf.json")"
+bundle="$tauri_dir/target/$target/release/bundle"
+app="$bundle/macos/$product.app"
+dmg="$bundle/dmg/${product}_${version}_${dmg_arch}.dmg"
+archive="$bundle/macos/$product.app.tar.gz"
+release_stem="DEEIX-Chat-$version-macos-$arch"
+mode="${DESKTOP_SIGNING_MODE:-adhoc}"
+workdir=""
+keychain=""
+cleanup() {
+ [[ -z "$keychain" ]] || security delete-keychain "$keychain" || true
+ [[ -z "$workdir" ]] || rm -rf "$workdir"
+}
+trap cleanup EXIT
+
+fail() { echo "package-macos: $*" >&2; exit 1; }
+make_workdir() { workdir="$(mktemp -d "$bundle/.package-macos.XXXXXX")"; }
+
+sign_external() {
+ # Fetch once per job so app and DMG use exactly the same helper revision.
+ local helper="${RUNNER_TEMP:?}/desktop-signing-$target.sh"
+ if [[ ! -s "$helper" ]]; then
+ curl -fsSL --connect-timeout 20 --max-time 120 --retry 2 -- "$SIGN_AND_NOTARIZE_SCRIPT_URL" > "$helper"
+ [[ -s "$helper" ]] || fail "the signing helper is empty"
+ fi
+ API_BASE_URL="$SIGN_AND_NOTARIZE_BASE_URL" \
+ API_TOKEN="$SIGN_AND_NOTARIZE_API_TOKEN" \
+ RESULT_HELPER_URL="$SIGN_AND_NOTARIZE_HELPER_URL" \
+ bash -c "$(cat "$helper")" -- "$1" "$2"
+}
+
+case "$phase" in
+ sign-app)
+ [[ "$mode" == external ]] || fail "sign-app requires the external signing provider"
+ [[ -d "$app" ]] || fail "missing $app"
+ make_workdir
+ ditto "$app" "$workdir/$release_stem.app"
+ mkdir -p "$workdir/signed"
+ signed_app="$workdir/signed/$product.app"
+ sign_external "$workdir/$release_stem.app" "$signed_app"
+ [[ -d "$signed_app" ]] || fail "the signing helper did not produce an app"
+ codesign --verify --deep --strict "$signed_app"
+ team="$(codesign -dv "$signed_app" 2>&1 | sed -n 's/^TeamIdentifier=//p')"
+ [[ "$team" =~ ^[A-Z0-9]{10}$ ]] || fail "the signing service returned an app without a Developer ID team"
+ # verify-macos-bundle requires this same team and a stapled notarization
+ # ticket on both containers, including the external signing path.
+ printf 'APPLE_TEAM_ID=%s\n' "$team" >> "${GITHUB_ENV:?}"
+ rm -rf "$app"
+ ditto "$signed_app" "$app"
+ ;;
+ dmg)
+ [[ -d "$app" ]] || fail "missing $app"
+ make_workdir
+ stage="$workdir/image"
+ mkdir -p "$stage" "$bundle/dmg"
+ ditto "$app" "$stage/$product.app"
+ ln -s /Applications "$stage/Applications"
+ if [[ -f "$tauri_dir/icons/icon.icns" ]]; then
+ cp "$tauri_dir/icons/icon.icns" "$stage/.VolumeIcon.icns"
+ if command -v SetFile >/dev/null; then SetFile -a C "$stage"; fi
+ fi
+ # Encode directly to the final format, avoiding Tauri's zlib DMG followed
+ # by a writable conversion, app replacement and a second compression.
+ hdiutil create -volname "$product" -srcfolder "$stage" -fs HFS+ -format ULFO -ov "$dmg"
+ ;;
+ sign-dmg)
+ [[ -s "$dmg" ]] || fail "missing $dmg"
+ if [[ "$mode" == external ]]; then
+ make_workdir
+ cp "$dmg" "$workdir/$release_stem.dmg"
+ sign_external "$workdir/$release_stem.dmg" "$workdir/signed.dmg"
+ [[ -s "$workdir/signed.dmg" ]] || fail "the signing helper did not produce a DMG"
+ mv -f "$workdir/signed.dmg" "$dmg"
+ elif [[ "$mode" == apple ]]; then
+ # Tauri deletes its temporary keychain when app bundling exits. Import
+ # the certificate for this phase without changing the user's search list.
+ make_workdir
+ keychain="$workdir/dmg-signing.keychain-db"
+ password="$(openssl rand -hex 24)"
+ printf '%s' "$APPLE_CERTIFICATE" | base64 --decode > "$workdir/certificate.p12"
+ security create-keychain -p "$password" "$keychain"
+ security unlock-keychain -p "$password" "$keychain"
+ security import "$workdir/certificate.p12" -k "$keychain" -P "${APPLE_CERTIFICATE_PASSWORD:-}" -T /usr/bin/codesign
+ security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$password" "$keychain" >/dev/null
+ codesign --force --sign "$APPLE_SIGNING_IDENTITY" --keychain "$keychain" --timestamp "$dmg"
+ xcrun notarytool submit "$dmg" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" --team-id "$APPLE_TEAM_ID" --wait
+ xcrun stapler staple "$dmg"
+ else
+ fail "sign-dmg requires an Apple signing provider"
+ fi
+ ;;
+ updater)
+ [[ -d "$app" ]] || fail "missing $app"
+ [[ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]] || fail "updater signing key is required"
+ export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="${TAURI_SIGNING_PRIVATE_KEY_PASSWORD:-}"
+ tar -czf "$archive" -C "$bundle/macos" "$product.app"
+ pnpm --dir "$tauri_dir/.." exec tauri signer sign --app-version "$version" "$archive"
+ [[ -s "$archive.sig" ]] || fail "the updater signature was not created"
+ ;;
+ verify)
+ bash "$script_dir/verify-macos-bundle.sh" --arch "$binary_arch" "$dmg" "$archive"
+ ;;
+ *) fail "unknown packaging phase: $phase" ;;
+esac
diff --git a/apps/desktop/scripts/package-macos.test.mjs b/apps/desktop/scripts/package-macos.test.mjs
new file mode 100644
index 000000000..06fe1d0ce
--- /dev/null
+++ b/apps/desktop/scripts/package-macos.test.mjs
@@ -0,0 +1,178 @@
+import assert from "node:assert/strict";
+import { execFileSync, spawnSync } from "node:child_process";
+import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+import { test as nodeTest } from "node:test";
+import { fileURLToPath } from "node:url";
+
+const scripts = dirname(fileURLToPath(import.meta.url));
+// These shell integration tests run on the Linux desktop CI job and on macOS.
+const test = process.platform === "win32" ? nodeTest.skip : nodeTest;
+const target = "aarch64-apple-darwin";
+const product = "DEEIX Chat";
+const version = "1.2.3-beta.4";
+const apple = { APPLE_CERTIFICATE: "Y2VydGlmaWNhdGU=", APPLE_CERTIFICATE_PASSWORD: "", APPLE_ID: "test@example.invalid", APPLE_PASSWORD: "test-password" };
+const remote = {
+ SIGN_AND_NOTARIZE_SCRIPT_URL: "https://example.invalid/sign.sh", SIGN_AND_NOTARIZE_BASE_URL: "https://example.invalid",
+ SIGN_AND_NOTARIZE_API_TOKEN: "test-token", SIGN_AND_NOTARIZE_HELPER_URL: "https://example.invalid/result.sh",
+};
+
+function fixture(t) {
+ const root = mkdtempSync(join(tmpdir(), "macos-packaging-"));
+ t.after(() => rmSync(root, { recursive: true, force: true }));
+ const bin = join(root, "bin");
+ const tauri = join(root, "desktop project", "src-tauri");
+ const bundle = join(tauri, "target", target, "release", "bundle");
+ const app = join(bundle, "macos", `${product}.app`);
+ const dmg = join(bundle, "dmg", `${product}_${version}_aarch64.dmg`);
+ const archive = `${app}.tar.gz`;
+ const image = join(root, "dmg contents");
+ const log = join(root, "calls.jsonl");
+ const githubEnv = join(root, "github-env");
+ mkdirSync(bin);
+ mkdirSync(join(app, "Contents", "MacOS"), { recursive: true });
+ writeFileSync(join(tauri, "tauri.conf.json"), JSON.stringify({ productName: product, version }));
+ writeFileSync(join(app, "Contents", "MacOS", "app"), "unsigned");
+ chmodSync(join(app, "Contents", "MacOS", "app"), 0o755);
+ symlinkSync("app", join(app, "Contents", "MacOS", "alias"));
+ writeFileSync(githubEnv, "");
+ const helper = `set -euo pipefail
+printf '%s\\n' "$(basename "$1")" >> "$TEST_SIGN_REQUESTS"
+if [[ -d "$1" ]]; then
+ ditto "$1" "$2"
+ printf 'signed-app' > "$2/Contents/MacOS/app"
+else
+ cp "$1" "$2"
+ printf ':signed-dmg' >> "$2"
+fi
+`;
+ const helperPath = join(root, "remote-helper.sh");
+ writeFileSync(helperPath, helper);
+ const stub = `#!${process.execPath}
+const fs = require('node:fs');
+const path = require('node:path');
+const crypto = require('node:crypto');
+const tool = path.basename(process.argv[1]);
+const args = process.argv.slice(2);
+const env = process.env;
+fs.appendFileSync(env.TEST_LOG, JSON.stringify({ tool, args }) + '\\n');
+switch (tool) {
+ case 'curl': process.stdout.write(fs.readFileSync(env.TEST_HELPER)); break;
+ case 'ditto': fs.cpSync(args[0], args[1], { recursive: true, verbatimSymlinks: true }); break;
+ case 'codesign':
+ if (args.includes('-dv')) console.error('TeamIdentifier=ABCDE12345');
+ if (args.includes('--verify') && env.TEST_BAD_SIGNATURE) process.exit(1);
+ if (args.includes('--keychain') && !fs.existsSync(args[args.indexOf('--keychain') + 1])) process.exit(2);
+ break;
+ case 'hdiutil':
+ if (args[0] !== 'create') throw new Error('unexpected extra DMG conversion');
+ fs.cpSync(args[args.indexOf('-srcfolder') + 1], env.TEST_IMAGE, { recursive: true, verbatimSymlinks: true });
+ fs.writeFileSync(args.at(-1), 'dmg');
+ break;
+ case 'pnpm':
+ if (!env.TAURI_SIGNING_PRIVATE_KEY) throw new Error('updater key missing');
+ fs.writeFileSync(args.at(-1) + '.sig', crypto.createHash('sha256').update(fs.readFileSync(args.at(-1))).digest('hex'));
+ break;
+ case 'openssl':
+ if (args[0] === 'rand') console.log('0123456789abcdef');
+ else if (args[0] === 'pkcs12') console.log('certificate');
+ else console.log(' CN=Developer ID Application: Test (ABCDE12345)\\n OU=ABCDE12345');
+ break;
+ case 'security':
+ if (args[0] === 'create-keychain') fs.writeFileSync(args.at(-1), 'keychain');
+ if (args[0] === 'delete-keychain') fs.rmSync(args.at(-1));
+ break;
+ case 'xcrun': if (env.TEST_NOTARY_FAILURE) process.exit(1); break;
+ default: throw new Error('unexpected tool: ' + tool);
+}
+`;
+ for (const name of ["curl", "ditto", "codesign", "hdiutil", "pnpm", "openssl", "security", "xcrun"]) {
+ writeFileSync(join(bin, name), stub, { mode: 0o755 });
+ }
+ const env = {
+ ...process.env,
+ ...Object.fromEntries([...Object.keys(apple), ...Object.keys(remote), "APPLE_TEAM_ID", "APPLE_CERTIFICATE_PATH", "TAURI_SIGNING_PRIVATE_KEY"].map((name) => [name, ""])),
+ PATH: `${bin}:${process.env.PATH}`, GITHUB_ENV: githubEnv, RUNNER_TEMP: root,
+ TEST_HELPER: helperPath, TEST_LOG: log, TEST_IMAGE: image, TEST_SIGN_REQUESTS: join(root, "requests"),
+ };
+ const configure = (extra = {}) => spawnSync("bash", [join(scripts, "configure-apple-signing.sh")], { env: { ...env, ...extra }, encoding: "utf8" });
+ const phase = (name, extra = {}) => spawnSync("bash", [join(scripts, "package-macos.sh"), name, target, tauri], { env: { ...env, ...extra }, encoding: "utf8" });
+ const calls = () => existsSync(log) ? readFileSync(log, "utf8").trim().split("\n").map(JSON.parse) : [];
+ return { root, app, dmg, archive, bundle, githubEnv, env, image, configure, phase, calls };
+}
+
+function passed(result) {
+ assert.equal(result.status, 0, result.stderr + result.stdout);
+}
+
+test("signing configuration selects one provider and rejects partial credentials", (t) => {
+ const f = fixture(t);
+ passed(f.configure());
+ assert.match(readFileSync(f.githubEnv, "utf8"), /DESKTOP_SIGNING_MODE=adhoc/);
+ writeFileSync(f.githubEnv, "");
+ passed(f.configure({ ...apple, ...remote }));
+ assert.equal(readFileSync(f.githubEnv, "utf8"), "DESKTOP_SIGNING_MODE=external\nAPPLE_SIGNING_IDENTITY=-\n");
+ assert.equal(f.calls().length, 0, "external signing must not import or inspect the Apple certificate");
+ assert.notEqual(f.configure({ SIGN_AND_NOTARIZE_SCRIPT_URL: remote.SIGN_AND_NOTARIZE_SCRIPT_URL }).status, 0);
+ assert.notEqual(f.configure({ APPLE_ID: apple.APPLE_ID }).status, 0);
+ writeFileSync(f.githubEnv, "");
+ passed(f.configure(apple));
+ const exported = readFileSync(f.githubEnv, "utf8");
+ assert.match(exported, /DESKTOP_SIGNING_MODE=apple/);
+ assert.match(exported, /APPLE_SIGNING_IDENTITY=Developer ID Application: Test \(ABCDE12345\)/);
+ assert.match(exported, /APPLE_TEAM_ID=ABCDE12345/);
+});
+
+test("DMG and updater contain the same final signed app with its executable mode and symlink", (t) => {
+ const f = fixture(t);
+ const env = { ...remote, DESKTOP_SIGNING_MODE: "external", TAURI_SIGNING_PRIVATE_KEY: "test-updater-key" };
+ for (const phase of ["sign-app", "dmg", "sign-dmg", "updater"]) passed(f.phase(phase, env));
+ const unpacked = join(f.root, "updater contents");
+ mkdirSync(unpacked);
+ execFileSync("tar", ["-xzf", f.archive, "-C", unpacked]);
+ for (const app of [f.app, join(f.image, `${product}.app`), join(unpacked, `${product}.app`)]) {
+ assert.equal(readFileSync(join(app, "Contents", "MacOS", "app"), "utf8"), "signed-app");
+ assert.equal(readlinkSync(join(app, "Contents", "MacOS", "alias")), "app");
+ execFileSync("test", ["-x", join(app, "Contents", "MacOS", "app")]);
+ }
+ assert.equal(readlinkSync(join(f.image, "Applications")), "/Applications");
+ assert.equal(readFileSync(f.dmg, "utf8"), "dmg:signed-dmg");
+ assert.equal(existsSync(`${f.archive}.sig`), true);
+ assert.match(readFileSync(f.githubEnv, "utf8"), /APPLE_TEAM_ID=ABCDE12345/);
+ assert.equal(f.calls().filter(({ tool }) => tool === "curl").length, 1);
+ const images = f.calls().filter(({ tool }) => tool === "hdiutil");
+ assert.equal(images.length, 1);
+ assert.equal(images[0].args[images[0].args.indexOf("-format") + 1], "ULFO");
+ const signer = f.calls().find(({ tool }) => tool === "pnpm");
+ assert.deepEqual(signer.args.slice(2), ["exec", "tauri", "signer", "sign", "--app-version", version, f.archive]);
+ assert.deepEqual(readFileSync(f.env.TEST_SIGN_REQUESTS, "utf8").trim().split("\n"), [
+ `DEEIX-Chat-${version}-macos-arm64.app`, `DEEIX-Chat-${version}-macos-arm64.dmg`,
+ ]);
+ assert.equal(readdirSync(f.bundle).some((name) => name.startsWith(".package-macos.")), false);
+});
+
+test("an invalid service signature never replaces the app and a missing updater key fails", (t) => {
+ const f = fixture(t);
+ assert.notEqual(f.phase("sign-app", { ...remote, DESKTOP_SIGNING_MODE: "external", TEST_BAD_SIGNATURE: "1" }).status, 0);
+ assert.equal(readFileSync(join(f.app, "Contents", "MacOS", "app"), "utf8"), "unsigned");
+ assert.notEqual(f.phase("updater").status, 0);
+ assert.equal(existsSync(f.archive), false);
+});
+
+test("local Apple DMG signing imports a fresh keychain and cleans it up on notarization failure", (t) => {
+ const f = fixture(t);
+ passed(f.phase("dmg"));
+ const result = f.phase("sign-dmg", {
+ ...apple, DESKTOP_SIGNING_MODE: "apple", APPLE_SIGNING_IDENTITY: "Developer ID Application: Test (ABCDE12345)",
+ APPLE_TEAM_ID: "ABCDE12345", TEST_NOTARY_FAILURE: "1",
+ });
+ assert.notEqual(result.status, 0);
+ const calls = f.calls();
+ const sign = calls.find(({ tool, args }) => tool === "codesign" && args.includes("--keychain"));
+ assert.ok(sign);
+ const keychain = sign.args[sign.args.indexOf("--keychain") + 1];
+ assert.ok(calls.some(({ tool, args }) => tool === "security" && args[0] === "delete-keychain" && args[1] === keychain));
+ assert.equal(existsSync(keychain), false);
+ assert.equal(readdirSync(f.bundle).some((name) => name.startsWith(".package-macos.")), false);
+});
diff --git a/apps/desktop/scripts/publish-desktop-release.mjs b/apps/desktop/scripts/publish-desktop-release.mjs
new file mode 100644
index 000000000..2bb489aa0
--- /dev/null
+++ b/apps/desktop/scripts/publish-desktop-release.mjs
@@ -0,0 +1,123 @@
+// Assemble all four verified targets, then upload once into one draft. The
+// updater keys and public filenames match the previous tauri-action releases.
+import { execFileSync } from "node:child_process";
+import { appendFileSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+import { pathToFileURL } from "node:url";
+
+import { releaseAssets, releasePlatforms, requireFile } from "./collect-release-assets.mjs";
+import { formatChecksums, hashFile } from "./release-checksums.mjs";
+
+export function releaseManifest({ version, tag, repository, directory, date = new Date().toISOString() }) {
+ if (tag !== `v${version}`) throw new Error(`Tag ${tag} does not match VERSION ${version}`);
+ if (!/^[\w.-]+\/[\w.-]+$/.test(repository ?? "")) throw new Error("expected repository owner/name");
+ const platforms = {};
+ const files = [];
+ const present = new Set(readdirSync(directory));
+ for (const platform of releasePlatforms) {
+ for (const spec of releaseAssets(platform, version)) {
+ if (spec.optional && !present.has(spec.name)) continue;
+ requireFile(join(directory, spec.name));
+ files.push(spec.name);
+ if (!spec.updater.length) continue;
+ const signatureName = `${spec.name}.sig`;
+ requireFile(join(directory, signatureName));
+ const signature = readFileSync(join(directory, signatureName), "utf8").trim();
+ if (!signature) throw new Error(`empty updater signature: ${signatureName}`);
+ files.push(signatureName);
+ const entry = {
+ signature,
+ url: `https://github.com/${repository}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(spec.name)}`,
+ };
+ for (const key of spec.updater) platforms[key] = entry;
+ }
+ }
+ const allowed = new Set([...files, "latest.json", "SHA256SUMS"]);
+ for (const name of present) {
+ if (!allowed.has(name)) throw new Error(`unexpected release asset: ${name}`);
+ }
+ return { manifest: { version, notes: "", pub_date: date, platforms }, files: files.sort() };
+}
+
+export async function assembleRelease(options) {
+ const { manifest, files } = releaseManifest(options);
+ writeFileSync(join(options.directory, "latest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
+ files.push("latest.json");
+ const entries = await Promise.all(files.map(async (name) => ({ name, sha256: await hashFile(join(options.directory, name)) })));
+ writeFileSync(join(options.directory, "SHA256SUMS"), formatChecksums(entries));
+ return [...files, "SHA256SUMS"];
+}
+
+export function existingDraft(releases, tag) {
+ const matches = releases.filter((release) => release.tag_name === tag);
+ if (matches.length > 1) throw new Error(`${tag} has multiple releases; remove the duplicate drafts before retrying`);
+ if (matches[0] && !matches[0].draft) throw new Error(`${tag} is already published; refusing to replace its assets`);
+ return matches[0];
+}
+
+function releaseNotes(version) {
+ const stem = `DEEIX-Chat-${version}`;
+ return `### ⏬ Download
+
+| Platform | Download |
+| --- | --- |
+| macOS · Apple silicon | \`${stem}-macos-arm64.dmg\` |
+| macOS · Intel | \`${stem}-macos-x64.dmg\` |
+| Linux · x86_64 | \`${stem}-linux-x64.AppImage\` or \`.deb\` |
+| Windows · x86_64 | \`${stem}-windows-x64-setup.exe\` |
+| Windows · x86_64 · MSI | \`${stem}-windows-x64.msi\` |
+| Windows · x86_64 · Portable | \`${stem}-windows-x64-portable.zip\` |
+
+Checksums are in \`SHA256SUMS\`. \`-updater.tar.gz\`, \`.sig\` and \`latest.json\` belong to the built-in updater.
+`;
+}
+
+async function main() {
+ const args = process.argv.slice(2);
+ const value = (flag) => args[args.indexOf(flag) + 1];
+ for (const flag of ["--tag", "--sha", "--dir"]) {
+ if (!args.includes(flag) || !value(flag)) throw new Error(`missing ${flag}`);
+ }
+ const tag = value("--tag");
+ const sha = value("--sha");
+ if (!/^[0-9a-f]{40}$/.test(sha)) throw new Error("--sha must be an immutable Git commit");
+ const repository = process.env.GH_REPO;
+ const directory = value("--dir");
+ const version = readFileSync("VERSION", "utf8").trim();
+ const files = await assembleRelease({ version, tag, repository, directory });
+ // A dry run exercises manifest/checksum generation without a GitHub write.
+ if (args.includes("--dry-run")) {
+ console.log(files.join("\n"));
+ return;
+ }
+ const gh = (args, input) => execFileSync("gh", args, { encoding: "utf8", input, maxBuffer: 16 * 1024 * 1024 });
+ // Resolve the actual tag, including annotated tags, never a same-named branch.
+ const commit = JSON.parse(gh(["api", `repos/${repository}/commits/${encodeURIComponent(`refs/tags/${tag}`)}`]));
+ if (commit.sha !== sha) throw new Error(`${tag} moved since the build started (${sha} -> ${commit.sha})`);
+ const pages = JSON.parse(gh(["api", `repos/${repository}/releases?per_page=100`, "--paginate", "--slurp"]));
+ let release = existingDraft(pages.flat(), tag);
+ const unexpected = (release?.assets ?? []).filter(({ name }) => !files.includes(name));
+ if (unexpected.length) {
+ throw new Error(`draft contains assets outside this build; remove or reconcile them before retrying: ${unexpected.map(({ name }) => name).join(", ")}`);
+ }
+ if (!release) {
+ // The tag already exists and was verified above. An explicit target_commitish
+ // can require workflow-write permission, which GITHUB_TOKEN cannot grant.
+ release = JSON.parse(gh(["api", "--method", "POST", `repos/${repository}/releases`, "--input", "-"], JSON.stringify({
+ tag_name: tag, name: tag, draft: true,
+ prerelease: version.includes("-"), body: releaseNotes(version),
+ })));
+ }
+ gh(["release", "upload", tag, ...files.map((name) => join(directory, name)), "--repo", repository, "--clobber"]);
+ console.log(`Draft release ready: ${release.html_url}`);
+ if (process.env.GITHUB_STEP_SUMMARY) {
+ appendFileSync(process.env.GITHUB_STEP_SUMMARY, `Draft release: ${release.html_url}\n\nBuilt from ${sha}; uploaded ${files.length} files.\n`);
+ }
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ main().catch((error) => {
+ console.error(error.message);
+ process.exitCode = 1;
+ });
+}
diff --git a/apps/desktop/scripts/release-assets.test.mjs b/apps/desktop/scripts/release-assets.test.mjs
new file mode 100644
index 000000000..59949d56f
--- /dev/null
+++ b/apps/desktop/scripts/release-assets.test.mjs
@@ -0,0 +1,222 @@
+import assert from "node:assert/strict";
+import { execFileSync, spawnSync } from "node:child_process";
+import { createHash } from "node:crypto";
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+import { test } from "node:test";
+import { fileURLToPath } from "node:url";
+
+import { collectReleaseAssets } from "./collect-release-assets.mjs";
+import { assembleRelease, existingDraft, releaseManifest } from "./publish-desktop-release.mjs";
+
+const script = join(dirname(fileURLToPath(import.meta.url)), "publish-desktop-release.mjs");
+const version = "1.2.3-beta.4";
+const tag = `v${version}`;
+const repository = "DEEIX-AI/DEEIX-Chat";
+const sha = "a".repeat(40);
+const prefix = `DEEIX-Chat-${version}`;
+
+// Examples of the actual Tauri/portable filenames, independent of the staging
+// implementation. The manifest must retain the keys used by existing installs.
+const bundles = {
+ "macos-arm64": [
+ [`dmg/DEEIX Chat_${version}_aarch64.dmg`, "macos-arm64.dmg"],
+ ["macos/DEEIX Chat.app.tar.gz", "macos-arm64-updater.tar.gz", ["darwin-aarch64", "darwin-aarch64-app"]],
+ ],
+ "macos-x64": [
+ [`dmg/DEEIX Chat_${version}_x64.dmg`, "macos-x64.dmg"],
+ ["macos/DEEIX Chat.app.tar.gz", "macos-x64-updater.tar.gz", ["darwin-x86_64", "darwin-x86_64-app"]],
+ ],
+ "linux-x64": [
+ [`deb/DEEIX Chat_${version}_amd64.deb`, "linux-x64.deb", ["linux-x86_64-deb"]],
+ [`appimage/DEEIX Chat_${version}_amd64.AppImage`, "linux-x64.AppImage", ["linux-x86_64", "linux-x86_64-appimage"]],
+ ],
+ "windows-x64": [
+ [`nsis/DEEIX Chat_${version}_x64-setup.exe`, "windows-x64-setup.exe", ["windows-x86_64", "windows-x86_64-nsis"]],
+ [`msi/DEEIX Chat_${version}_x64_en-US.msi`, "windows-x64.msi", ["windows-x86_64-msi"]],
+ [`portable/${prefix}-windows-x64-portable.zip`, "windows-x64-portable.zip"],
+ ],
+};
+
+function fixture(t, { offline = false } = {}) {
+ const root = mkdtempSync(join(tmpdir(), "desktop-assets-"));
+ t.after(() => rmSync(root, { recursive: true, force: true }));
+ const out = join(root, "release");
+ const write = (path, bytes) => {
+ mkdirSync(dirname(path), { recursive: true });
+ writeFileSync(path, bytes);
+ };
+ for (const [platform, entries] of Object.entries(bundles)) {
+ for (const [source, final, updater] of entries) {
+ write(join(root, platform, source), `bytes:${final}`);
+ if (updater) write(join(root, platform, `${source}.sig`), `signature:${final}\n`);
+ }
+ }
+ if (offline) write(join(root, "windows-x64", "portable", `${prefix}-windows-x64-portable-offline.zip`), "offline runtime");
+ const collect = (platform) => collectReleaseAssets({ platform, version, bundle: join(root, platform), out });
+ const collectAll = () => Object.keys(bundles).forEach(collect);
+ return { root, out, write, collect, collectAll, options: { version, tag, repository, directory: out } };
+}
+
+test("stages all targets without changing signed bytes and preserves the installed updater keys", (t) => {
+ const f = fixture(t);
+ f.collectAll();
+ const { manifest, files } = releaseManifest(f.options);
+ const expectedKeys = [];
+ for (const entries of Object.values(bundles)) {
+ for (const [, final, updater] of entries) {
+ const name = `${prefix}-${final}`;
+ assert.equal(readFileSync(join(f.out, name), "utf8"), `bytes:${final}`);
+ for (const key of updater ?? []) {
+ expectedKeys.push(key);
+ assert.deepEqual(manifest.platforms[key], {
+ signature: `signature:${final}`,
+ url: `https://github.com/${repository}/releases/download/${tag}/${name}`,
+ });
+ }
+ }
+ }
+ assert.deepEqual(Object.keys(manifest.platforms).sort(), expectedKeys.sort());
+ assert.equal(manifest.platforms["windows-x86_64"].url.endsWith("-setup.exe"), true);
+ assert.equal(manifest.version, version);
+ assert.equal(files.length, 15);
+});
+
+test("hashes every final download, signature and manifest, including the optional offline portable", async (t) => {
+ const f = fixture(t, { offline: true });
+ f.collectAll();
+ const files = await assembleRelease(f.options);
+ assert.equal(files.length, 18);
+ const lines = readFileSync(join(f.out, "SHA256SUMS"), "utf8").trim().split("\n");
+ const hashes = new Map(lines.map((line) => [line.slice(66), line.slice(0, 64)]));
+ assert.deepEqual([...hashes.keys()].sort(), files.filter((name) => name !== "SHA256SUMS").sort());
+ for (const [name, hash] of hashes) {
+ assert.equal(hash, createHash("sha256").update(readFileSync(join(f.out, name))).digest("hex"));
+ }
+ // A retry can rebuild generated metadata without treating it as an extra asset.
+ assert.deepEqual(await assembleRelease(f.options), files);
+});
+
+test("a missing updater signature prevents staging any files for that platform", (t) => {
+ const f = fixture(t);
+ rmSync(join(f.root, "linux-x64", `${bundles["linux-x64"][1][0]}.sig`));
+ assert.throws(() => f.collect("linux-x64"), /missing or empty/);
+ assert.equal(existsSync(f.out), false);
+});
+
+test("rejects stale versions and multiple bundles instead of guessing which installer to ship", (t) => {
+ const f = fixture(t);
+ const directory = join(f.root, "macos-arm64", "dmg");
+ f.write(join(directory, "DEEIX Chat_0.0.1_aarch64.dmg"), "stale");
+ assert.throws(() => f.collect("macos-arm64"), /expected one/);
+ rmSync(join(f.root, "macos-arm64", bundles["macos-arm64"][0][0]));
+ assert.throws(() => f.collect("macos-arm64"), /does not match version/);
+ assert.equal(existsSync(f.out), false);
+});
+
+test("incomplete or mixed releases cannot produce a manifest", (t) => {
+ const f = fixture(t);
+ f.collect("macos-arm64");
+ assert.throws(() => releaseManifest(f.options), /missing or empty/);
+ for (const platform of ["macos-x64", "linux-x64", "windows-x64"]) f.collect(platform);
+ assert.throws(() => releaseManifest({ ...f.options, tag: "v9.0.0" }), /does not match/);
+ f.write(join(f.out, "stale.exe"), "other version");
+ assert.throws(() => releaseManifest(f.options), /unexpected release asset/);
+ rmSync(join(f.out, "stale.exe"));
+ f.write(join(f.out, `${prefix}-linux-x64.deb.sig`), " \n");
+ assert.throws(() => releaseManifest(f.options), /empty updater signature/);
+ assert.equal(existsSync(join(f.out, "latest.json")), false);
+});
+
+test("publication only resumes a single draft and never replaces a published release", () => {
+ const draft = { id: 123, tag_name: tag, draft: true };
+ assert.equal(existingDraft([draft, { tag_name: "v0.1.0", draft: false }], tag), draft);
+ assert.equal(existingDraft([], tag), undefined);
+ assert.throws(() => existingDraft([draft, { ...draft, id: 456 }], tag), /multiple releases/);
+ assert.throws(() => existingDraft([{ ...draft, draft: false }], tag), /already published/);
+});
+
+test("dry-run publication assembles metadata without invoking GitHub", (t) => {
+ const f = fixture(t);
+ f.collectAll();
+ f.write(join(f.root, "VERSION"), version);
+ const output = execFileSync(process.execPath, [script, "--tag", tag, "--sha", sha, "--dir", f.out, "--dry-run"], {
+ cwd: f.root, encoding: "utf8", env: { ...process.env, GH_REPO: repository, GH_TOKEN: "", PATH: "" },
+ });
+ assert.equal(output.trim().split("\n").length, 17);
+ assert.equal(readdirSync(f.out).length, 17);
+ assert.equal(JSON.parse(readFileSync(join(f.out, "latest.json"), "utf8")).version, version);
+});
+
+test("publication uses a verified tag without workflow-write permission and blocks unsafe retries", { skip: process.platform === "win32" }, (t) => {
+ const f = fixture(t);
+ f.collectAll();
+ f.write(join(f.root, "VERSION"), version);
+ const bin = join(f.root, "bin");
+ const log = join(f.root, "github-calls.jsonl");
+ mkdirSync(bin);
+ writeFileSync(join(bin, "gh"), `#!${process.execPath}
+const fs = require('node:fs');
+const args = process.argv.slice(2);
+const input = args.includes('--input') ? JSON.parse(fs.readFileSync(0, 'utf8')) : null;
+fs.appendFileSync(process.env.TEST_LOG, JSON.stringify({ args, input }) + '\\n');
+if (args[0] === 'release') process.exit(0);
+if (args[1].includes('/commits/')) {
+ // A same-named branch can still resolve when the actual tag is missing.
+ if (args[1].includes('/commits/refs%2Ftags%2F') && process.env.TEST_MISSING_TAG === '1') {
+ console.error('gh: Not Found (HTTP 404)');
+ process.exit(1);
+ }
+ console.log(JSON.stringify({ sha: process.env.TEST_COMMIT }));
+} else if (args.includes('POST')) {
+ // GitHub checks workflow-write permission for an explicit target commit
+ // with workflow changes, even when contents: write has been granted.
+ if (input.target_commitish) {
+ console.error('gh: Resource not accessible by integration (HTTP 403)');
+ process.exit(1);
+ }
+ console.log(JSON.stringify({ id: 123, html_url: 'https://example.invalid/draft', ...input }));
+}
+else console.log(JSON.stringify([JSON.parse(process.env.TEST_RELEASES)]));
+`, { mode: 0o755 });
+ const run = (commit, releases, extraEnv = {}) => {
+ writeFileSync(log, "");
+ const result = spawnSync(process.execPath, [script, "--tag", tag, "--sha", sha, "--dir", f.out], {
+ cwd: f.root, encoding: "utf8",
+ env: { ...process.env, GH_REPO: repository, GITHUB_STEP_SUMMARY: "", PATH: `${bin}:${process.env.PATH}`,
+ TEST_COMMIT: commit, TEST_RELEASES: JSON.stringify(releases), TEST_LOG: log, TEST_MISSING_TAG: "", ...extraEnv },
+ });
+ const calls = readFileSync(log, "utf8").trim().split("\n").map(JSON.parse);
+ return { result, calls, writes: calls.filter(({ args }) => args[0] === "release" || args.includes("POST")) };
+ };
+ const draft = { tag_name: tag, draft: true, assets: [] };
+ for (const [commit, releases, message] of [
+ ["b".repeat(40), [], /moved/],
+ [sha, [{ ...draft, draft: false }], /already published/],
+ [sha, [{ ...draft, assets: [{ name: "old-installer.exe" }] }], /outside this build/],
+ ]) {
+ const { result, writes } = run(commit, releases);
+ assert.notEqual(result.status, 0);
+ assert.match(result.stderr, message);
+ assert.deepEqual(writes, []);
+ }
+ const { result, calls, writes } = run(sha, []);
+ assert.equal(result.status, 0, result.stderr);
+ assert.deepEqual(calls[0].args, ["api", `repos/${repository}/commits/${encodeURIComponent(`refs/tags/${tag}`)}`]);
+ assert.equal(writes.length, 2, "one draft creation and one asset upload");
+ assert.equal(writes[0].input.tag_name, tag);
+ assert.equal(Object.hasOwn(writes[0].input, "target_commitish"), false);
+ assert.equal(writes[0].input.draft, true);
+ assert.equal(writes[0].input.prerelease, true);
+ assert.ok(writes[1].args.includes(join(f.out, "SHA256SUMS")));
+ assert.ok(writes[1].args.includes(join(f.out, "latest.json")));
+ const retry = run(sha, [draft]);
+ assert.equal(retry.result.status, 0, retry.result.stderr);
+ assert.equal(retry.writes.length, 1, "retry uploads to the existing draft without creating another one");
+ const missing = run(sha, [], { TEST_MISSING_TAG: "1" });
+ assert.notEqual(missing.result.status, 0);
+ assert.match(missing.result.stderr, /Not Found/);
+ assert.deepEqual(missing.writes, [], "a missing tag must never create a release or a new tag");
+ assert.equal(missing.calls.length, 1);
+});
diff --git a/apps/desktop/scripts/release-config.mjs b/apps/desktop/scripts/release-config.mjs
index 2c02fce69..aa756ce64 100644
--- a/apps/desktop/scripts/release-config.mjs
+++ b/apps/desktop/scripts/release-config.mjs
@@ -1,6 +1,5 @@
-// Build-time Tauri config for a release, printed as one `--config `
-// argument for tauri-action (which splits `args` on whitespace, so the JSON
-// must not contain spaces):
+// Build-time Tauri config for a release. CI writes it with --output; local
+// callers can still use the printed `--config ` argument:
//
// - beta builds poll the rolling desktop-beta manifest instead of
// releases/latest (see desktop-release.yml);
@@ -21,8 +20,9 @@
//
// Usage: release-config.mjs --channel stable|beta [--repository owner/name]
// [--version x.y.z] [--thumbprint ]
+// [--prebuilt-frontend] [--defer-updater] [--output path]
-import { readFileSync } from "node:fs";
+import { readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
@@ -53,11 +53,19 @@ export function msiVersion(version) {
}
/** The config object merged over tauri.conf.json for a release build. */
-export function releaseConfig({ version, channel, repository, thumbprint }) {
+export function releaseConfig({ version, channel, repository, thumbprint, prebuiltFrontend = false, deferUpdater = false }) {
if (channel !== "stable" && channel !== "beta") {
throw new Error(`unknown channel: ${channel}`);
}
const config = { bundle: { windows: { wix: { version: msiVersion(version) } } } };
+ if (prebuiltFrontend) {
+ config.build = { beforeBuildCommand: "" };
+ }
+ // macOS archives must contain the final signed/notarized app. CI creates
+ // and signs that archive after the external signing service has returned.
+ if (deferUpdater) {
+ config.bundle.createUpdaterArtifacts = false;
+ }
if (thumbprint) {
if (!/^[0-9A-Fa-f]{40}$/.test(thumbprint)) {
throw new Error("--thumbprint must be a 40-digit SHA-1 certificate thumbprint");
@@ -87,8 +95,15 @@ function main() {
channel: value("--channel"),
repository: value("--repository"),
thumbprint: value("--thumbprint"),
+ prebuiltFrontend: args.includes("--prebuilt-frontend"),
+ deferUpdater: args.includes("--defer-updater"),
});
- process.stdout.write(`--config ${JSON.stringify(config)}\n`);
+ const output = value("--output");
+ if (output) {
+ writeFileSync(output, `${JSON.stringify(config)}\n`);
+ } else {
+ process.stdout.write(`--config ${JSON.stringify(config)}\n`);
+ }
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
diff --git a/apps/desktop/scripts/release-config.test.mjs b/apps/desktop/scripts/release-config.test.mjs
index 33fa939d9..f7093c8b0 100644
--- a/apps/desktop/scripts/release-config.test.mjs
+++ b/apps/desktop/scripts/release-config.test.mjs
@@ -1,5 +1,7 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
+import { mkdtempSync, readFileSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
@@ -65,3 +67,19 @@ test("prints one whitespace-free --config argument", () => {
assert.deepEqual(rest, []);
assert.equal(JSON.parse(json).bundle.windows.wix.version, "1.0.0.1");
});
+
+test("CI consumes the shared frontend and signs the updater after app notarization", (t) => {
+ const directory = mkdtempSync(join(tmpdir(), "desktop-config-"));
+ t.after(() => rmSync(directory, { recursive: true, force: true }));
+ const output = join(directory, "release config.json");
+ const stdout = execFileSync(process.execPath, [script, "--channel", "beta", "--repository", "o/r",
+ "--version", "1.2.3-beta.4", "--prebuilt-frontend", "--defer-updater", "--output", output], { encoding: "utf8" });
+ assert.equal(stdout, "");
+ const config = JSON.parse(readFileSync(output, "utf8"));
+ assert.equal(config.build.beforeBuildCommand, "");
+ assert.equal(config.bundle.createUpdaterArtifacts, false);
+ assert.equal(config.bundle.windows.wix.version, "1.2.3.4");
+ assert.deepEqual(config.plugins.updater.endpoints, ["https://github.com/o/r/releases/download/desktop-beta/latest.json"]);
+ // Other platforms still let Tauri create and sign their updater packages.
+ assert.equal(releaseConfig({ version: "1.2.3", channel: "stable", prebuiltFrontend: true }).bundle.createUpdaterArtifacts, undefined);
+});
diff --git a/apps/desktop/scripts/sync-version.test.mjs b/apps/desktop/scripts/sync-version.test.mjs
new file mode 100644
index 000000000..41da7a718
--- /dev/null
+++ b/apps/desktop/scripts/sync-version.test.mjs
@@ -0,0 +1,92 @@
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, join, resolve } from "node:path";
+import { test } from "node:test";
+import { fileURLToPath } from "node:url";
+
+const repository = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
+const version = "0.4.6-beta.1";
+
+function fixture(t, { eol = "\n", name = "deeix-chat-desktop" } = {}) {
+ const root = mkdtempSync(join(tmpdir(), "sync-version-"));
+ t.after(() => rmSync(root, { recursive: true, force: true }));
+ const write = (path, content) => {
+ mkdirSync(dirname(join(root, path)), { recursive: true });
+ writeFileSync(join(root, path), content.replace(/\r?\n/g, eol));
+ };
+ const read = (path) => readFileSync(join(root, path), "utf8");
+ const lockPath = "apps/desktop/src-tauri/Cargo.lock";
+ write("VERSION", `${version}\n`);
+ for (const area of ["", "packages/api-contract/", "packages/core/", "apps/web/", "apps/desktop/", "backend/"]) {
+ write(`${area}package.json`, `${JSON.stringify({ name: area || "root", version }, null, 2)}\n`);
+ }
+ write("backend/cmd/server/main.go", `package main\n// @version ${version}\n`);
+ write("apps/desktop/src-tauri/tauri.conf.json", `${JSON.stringify({ version }, null, 2)}\n`);
+ write("apps/desktop/src-tauri/Cargo.toml", `[package]\nname = "${name}"\nversion = "${version}"\n\n[dependencies]\nfixture = "1"\n`);
+ const rootEntry = `[[package]]\nname = "${name}"\nversion = "0.4.5"\ndependencies = [\n "fixture",\n]\n`;
+ write(lockPath, `# Generated by Cargo.\nversion = 3\n\n[[package]]\nname = "fixture"\nversion = "0.4.5"\nsource = "registry+https://github.com/rust-lang/crates.io-index"\nchecksum = "unchanged-dependency-checksum"\n\n${rootEntry}\n[[package]]\nname = "${name}"\nversion = "7.8.9"\nsource = "registry+https://github.com/rust-lang/crates.io-index"\nchecksum = "unchanged-same-name-checksum"\n`);
+ const script = join(root, "scripts", "sync-version.mjs");
+ mkdirSync(dirname(script), { recursive: true });
+ copyFileSync(join(repository, "scripts", "sync-version.mjs"), script);
+ const run = (...args) => spawnSync(process.execPath, [script, ...args], { cwd: root, encoding: "utf8" });
+ return { root, write, read, run, lockPath, rootEntry: rootEntry.replace(/\n/g, eol), eol };
+}
+
+test("desktop version checks reject a stale Cargo root entry without changing it", (t) => {
+ const f = fixture(t);
+ const before = f.read(f.lockPath);
+ const result = f.run("--check", "desktop");
+ assert.notEqual(result.status, 0);
+ assert.match(result.stderr, /VERSION is not synchronized with 1 file/);
+ assert.match(result.stderr, /Cargo\.lock/);
+ assert.match(result.stderr, /node scripts\/sync-version\.mjs desktop/);
+ assert.equal(f.read(f.lockPath), before);
+});
+
+test("sync updates only the local package, preserving dependency pins and line endings", (t) => {
+ for (const options of [{ eol: "\n" }, { eol: "\r\n", name: "renamed-desktop" }]) {
+ const f = fixture(t, options);
+ const before = f.read(f.lockPath);
+ const result = f.run("desktop");
+ assert.equal(result.status, 0, result.stderr);
+ const expected = before.replace(f.rootEntry, f.rootEntry.replace('version = "0.4.5"', `version = "${version}"`));
+ assert.equal(f.read(f.lockPath), expected);
+ assert.equal(f.run("--check", "desktop").status, 0);
+ assert.equal(f.run("desktop").status, 0);
+ assert.equal(f.read(f.lockPath), expected, "a repeated sync is idempotent");
+ }
+});
+
+test("the normal all-target version command also checks and synchronizes Cargo.lock", (t) => {
+ const f = fixture(t);
+ const check = f.run("--check");
+ assert.notEqual(check.status, 0);
+ assert.match(check.stderr, /Cargo\.lock/);
+ assert.match(check.stderr, /Run: pnpm api:generate\s*$/);
+ const sync = f.run();
+ assert.equal(sync.status, 0, sync.stderr);
+ assert.equal(f.run("--check").status, 0);
+});
+
+test("missing or ambiguous local package records fail without rewriting dependencies", (t) => {
+ for (const duplicate of [false, true]) {
+ const f = fixture(t);
+ const before = f.read(f.lockPath);
+ f.write(f.lockPath, duplicate ? before + `\n${f.rootEntry}` : before.replace(f.rootEntry, ""));
+ const invalid = f.read(f.lockPath);
+ const result = f.run("desktop");
+ assert.notEqual(result.status, 0);
+ assert.match(result.stderr, /Expected one local/);
+ assert.equal(f.read(f.lockPath), invalid);
+ }
+});
+
+test("version checks for other areas remain independent of the desktop lockfile", (t) => {
+ const f = fixture(t);
+ for (const area of ["workspace", "web", "backend"]) {
+ const result = f.run("--check", area);
+ assert.equal(result.status, 0, result.stderr);
+ }
+});
diff --git a/apps/desktop/scripts/verify-macos-bundle.sh b/apps/desktop/scripts/verify-macos-bundle.sh
index 86d98026e..bf8fa6edd 100755
--- a/apps/desktop/scripts/verify-macos-bundle.sh
+++ b/apps/desktop/scripts/verify-macos-bundle.sh
@@ -81,6 +81,18 @@ check_team() {
[[ "$team" == "$APPLE_TEAM_ID" ]] || fail "$1 is signed by team '${team:-none}', expected $APPLE_TEAM_ID"
}
+verify_dmg_signature() {
+ local requirement
+ # Disk images can report TeamIdentifier=not set even with a valid Developer
+ # ID signature. Validate the signing certificate's OU (the Team ID) instead
+ # of the CodeDirectory field, and require Apple's Developer ID chain.
+ [[ "$APPLE_TEAM_ID" =~ ^[A-Z0-9]{10}$ ]] || fail "APPLE_TEAM_ID must contain 10 uppercase letters or digits"
+ requirement="=anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] exists \
+and certificate leaf[field.1.2.840.113635.100.6.1.13] exists and certificate leaf[subject.OU] = \"$APPLE_TEAM_ID\""
+ codesign --verify --strict --verbose=2 --test-requirement "$requirement" "$1" \
+ || fail "$1 must be signed by a Developer ID Application certificate for team $APPLE_TEAM_ID"
+}
+
verify_app() {
local app="$1" plist minimum executable binary archs minos
plist="$app/Contents/Info.plist"
@@ -122,8 +134,7 @@ for artifact in "${artifacts[@]}"; do
case "$artifact" in
*.dmg)
if $signed; then
- codesign --verify --strict --verbose=2 "$artifact"
- check_team "$artifact"
+ verify_dmg_signature "$artifact"
xcrun stapler validate "$artifact"
assess --type open --context context:primary-signature "$artifact"
fi
diff --git a/apps/desktop/scripts/verify-macos-bundle.test.mjs b/apps/desktop/scripts/verify-macos-bundle.test.mjs
new file mode 100644
index 000000000..bf46c36e5
--- /dev/null
+++ b/apps/desktop/scripts/verify-macos-bundle.test.mjs
@@ -0,0 +1,164 @@
+import assert from "node:assert/strict";
+import { execFileSync, spawnSync } from "node:child_process";
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+import { test as nodeTest } from "node:test";
+import { fileURLToPath } from "node:url";
+
+const script = join(dirname(fileURLToPath(import.meta.url)), "verify-macos-bundle.sh");
+const test = process.platform === "win32" ? nodeTest.skip : nodeTest;
+// Exercise macOS's system Bash 3.2 locally; Linux CI also runs these tests.
+const bash = process.platform === "darwin" ? "/bin/bash" : "bash";
+
+function fixture(t, arch = "x86_64") {
+ const root = mkdtempSync(join(tmpdir(), "macos-verification-"));
+ t.after(() => rmSync(root, { recursive: true, force: true }));
+ const bin = join(root, "bin");
+ const source = join(root, "source");
+ const app = join(source, "DEEIX Chat.app");
+ const dmg = join(root, "DEEIX Chat.dmg");
+ const archive = join(root, "DEEIX Chat.app.tar.gz");
+ const log = join(root, "calls.jsonl");
+ const temp = join(root, "temp");
+ mkdirSync(bin);
+ mkdirSync(temp);
+ mkdirSync(join(app, "Contents", "MacOS"), { recursive: true });
+ writeFileSync(join(app, "Contents", "Info.plist"), JSON.stringify({ LSMinimumSystemVersion: "12.0", CFBundleExecutable: "app" }));
+ for (const name of ["app", "deeix-chat-server"]) writeFileSync(join(app, "Contents", "MacOS", name), "fixture");
+ writeFileSync(dmg, "fixture");
+ execFileSync("tar", ["-czf", archive, "-C", source, "DEEIX Chat.app"]);
+ const stub = `#!${process.execPath}
+const fs = require('node:fs');
+const path = require('node:path');
+const tool = path.basename(process.argv[1]);
+const args = process.argv.slice(2);
+const env = process.env;
+const file = args.at(-1);
+const kind = file?.endsWith('.dmg') ? 'dmg' : 'app';
+fs.appendFileSync(env.TEST_LOG, JSON.stringify({ tool, args }) + '\\n');
+switch (tool) {
+ case 'codesign':
+ if (args.includes('-dv')) {
+ // Real-world regression: the DMG has a valid certificate but no
+ // TeamIdentifier in its CodeDirectory. Apps still expose that field.
+ console.error('TeamIdentifier=' + (kind === 'dmg' ? 'not set' : env.TEST_APP_TEAM));
+ } else if (args.includes('--verify')) {
+ if (env.TEST_BAD_SIGNATURE === kind) process.exit(1);
+ if (args.includes('--test-requirement')) {
+ const requirement = args[args.indexOf('--test-requirement') + 1];
+ for (const term of ['=anchor apple generic',
+ 'certificate 1[field.1.2.840.113635.100.6.2.6] exists',
+ 'certificate leaf[field.1.2.840.113635.100.6.1.13] exists']) {
+ if (!requirement.includes(term)) throw new Error('missing Developer ID constraint: ' + term);
+ }
+ if (!requirement.includes('certificate leaf[subject.OU] = "' + env.TEST_DMG_TEAM + '"')
+ || env.TEST_BAD_DMG_CERTIFICATE) process.exit(3);
+ }
+ } else throw new Error('unexpected codesign operation');
+ break;
+ case 'plutil': console.log(JSON.parse(fs.readFileSync(file, 'utf8'))[args[args.indexOf('-extract') + 1]]); break;
+ case 'lipo': console.log(file.endsWith('/deeix-chat-server') ? env.TEST_SIDECAR_ARCH || env.TEST_ARCH : env.TEST_ARCH); break;
+ case 'vtool': console.log(' minos ' + (file.endsWith('/deeix-chat-server') ? env.TEST_SIDECAR_MINIMUM || '12.0' : '12.0')); break;
+ case 'xcrun':
+ if (env.TEST_MISSING_TICKET === kind) { console.error('missing notarization ticket'); process.exit(65); }
+ break;
+ case 'spctl':
+ if (env.TEST_GATEKEEPER_REJECT === kind) { console.error('rejected'); process.exit(3); }
+ console.error('accepted\\nsource=Notarized Developer ID');
+ break;
+ case 'hdiutil':
+ if (args[0] === 'attach') fs.cpSync(env.TEST_SOURCE_APP, path.join(args[args.indexOf('-mountpoint') + 1], 'DEEIX Chat.app'), { recursive: true });
+ else if (args[0] === 'detach') fs.rmSync(path.join(file, 'DEEIX Chat.app'), { recursive: true });
+ else throw new Error('unexpected disk image operation');
+ break;
+ default: throw new Error('unexpected tool: ' + tool);
+}
+`;
+ for (const tool of ["codesign", "plutil", "lipo", "vtool", "hdiutil", "xcrun", "spctl"]) {
+ writeFileSync(join(bin, tool), stub, { mode: 0o755 });
+ }
+ const env = { ...process.env, PATH: `${bin}:${process.env.PATH}`, TMPDIR: temp,
+ APPLE_TEAM_ID: "ABCDE12345", TEST_APP_TEAM: "ABCDE12345", TEST_DMG_TEAM: "ABCDE12345",
+ TEST_ARCH: arch, TEST_SOURCE_APP: app, TEST_LOG: log };
+ const run = (extra = {}, artifacts = [dmg, archive]) => spawnSync(bash, [script, "--arch", arch, ...artifacts], {
+ env: { ...env, ...extra }, encoding: "utf8",
+ });
+ const calls = () => existsSync(log) ? readFileSync(log, "utf8").trim().split("\n").map(JSON.parse) : [];
+ return { run, calls, dmg, archive };
+}
+
+test("a Developer ID DMG without TeamIdentifier verifies together with its updater on both architectures", (t) => {
+ for (const arch of ["x86_64", "arm64"]) {
+ const f = fixture(t, arch);
+ const result = f.run();
+ assert.equal(result.status, 0, result.stderr + result.stdout);
+ assert.match(result.stdout, /verified 2 macOS artifact\(s\)/);
+ const calls = f.calls();
+ assert.equal(calls.filter(({ tool }) => tool === "spctl").length, 3, "assess the DMG and both extracted apps");
+ assert.equal(calls.filter(({ tool }) => tool === "xcrun").length, 3, "validate all three stapled tickets");
+ assert.equal(calls.filter(({ tool, args }) => tool === "codesign" && args.includes("-dv") && args.at(-1) === f.dmg).length, 0);
+ assert.ok(calls.some(({ tool, args }) => tool === "codesign" && args.includes("--test-requirement") && args.at(-1) === f.dmg));
+ assert.ok(calls.some(({ tool, args }) => tool === "hdiutil" && args[0] === "detach"));
+ }
+});
+
+test("wrong-team, ad-hoc and non-Developer-ID DMG certificates fail before mounting", (t) => {
+ for (const extra of [
+ { TEST_DMG_TEAM: "OTHER12345" },
+ { TEST_BAD_DMG_CERTIFICATE: "adhoc" },
+ { TEST_BAD_DMG_CERTIFICATE: "development" },
+ ]) {
+ const f = fixture(t);
+ const result = f.run(extra);
+ assert.notEqual(result.status, 0);
+ assert.match(result.stderr, /Developer ID Application certificate for team ABCDE12345/);
+ assert.equal(f.calls().some(({ tool }) => tool === "hdiutil"), false);
+ }
+});
+
+test("the expected Team ID cannot inject a different code requirement", (t) => {
+ const f = fixture(t);
+ const result = f.run({ APPLE_TEAM_ID: 'ABCDE12345" or always' });
+ assert.notEqual(result.status, 0);
+ assert.match(result.stderr, /APPLE_TEAM_ID must contain 10 uppercase letters or digits/);
+ assert.equal(f.calls().length, 0);
+});
+
+test("signature integrity, app team, stapled tickets and Gatekeeper failures still block release", (t) => {
+ for (const [extra, expected] of [
+ [{ TEST_BAD_SIGNATURE: "dmg" }, /Developer ID Application certificate/],
+ [{ TEST_BAD_SIGNATURE: "app" }, undefined],
+ [{ TEST_APP_TEAM: "OTHER12345" }, /signed by team 'OTHER12345'/],
+ [{ TEST_MISSING_TICKET: "dmg" }, /missing notarization ticket/],
+ [{ TEST_MISSING_TICKET: "app" }, /missing notarization ticket/],
+ [{ TEST_GATEKEEPER_REJECT: "dmg" }, /Gatekeeper rejected/],
+ [{ TEST_GATEKEEPER_REJECT: "app" }, /Gatekeeper rejected/],
+ ]) {
+ const f = fixture(t);
+ const result = f.run(extra);
+ assert.notEqual(result.status, 0, JSON.stringify(extra));
+ if (expected) assert.match(result.stderr, expected);
+ }
+});
+
+test("the shipped sidecar must still match the architecture and declared minimum macOS", (t) => {
+ for (const [extra, expected] of [
+ [{ TEST_SIDECAR_ARCH: "arm64" }, /expected x86_64/],
+ [{ TEST_SIDECAR_MINIMUM: "13.0" }, /requires macOS 13.0, but the app declares 12.0/],
+ ]) {
+ const f = fixture(t);
+ const result = f.run(extra);
+ assert.notEqual(result.status, 0);
+ assert.match(result.stderr, expected);
+ }
+});
+
+test("ad-hoc builds still check the shipped apps without requiring Developer ID credentials", (t) => {
+ const f = fixture(t);
+ const result = f.run({ APPLE_TEAM_ID: "" });
+ assert.equal(result.status, 0, result.stderr + result.stdout);
+ const calls = f.calls();
+ assert.equal(calls.some(({ tool }) => ["xcrun", "spctl"].includes(tool)), false);
+ assert.equal(calls.filter(({ tool, args }) => tool === "codesign" && args.includes("--verify")).length, 2);
+});
diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock
index 2fe9073e4..1a49d17cc 100644
--- a/apps/desktop/src-tauri/Cargo.lock
+++ b/apps/desktop/src-tauri/Cargo.lock
@@ -69,15 +69,6 @@ dependencies = [
"security-framework",
]
-[[package]]
-name = "arbitrary"
-version = "1.4.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
-dependencies = [
- "derive_arbitrary",
-]
-
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -804,7 +795,7 @@ dependencies = [
[[package]]
name = "deeix-chat-desktop"
-version = "0.4.5"
+version = "0.4.6"
dependencies = [
"dunce",
"gtk",
@@ -869,17 +860,6 @@ dependencies = [
"serde_core",
]
-[[package]]
-name = "derive_arbitrary"
-version = "1.4.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.119",
-]
-
[[package]]
name = "derive_more"
version = "2.1.1"
@@ -3479,9 +3459,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
-version = "0.103.15"
+version = "0.103.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
+checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
dependencies = [
"ring",
"rustls-pki-types",
@@ -4422,7 +4402,6 @@ dependencies = [
"tokio",
"url",
"windows-sys 0.60.2",
- "zip",
]
[[package]]
@@ -5244,9 +5223,9 @@ dependencies = [
[[package]]
name = "webpki-root-certs"
-version = "1.0.9"
+version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
+checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb"
dependencies = [
"rustls-pki-types",
]
@@ -6101,18 +6080,6 @@ dependencies = [
"syn 3.0.6",
]
-[[package]]
-name = "zip"
-version = "4.6.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
-dependencies = [
- "arbitrary",
- "crc32fast",
- "indexmap 2.14.2",
- "memchr",
-]
-
[[package]]
name = "zlib-rs"
version = "0.6.8"
diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml
index 07a23981b..5657fd968 100644
--- a/apps/desktop/src-tauri/Cargo.toml
+++ b/apps/desktop/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "deeix-chat-desktop"
-version = "0.4.5"
+version = "0.4.6"
description = "DEEIX Chat desktop shell"
authors = ["DEEIX"]
edition = "2021"
@@ -17,7 +17,11 @@ tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon", "image-png", "unstable"] }
-tauri-plugin-updater = "2"
+# Without the default `zip` feature: it only unpacks Windows `.zip` and Linux
+# `.AppImage.tar.gz` updates, and releases ship `.exe` / `.msi` / bare `.AppImage`
+# (macOS unpacks its `.app.tar.gz` regardless). It also pulls in `zip` 4, which has
+# extraction advisories (RUSTSEC-2026-0367/0368/0369) and no fixed 4.x release.
+tauri-plugin-updater = { version = "2", default-features = false, features = ["rustls-tls", "system-proxy"] }
tauri-plugin-process = "2"
tauri-plugin-opener = "2"
# OS credential store: macOS Keychain, Windows Credential Manager, Linux Secret Service.
diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json
index be1b748ad..b269fe6d4 100644
--- a/apps/desktop/src-tauri/tauri.conf.json
+++ b/apps/desktop/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "DEEIX Chat",
- "version": "0.4.5",
+ "version": "0.4.6",
"identifier": "com.deeix.chat.desktop",
"build": {
"frontendDist": "../../web/out",
diff --git a/apps/web/features/admin/components/sections/logs/admin-logs.tsx b/apps/web/features/admin/components/sections/logs/admin-logs.tsx
index c5e3bbc06..d46123fd2 100644
--- a/apps/web/features/admin/components/sections/logs/admin-logs.tsx
+++ b/apps/web/features/admin/components/sections/logs/admin-logs.tsx
@@ -953,7 +953,12 @@ export function AdminLogsPage() {
-
+ {/* Seven tabs outgrow a phone and the admin shell clips horizontal overflow,
+ so the list scrolls on its own (scrollbar hidden, as in the admin nav). */}
+
{visibleTabs.map((tab) => (
{t(`tabs.${tab.id}`)}
))}
diff --git a/apps/web/features/admin/components/sections/upstreams/upstreams-models-dialog.tsx b/apps/web/features/admin/components/sections/upstreams/upstreams-models-dialog.tsx
index b8d59da29..c5c5dc462 100644
--- a/apps/web/features/admin/components/sections/upstreams/upstreams-models-dialog.tsx
+++ b/apps/web/features/admin/components/sections/upstreams/upstreams-models-dialog.tsx
@@ -204,7 +204,7 @@ function ProtocolsDropdown({
}
return (
-
+
-
+
@@ -827,19 +827,26 @@ export function AgentToolStepRow({
step,
labels,
autoExpand,
+ messageStreaming,
runID,
allowFullResult = false,
}: {
step: ToolChainStep;
labels: ProcessTraceLabels;
autoExpand: boolean;
+ messageStreaming: boolean;
runID?: string;
allowFullResult?: boolean;
}) {
- const active = isToolChainStepActive(step);
+ // A finished reply can still carry a step whose last saved status is "calling" (its final
+ // write was lost); counting from startedAt would then show days. Only a live reply runs.
+ const reportedActive = isToolChainStepActive(step);
+ const active = messageStreaming && reportedActive;
const { open, onOpenChange } = useAutoExpandDisclosure({ active, autoExpand });
const failed = step.failed;
- const statusText = isToolStepDone(step) ? "" : toolStatusLabel(step.toolCall?.status ?? step.toolStatus, labels);
+ const statusText = isToolStepDone(step) || (reportedActive && !active)
+ ? ""
+ : toolStatusLabel(step.toolCall?.status ?? step.toolStatus, labels);
const expandable = Boolean(step.toolCall || step.detail);
const liveDurationMS = useChatElapsedDurationMS(active, step.startedAt);
const durationText = formatDurationMS(active ? liveDurationMS : step.latencyMS);
@@ -847,7 +854,7 @@ export function AgentToolStepRow({
return (
}
+ icon={ }
title={labels.tool.names.generic}
status={statusText || undefined}
duration={durationText}
diff --git a/apps/web/features/chat/components/message/message-agent-trace.tsx b/apps/web/features/chat/components/message/message-agent-trace.tsx
index f8dd6f5fd..2bc575bff 100644
--- a/apps/web/features/chat/components/message/message-agent-trace.tsx
+++ b/apps/web/features/chat/components/message/message-agent-trace.tsx
@@ -315,6 +315,7 @@ function AgentTraceTimeline({
labels,
autoExpandThinking,
autoExpandToolCalls,
+ messageStreaming,
runID,
allowFullToolResults,
}: {
@@ -322,6 +323,7 @@ function AgentTraceTimeline({
labels: ProcessTraceLabels;
autoExpandThinking: boolean;
autoExpandToolCalls: boolean;
+ messageStreaming: boolean;
runID?: string;
allowFullToolResults: boolean;
}) {
@@ -345,6 +347,7 @@ function AgentTraceTimeline({
step={item.step}
labels={labels}
autoExpand={autoExpandToolCalls}
+ messageStreaming={messageStreaming}
runID={runID}
allowFullResult={allowFullToolResults}
/>
@@ -543,6 +546,7 @@ export function MessageAgentTrace({
labels={labels}
autoExpandThinking={autoExpandThinking}
autoExpandToolCalls={autoExpandToolCalls}
+ messageStreaming={messageStreaming}
runID={runID}
allowFullToolResults={allowFullToolResults}
/>
diff --git a/apps/web/package.json b/apps/web/package.json
index 7e745a653..79e4e1656 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -1,6 +1,6 @@
{
"name": "@deeix/web",
- "version": "0.4.5",
+ "version": "0.4.6",
"private": true,
"sideEffects": [
"*.css",
@@ -93,7 +93,7 @@
"@biomejs/biome": "2.5.11",
"@deeix/api-contract": "workspace:*",
"@tailwindcss/postcss": "^4.3.3",
- "@types/node": "^25.6.0",
+ "@types/node": "^26.6.5",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.5",
"@types/turndown": "^5.0.6",
diff --git a/backend/README.md b/backend/README.md
index 253e8bcad..0616254b3 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -4,7 +4,7 @@ DEEIX Chat 后端是 Go API 服务,负责认证、用户、对话、模型渠
## 技术栈
-- Go 1.26.8
+- Go 1.26.10
- Gin
- Gorm
- PostgreSQL + pgvector 或 SQLite + sqlite-vec
diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go
index 9117414be..d4de311c2 100644
--- a/backend/cmd/server/main.go
+++ b/backend/cmd/server/main.go
@@ -13,7 +13,7 @@ import (
)
// @title DEEIX Chat API
-// @version 0.4.5
+// @version 0.4.6
// @description DEEIX Chat 后端 API 文档
// @BasePath /api/v1
// @securityDefinitions.apikey BearerAuth
diff --git a/backend/docs/docs.go b/backend/docs/docs.go
index f44943a0d..4e0ed54f4 100644
--- a/backend/docs/docs.go
+++ b/backend/docs/docs.go
@@ -35322,7 +35322,7 @@ const docTemplate = `{
// SwaggerInfo holds exported Swagger Info so clients can modify it
var SwaggerInfo = &swag.Spec{
- Version: "0.4.5",
+ Version: "0.4.6",
Host: "",
BasePath: "/api/v1",
Schemes: []string{},
diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json
index f3de77728..17f5622c6 100644
--- a/backend/docs/swagger.json
+++ b/backend/docs/swagger.json
@@ -4,7 +4,7 @@
"description": "DEEIX Chat 后端 API 文档",
"title": "DEEIX Chat API",
"contact": {},
- "version": "0.4.5"
+ "version": "0.4.6"
},
"basePath": "/api/v1",
"paths": {
diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml
index b3319ceec..57ce68b53 100644
--- a/backend/docs/swagger.yaml
+++ b/backend/docs/swagger.yaml
@@ -12091,7 +12091,7 @@ info:
contact: {}
description: DEEIX Chat 后端 API 文档
title: DEEIX Chat API
- version: "0.4.5"
+ version: "0.4.6"
paths:
/admin/announcements:
get:
diff --git a/backend/go.mod b/backend/go.mod
index 6f7d9f9d0..2d011772a 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -1,6 +1,6 @@
module github.com/DEEIX-AI/DEEIX-Chat/backend
-go 1.26.8
+go 1.26.10
require (
github.com/asg017/sqlite-vec-go-bindings v0.1.6
@@ -35,7 +35,7 @@ require (
go.uber.org/zap v1.28.0
golang.org/x/crypto v0.57.0
golang.org/x/image v0.45.0
- golang.org/x/net v0.59.0
+ golang.org/x/net v0.60.0
golang.org/x/sync v0.23.0
golang.org/x/text v0.42.0
gopkg.in/yaml.v3 v3.0.1
diff --git a/backend/go.sum b/backend/go.sum
index e42091aef..457c5cc36 100644
--- a/backend/go.sum
+++ b/backend/go.sum
@@ -348,8 +348,8 @@ golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
-golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
-golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
+golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
+golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
diff --git a/backend/internal/application/conversation/service_media_generation_test.go b/backend/internal/application/conversation/service_media_generation_test.go
index f78074920..044f13ef3 100644
--- a/backend/internal/application/conversation/service_media_generation_test.go
+++ b/backend/internal/application/conversation/service_media_generation_test.go
@@ -2,13 +2,30 @@ package conversation
import (
"bytes"
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
"image"
"image/color"
"image/jpeg"
"image/png"
+ "io"
+ "net/http"
+ "net/http/httptest"
"testing"
+ "time"
+ "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/channel"
+ appupload "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/upload"
+ model "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/conversation"
+ "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/config"
+ infrallm "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/llm"
+ "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/objectstorage"
"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/ports/llm"
+ "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository"
+ "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/shared/security"
)
func TestDetectGeneratedImageMIMERejectsNonImageBytes(t *testing.T) {
@@ -140,3 +157,170 @@ func TestMediaImageStreamEnabledUsesModelCapabilitiesOverride(t *testing.T) {
})
}
}
+
+type mediaImageResultRepository struct {
+ *conversationRunClaimRepositoryStub
+ source model.FileObject
+ attachments []model.Attachment
+}
+
+func (r *mediaImageResultRepository) UpdateConversationModel(_ context.Context, _ uint, name string, provider string) error {
+ r.conversation.Model = name
+ r.conversation.Provider = provider
+ return nil
+}
+
+func (r *mediaImageResultRepository) CompleteAssistantMessageWithAttachments(_ context.Context, _ uint, _ repository.MessageUsageUpdate, _ uint, _ repository.AssistantMessageCompletionUpdate, attachments []model.Attachment) error {
+ r.attachments = attachments
+ return nil
+}
+
+func (*mediaImageResultRepository) UpdateMessageState(context.Context, uint, string, string, string) error {
+ return nil
+}
+
+func (*mediaImageResultRepository) GetUserSettingValue(context.Context, uint, string) (string, error) {
+ return "", nil
+}
+
+func (r *mediaImageResultRepository) GetActiveFileObjectsByIDs(context.Context, uint, []string) ([]model.FileObject, error) {
+ return []model.FileObject{r.source}, nil
+}
+
+type mediaImageResultUploadRepository struct {
+ generatedImageUploadRepository
+ source model.FileObject
+}
+
+func (r mediaImageResultUploadRepository) GetActiveFileObjectByID(context.Context, uint, string) (*model.FileObject, error) {
+ return &r.source, nil
+}
+
+func (mediaImageResultUploadRepository) TouchFileObjectLastAccessedAt(context.Context, uint, string, time.Time) error {
+ return nil
+}
+
+// 上游在同一条目里同时返回 b64_json 与 url 时,图片任务直接保存内联字节,不下载 url:
+// 下载可能超时或被重置,不能让已经拿到结果的生成因此失败。生成与编辑、JSON 与 SSE 都覆盖。
+func TestStreamMediaImageSavesInlineBase64WithoutDownloading(t *testing.T) {
+ var encoded bytes.Buffer
+ if err := png.Encode(&encoded, image.NewRGBA(image.Rect(0, 0, 2, 2))); err != nil {
+ t.Fatal(err)
+ }
+ b64 := base64.StdEncoding.EncodeToString(encoded.Bytes())
+ for _, task := range []MediaImageTaskType{MediaImageTaskGeneration, MediaImageTaskEdit} {
+ for _, format := range []string{"json", "sse", "stream json"} {
+ t.Run(string(task)+"/"+format, func(t *testing.T) {
+ var responseEntry map[string]string
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if task == MediaImageTaskEdit && r.URL.Path != "/v1/images/edits" {
+ t.Error("edit task used the wrong endpoint")
+ }
+ body, _ := json.Marshal(map[string]any{"data": []any{responseEntry}})
+ if format == "sse" {
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = fmt.Fprintf(w, "event: image_generation.completed\ndata: %s\n\n", body)
+ } else {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write(body)
+ }
+ }))
+ defer server.Close()
+ store := objectstorage.NewLocal(t.TempDir())
+ if _, err := store.Put(t.Context(), "source.png", bytes.NewReader(encoded.Bytes()), objectstorage.PutOptions{ContentType: "image/png"}); err != nil {
+ t.Fatal(err)
+ }
+ source := model.FileObject{FileID: "source", UserID: 1, FileName: "source.png", MimeType: "image/png", DetectedMIME: "image/png", StoragePath: "source.png", SizeBytes: int64(encoded.Len())}
+ repo := &mediaImageResultRepository{
+ conversationRunClaimRepositoryStub: &conversationRunClaimRepositoryStub{
+ runs: make(map[string]model.Run), conversation: model.Conversation{ID: 11, UserID: 1, PublicID: "conversation-11"},
+ },
+ source: source,
+ }
+ runtimeCfg := config.NewRuntime(config.Config{})
+ uploadSvc := appupload.NewServiceWithRuntime(runtimeCfg, mediaImageResultUploadRepository{source: source}, nil, appupload.Hooks{}, appupload.ErrorSet{}, "test")
+ uploadSvc.SetObjectStoreProvider(&conversationTestStoreProvider{store: store})
+ client := infrallm.NewClient(security.OutboundPolicy{})
+ defer client.CloseIdleConnections()
+ protocol := llm.AdapterOpenAIImageGenerations
+ if task == MediaImageTaskEdit {
+ protocol = llm.AdapterOpenAIImageEdits
+ }
+ resolver := &textTaskRouteResolverStub{routes: make(map[string]*channel.ResolvedRoute)}
+ registry := newGenerationStreamRegistry(nil, generationStreamOptions{})
+ defer registry.close()
+ service := &Service{cfg: runtimeCfg, repo: repo, uploadSvc: uploadSvc, llmClient: client, routeResolver: resolver, generationStreams: registry}
+ // 非流式用 image.stream=false 关掉流式;其余两种按协议默认走流式。
+ caps := `{}`
+ if format == "json" {
+ caps = `{"image":{"stream":false}}`
+ }
+ for index, test := range []struct {
+ name string
+ entry map[string]string
+ downloadFails bool
+ wantDownloads int
+ wantStage string
+ }{
+ {"both", map[string]string{"b64_json": b64, "url": "https://images.example.test/unavailable"}, true, 0, ""},
+ {"url only", map[string]string{"url": "https://images.example.test/result.png"}, false, 1, ""},
+ {"base64 only", map[string]string{"b64_json": b64}, true, 0, ""},
+ {"blank base64", map[string]string{"b64_json": " \t", "url": "https://images.example.test/result.png"}, false, 1, ""},
+ // 非空但无效的字节明确报错,不悄悄改为下载 url。
+ {"invalid base64", map[string]string{"b64_json": "invalid!", "url": "https://images.example.test/result.png"}, false, 0, "decode"},
+ {"not an image", map[string]string{"b64_json": base64.StdEncoding.EncodeToString([]byte("not an image")), "url": "https://images.example.test/result.png"}, false, 0, "validation"},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ responseEntry = test.entry
+ name := fmt.Sprintf("image-model-%d", index)
+ resolver.routes[name] = &channel.ResolvedRoute{PlatformModelName: name, UpstreamID: uint(index + 1), Protocol: protocol, BaseURL: server.URL, UpstreamModel: "gpt-image-1", ModelCapabilitiesJSON: caps}
+ downloads := 0
+ service.mediaDownloader = generatedMediaDownloaderStub{downloadImage: func(context.Context, string, string, int64) ([]byte, string, error) {
+ downloads++
+ if test.downloadFails {
+ return nil, "", errors.New("download unavailable")
+ }
+ return encoded.Bytes(), "image/png", nil
+ }}
+ repo.attachments = nil
+ input := MediaImageInput{UserID: 1, ConversationID: 11, TaskType: task, PlatformModelName: name, Prompt: "draw", ClientRunID: fmt.Sprintf("run-%d", index)}
+ if task == MediaImageTaskEdit {
+ input.FileIDs = []string{"source"}
+ }
+ ctx, release, ok := registry.acquireRun(t.Context())
+ if !ok {
+ t.Fatal("acquire generation lifecycle")
+ }
+ defer release()
+ defer registry.finish(ctx, input.ClientRunID)
+ result, err := service.StreamMediaImage(ctx, input)
+ if downloads != test.wantDownloads {
+ t.Fatalf("downloads = %d, want %d", downloads, test.wantDownloads)
+ }
+ if test.wantStage != "" {
+ if !errors.Is(err, ErrGeneratedMediaArtifactUnavailable) || generatedMediaArtifactFailureDetails(err).stage != test.wantStage {
+ t.Fatalf("unexpected failure stage: %v", err)
+ }
+ if len(repo.attachments) != 0 {
+ t.Fatal("invalid result was saved")
+ }
+ return
+ }
+ if err != nil || result == nil || result.AssistantMessage.Status != "success" || len(repo.attachments) != 1 {
+ t.Fatalf("image task did not save successfully: %v", err)
+ }
+ reader, _, err := store.Open(t.Context(), repo.attachments[0].StoragePath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer reader.Close() //nolint:errcheck
+ data, err := io.ReadAll(reader)
+ if err != nil || !bytes.Equal(data, encoded.Bytes()) || repo.attachments[0].MimeType != "image/png" {
+ t.Fatal("saved image bytes or MIME did not match")
+ }
+ })
+ }
+ })
+ }
+ }
+}
diff --git a/backend/internal/infra/llm/openai_images.go b/backend/internal/infra/llm/openai_images.go
index 4bb727bae..fc3b30eaf 100644
--- a/backend/internal/infra/llm/openai_images.go
+++ b/backend/internal/infra/llm/openai_images.go
@@ -706,15 +706,13 @@ func parseOpenAIImagePayload(payload map[string]any, outputFormat string) (portl
if declared := declaredImageMediaType(payload); declared != "" {
mimeType = declared
}
- if url := strings.TrimSpace(getString(payload["url"])); url != "" {
+ // 兼容网关可能在同一条目里同时返回 url 与 b64_json:两者都保留。下游先用内联字节,
+ // 只在没有字节时才下载 url,避免一次多余且可能失败的下载让已经成功的生成失败。
+ url := strings.TrimSpace(getString(payload["url"]))
+ b64 := strings.TrimSpace(getString(payload["b64_json"]))
+ if url != "" || b64 != "" {
return portllm.GeneratedImage{
URL: url,
- MIMEType: mimeType,
- RevisedPrompt: strings.TrimSpace(getString(payload["revised_prompt"])),
- }, true
- }
- if b64 := strings.TrimSpace(getString(payload["b64_json"])); b64 != "" {
- return portllm.GeneratedImage{
B64JSON: b64,
MIMEType: mimeType,
RevisedPrompt: strings.TrimSpace(getString(payload["revised_prompt"])),
diff --git a/backend/internal/infra/llm/openai_images_test.go b/backend/internal/infra/llm/openai_images_test.go
index fa6d61fe0..4bf732205 100644
--- a/backend/internal/infra/llm/openai_images_test.go
+++ b/backend/internal/infra/llm/openai_images_test.go
@@ -4,8 +4,10 @@ import (
"bytes"
"context"
"encoding/json"
+ "fmt"
"net/http"
"net/http/httptest"
+ "strings"
"testing"
portllm "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/ports/llm"
@@ -306,6 +308,155 @@ func TestParseOpenAIImageGenerationOutputDoesNotInventUsage(t *testing.T) {
}
}
+// 兼容网关可能在同一条目里同时返回 url 与 b64_json:两者都要保留,下游才能直接用内联字节,
+// 不必再下载 url。JSON、SSE 与流式请求收到的普通 JSON 走同一套解析。
+func TestOpenAIImageResultKeepsURLAndBase64(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ payload string
+ wantURL bool
+ wantB64 bool
+ }{
+ {"both", `{"url":"https://images.example.test/result.png","b64_json":"aW1hZ2U="}`, true, true},
+ {"url only", `{"url":"https://images.example.test/result.png"}`, true, false},
+ {"base64 only", `{"b64_json":"aW1hZ2U="}`, false, true},
+ {"blank base64", `{"url":"https://images.example.test/result.png","b64_json":" \t"}`, true, false},
+ // 无效的 Base64 原样保留,由下游解码时报错,不在解析阶段悄悄换成下载。
+ {"invalid base64 is retained", `{"url":"https://images.example.test/result.png","b64_json":"invalid!"}`, true, true},
+ } {
+ for _, format := range []string{"json", "sse", "stream json"} {
+ t.Run(test.name+"/"+format, func(t *testing.T) {
+ body := `{"response":{"data":[{"image":` + test.payload + `,"revised_prompt":"revised"}]}}`
+ var output *portllm.GenerateOutput
+ var err error
+ if format == "json" {
+ output, err = parseOpenAIImageOutput([]byte(body), "webp")
+ } else {
+ output = &portllm.GenerateOutput{}
+ if format == "sse" {
+ body = "event: image_generation.completed\ndata: " + body + "\n\n"
+ }
+ err = consumeOpenAIImageStream(strings.NewReader(body), "webp", output, nil)
+ }
+ if err != nil || len(output.GeneratedImages) != 1 {
+ t.Fatalf("parse result: err=%v", err)
+ }
+ image := output.GeneratedImages[0]
+ if (image.URL != "") != test.wantURL || (image.B64JSON != "") != test.wantB64 {
+ t.Fatalf("unexpected image sources: URL=%t base64=%t", image.URL != "", image.B64JSON != "")
+ }
+ if image.MIMEType != "image/webp" || image.RevisedPrompt != "revised" {
+ t.Fatal("image metadata was not preserved")
+ }
+ })
+ }
+ }
+}
+
+// Responses 的内嵌生图共用同一个解析函数,同样两者都保留。
+func TestResponsesImageResultKeepsURLAndBase64(t *testing.T) {
+ image, ok := parseResponsesGeneratedImage(map[string]any{
+ "type": "image_generation_call",
+ "result": map[string]any{
+ "url": "https://images.example.test/result.png",
+ "b64_json": "aW1hZ2U=",
+ },
+ })
+ if !ok || image.URL == "" || image.B64JSON == "" {
+ t.Fatalf("expected both image sources, got URL=%t base64=%t", image.URL != "", image.B64JSON != "")
+ }
+}
+
+// 每个图片适配器在非流式、SSE 与流式请求收到普通 JSON 时,最终图与流式中间图都带上内联字节,
+// 且不改动请求的 response_format 与 stream。
+func TestImageAdaptersKeepInlineImageBytes(t *testing.T) {
+ for _, protocol := range []string{
+ portllm.AdapterOpenAIImageGenerations, portllm.AdapterOpenAIImageEdits,
+ portllm.AdapterOpenRouterImages, portllm.AdapterXAIImage, portllm.AdapterXAIImageEdits,
+ } {
+ for _, stream := range []bool{false, true} {
+ if stream && !portllm.SupportsImageGenerationStream(protocol, "gpt-image-1") {
+ continue
+ }
+ for _, jsonFallback := range []bool{false, true} {
+ if !stream && jsonFallback {
+ continue
+ }
+ t.Run(fmt.Sprintf("%s/stream=%t/json=%t", protocol, stream, jsonFallback), func(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/") {
+ if err := r.ParseMultipartForm(1 << 20); err != nil {
+ t.Errorf("parse multipart: %v", err)
+ return
+ }
+ defer r.MultipartForm.RemoveAll() //nolint:errcheck
+ if r.FormValue("response_format") != "" {
+ t.Error("request response_format changed")
+ }
+ if (r.FormValue("stream") == "true") != stream {
+ t.Error("request stream changed")
+ }
+ } else {
+ var payload map[string]any
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ t.Errorf("decode request: %v", err)
+ return
+ }
+ if _, ok := payload["response_format"]; ok {
+ t.Error("request response_format changed")
+ }
+ if (payload["stream"] == true) != stream {
+ t.Error("request stream changed")
+ }
+ }
+ entry := `{"url":"https://images.example.test/unavailable","b64_json":"aW1hZ2U="}`
+ if stream && !jsonFallback {
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = fmt.Fprintf(w, "event: image_generation.partial_image\ndata: %s\n\nevent: image_generation.completed\ndata: %s\n\n", entry, entry)
+ } else {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = fmt.Fprintf(w, `{"data":[%s]}`, entry)
+ }
+ }))
+ defer server.Close()
+ client := newTestClient()
+ defer client.CloseIdleConnections()
+ input := portllm.GenerateInput{Messages: []portllm.Message{{Role: "user", Parts: []portllm.ContentPart{
+ {Kind: portllm.ContentPartText, Text: "draw"},
+ {Kind: portllm.ContentPartImage, MimeType: "image/png", Data: []byte("source")},
+ }}}}
+ route := portllm.RouteConfig{Protocol: protocol, BaseURL: server.URL, UpstreamModel: "gpt-image-1"}
+ var output *portllm.GenerateOutput
+ var err error
+ partials := 0
+ if stream {
+ output, err = client.GenerateStream(t.Context(), route, input, func(event portllm.GenerateStreamEvent) error {
+ if event.GeneratedImage != nil {
+ partials++
+ if event.GeneratedImage.B64JSON == "" {
+ t.Error("partial image dropped its inline bytes")
+ }
+ }
+ return nil
+ })
+ } else {
+ output, err = client.Generate(t.Context(), route, input)
+ }
+ if err != nil || len(output.GeneratedImages) != 1 {
+ t.Fatalf("generate: err=%v", err)
+ }
+ if image := output.GeneratedImages[0]; image.B64JSON == "" || image.URL == "" {
+ t.Fatalf("expected both image sources, got URL=%t base64=%t", image.URL != "", image.B64JSON != "")
+ }
+ if stream && !jsonFallback && partials != 1 {
+ t.Fatalf("partial image count = %d", partials)
+ }
+ })
+ }
+ }
+ }
+}
+
func TestOpenAIImageGenerationStream(t *testing.T) {
var requestPayload map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
diff --git a/backend/internal/infra/llm/xai_images.go b/backend/internal/infra/llm/xai_images.go
index 18ceeba89..1a89be75b 100644
--- a/backend/internal/infra/llm/xai_images.go
+++ b/backend/internal/infra/llm/xai_images.go
@@ -293,28 +293,21 @@ func parseXAIImagePayload(payload map[string]any) (portllm.GeneratedImage, bool)
if revisedPrompt == "" {
revisedPrompt = strings.TrimSpace(getString(payload["revisedPrompt"]))
}
- if url := strings.TrimSpace(getString(payload["url"])); url != "" {
- return portllm.GeneratedImage{
- URL: url,
- MIMEType: mimeType,
- RevisedPrompt: revisedPrompt,
- }, true
- }
- if b64 := strings.TrimSpace(getString(payload["b64_json"])); b64 != "" {
- return portllm.GeneratedImage{
- B64JSON: b64,
- MIMEType: mimeType,
- RevisedPrompt: revisedPrompt,
- }, true
- }
- if publicURL := strings.TrimSpace(getString(asMap(payload["file_output"])["public_url"])); publicURL != "" {
- return portllm.GeneratedImage{
- URL: publicURL,
- MIMEType: mimeType,
- RevisedPrompt: revisedPrompt,
- }, true
- }
- return portllm.GeneratedImage{}, false
+ // url 与 b64_json 同时出现时两者都保留,下游先用内联字节,见 parseOpenAIImagePayload。
+ url := strings.TrimSpace(getString(payload["url"]))
+ if url == "" {
+ url = strings.TrimSpace(getString(asMap(payload["file_output"])["public_url"]))
+ }
+ b64 := strings.TrimSpace(getString(payload["b64_json"]))
+ if url == "" && b64 == "" {
+ return portllm.GeneratedImage{}, false
+ }
+ return portllm.GeneratedImage{
+ URL: url,
+ B64JSON: b64,
+ MIMEType: mimeType,
+ RevisedPrompt: revisedPrompt,
+ }, true
}
// xAIImageMIMEType 优先采用官方响应中的 MIME;旧代理未返回时回退为 JPEG。
diff --git a/backend/internal/infra/llm/xai_images_test.go b/backend/internal/infra/llm/xai_images_test.go
index 388ec3eaf..230167755 100644
--- a/backend/internal/infra/llm/xai_images_test.go
+++ b/backend/internal/infra/llm/xai_images_test.go
@@ -299,3 +299,25 @@ func TestParseXAIImageOutput(t *testing.T) {
t.Fatalf("expected URL citation, got %#v", output.Citations)
}
}
+
+// url(或 file_output.public_url)与 b64_json 同时出现时两者都保留,下游先用内联字节。
+func TestParseXAIImageOutputKeepsURLAndBase64Together(t *testing.T) {
+ output, err := parseXAIImageOutput([]byte(`{
+ "data": [
+ {"url": "https://example.com/a.jpg", "b64_json": "aGVsbG8="},
+ {"file_output": {"public_url": "https://example.com/b.jpg"}, "b64_json": "d29ybGQ="}
+ ]
+ }`), portllm.AdapterXAIImage)
+ if err != nil {
+ t.Fatalf("parse xAI image output: %v", err)
+ }
+ if len(output.GeneratedImages) != 2 {
+ t.Fatalf("expected two generated images, got %#v", output.GeneratedImages)
+ }
+ if image := output.GeneratedImages[0]; image.URL != "https://example.com/a.jpg" || image.B64JSON != "aGVsbG8=" {
+ t.Fatalf("expected both the URL and the inline bytes, got %#v", image)
+ }
+ if image := output.GeneratedImages[1]; image.URL != "https://example.com/b.jpg" || image.B64JSON != "d29ybGQ=" {
+ t.Fatalf("expected both the public URL and the inline bytes, got %#v", image)
+ }
+}
diff --git a/backend/internal/infra/persistence/postgres/conversation/repository.go b/backend/internal/infra/persistence/postgres/conversation/repository.go
index 11a6db4b7..ba477144f 100644
--- a/backend/internal/infra/persistence/postgres/conversation/repository.go
+++ b/backend/internal/infra/persistence/postgres/conversation/repository.go
@@ -15,6 +15,7 @@ import (
"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/persistence/models"
"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/persistence/sqlitevec"
"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/persistence/vectorutil"
+ "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/pkg/textutil"
"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -4318,9 +4319,9 @@ func toConversationMessageTraceModel(item *domainconversation.MessageTrace) mode
RoundID: item.RoundID,
ParentEventID: item.ParentEventID,
Title: item.Title,
- Summary: item.Summary,
- ContentMarkdown: item.ContentMarkdown,
- PayloadJSON: item.PayloadJSON,
+ Summary: textutil.SanitizeForTextColumn(item.Summary),
+ ContentMarkdown: textutil.SanitizeForTextColumn(item.ContentMarkdown),
+ PayloadJSON: textutil.SanitizeForTextColumn(item.PayloadJSON),
Seq: item.Seq,
StartedAt: startedAt,
EndedAt: item.EndedAt,
@@ -4379,9 +4380,9 @@ func toConversationMessageTraceEventModel(item *domainconversation.MessageTraceE
ParentEventID: item.ParentEventID,
Status: item.Status,
Title: item.Title,
- Summary: item.Summary,
- ContentMarkdown: item.ContentMarkdown,
- PayloadJSON: item.PayloadJSON,
+ Summary: textutil.SanitizeForTextColumn(item.Summary),
+ ContentMarkdown: textutil.SanitizeForTextColumn(item.ContentMarkdown),
+ PayloadJSON: textutil.SanitizeForTextColumn(item.PayloadJSON),
Seq: item.Seq,
StartedAt: startedAt,
EndedAt: item.EndedAt,
@@ -4414,9 +4415,9 @@ func toConversationToolCallModel(item *domainconversation.ToolCall) models.ChatR
MCPServerName: item.MCPServerName,
Status: item.Status,
LatencyMS: item.LatencyMS,
- InputJSON: item.InputJSON,
- OutputJSON: item.OutputJSON,
- ErrorJSON: item.ErrorJSON,
+ InputJSON: textutil.SanitizeForTextColumn(item.InputJSON),
+ OutputJSON: textutil.SanitizeForTextColumn(item.OutputJSON),
+ ErrorJSON: textutil.SanitizeForTextColumn(item.ErrorJSON),
StartedAt: startedAt,
}
}
diff --git a/backend/internal/infra/persistence/postgres/conversation/repository_postgres_integration_test.go b/backend/internal/infra/persistence/postgres/conversation/repository_postgres_integration_test.go
index 3d32330aa..7bd02134c 100644
--- a/backend/internal/infra/persistence/postgres/conversation/repository_postgres_integration_test.go
+++ b/backend/internal/infra/persistence/postgres/conversation/repository_postgres_integration_test.go
@@ -8,6 +8,7 @@ import (
"testing"
"time"
+ domainconversation "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/conversation"
"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/persistence/models"
"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository"
"gorm.io/driver/postgres"
@@ -138,3 +139,61 @@ func openConversationPostgresIntegrationDB(t *testing.T, dsn string) (*gorm.DB,
}
return db, cleanup
}
+
+// 工具抓到二进制内容时,渲染后的轨迹正文会带上 NUL 与非法 UTF-8;PostgreSQL 会拒绝整行,
+// 轨迹就停在「进行中」。写入前必须清理,且后续的终态更新要能覆盖这一行。
+func TestUpsertConversationTraceRowsAcceptBinaryToolOutputOnPostgres(t *testing.T) {
+ dsn := strings.TrimSpace(os.Getenv("DEEIX_TEST_DATABASE_DSN"))
+ if dsn == "" {
+ t.Skip("set DEEIX_TEST_DATABASE_DSN to run PostgreSQL trace persistence integration test")
+ }
+
+ db, cleanup := openConversationPostgresIntegrationDB(t, dsn)
+ t.Cleanup(cleanup)
+ if err := db.AutoMigrate(&models.ChatRunEvent{}); err != nil {
+ t.Fatalf("migrate chat run events: %v", err)
+ }
+ repo := NewRepo(db)
+ ctx := context.Background()
+ binary := "**web_fetch_exa**:已完成\nGIF89a\x00\x02\x00\xff\xfe"
+
+ event := &domainconversation.MessageTraceEventRow{
+ MessageID: 1, ConversationID: 1, UserID: 1, RunID: "run_binary",
+ EventID: "tools_3", EventType: "tool", Phase: "tools", Status: "streaming",
+ ContentMarkdown: "**web_fetch_exa**:进行中", PayloadJSON: "{}", StartedAt: time.Now(),
+ }
+ if err := repo.UpsertConversationMessageTraceEvent(ctx, event); err != nil {
+ t.Fatalf("insert streaming event: %v", err)
+ }
+ endedAt := time.Now()
+ event.Status = "completed"
+ event.ContentMarkdown = binary
+ event.Summary = "1 次工具调用已完成\x00"
+ event.EndedAt = &endedAt
+ if err := repo.UpsertConversationMessageTraceEvent(ctx, event); err != nil {
+ t.Fatalf("complete event with binary tool output: %v", err)
+ }
+ if err := repo.UpsertConversationMessageTrace(ctx, &domainconversation.MessageTrace{
+ MessageID: 1, ConversationID: 1, UserID: 1, RunID: "run_binary", TraceType: "tools",
+ Status: "completed", ContentMarkdown: binary, PayloadJSON: "{}", StartedAt: time.Now(), EndedAt: &endedAt,
+ }); err != nil {
+ t.Fatalf("upsert trace block with binary tool output: %v", err)
+ }
+ if err := repo.CreateConversationToolCall(ctx, &domainconversation.ToolCall{
+ MessageID: 1, ConversationID: 1, UserID: 1, RunID: "run_binary", ToolCallID: "call_binary",
+ ToolName: "web_fetch_exa", Status: "success", OutputJSON: "GIF89a\x00\xff",
+ }); err != nil {
+ t.Fatalf("create tool call with binary output: %v", err)
+ }
+
+ rows, err := repo.ListConversationMessageTraceEventsByMessageIDs(ctx, []uint{1})
+ if err != nil {
+ t.Fatalf("list trace events: %v", err)
+ }
+ if len(rows) != 1 || rows[0].Status != "completed" {
+ t.Fatalf("expected the completed event to replace the streaming one, got %+v", rows)
+ }
+ if strings.ContainsRune(rows[0].ContentMarkdown, 0) || !strings.Contains(rows[0].ContentMarkdown, "GIF89a") {
+ t.Fatalf("unexpected stored markdown: %q", rows[0].ContentMarkdown)
+ }
+}
diff --git a/backend/internal/pkg/textutil/text.go b/backend/internal/pkg/textutil/text.go
index ca7d9d4b4..717e4b183 100644
--- a/backend/internal/pkg/textutil/text.go
+++ b/backend/internal/pkg/textutil/text.go
@@ -58,3 +58,15 @@ func TruncateTrimmed(value string, maxRunes int) string {
}
return string(runes[:maxRunes])
}
+
+// SanitizeForTextColumn 让可能来自工具输出的文本可安全写入数据库文本列。
+//
+// 工具可能返回二进制内容(例如抓取到的 GIF),其字节解码成字符串后会带来
+// NUL 与非法 UTF-8;PostgreSQL 的 text 列会整体拒绝写入(SQLSTATE 22021),
+// 一次写入失败就会丢掉整条事件(例如轨迹停留在「进行中」)。
+func SanitizeForTextColumn(value string) string {
+ if !strings.ContainsRune(value, 0) {
+ return strings.ToValidUTF8(value, "\uFFFD")
+ }
+ return strings.ToValidUTF8(strings.ReplaceAll(value, "\x00", ""), "\uFFFD")
+}
diff --git a/backend/internal/pkg/textutil/text_test.go b/backend/internal/pkg/textutil/text_test.go
index 7c148914d..388dfc7ca 100644
--- a/backend/internal/pkg/textutil/text_test.go
+++ b/backend/internal/pkg/textutil/text_test.go
@@ -36,3 +36,14 @@ func TestTruncateTrimmed(t *testing.T) {
t.Fatalf("TruncateTrimmed() = %q, want %q", got, "你好世")
}
}
+
+func TestSanitizeForTextColumn(t *testing.T) {
+ clean := "普通文本 123"
+ if got := SanitizeForTextColumn(clean); got != clean {
+ t.Fatalf("clean text changed: %q", got)
+ }
+ // 抓取到二进制内容时解码出的 NUL 与非法 UTF-8 都不能进入 text 列;连续的非法字节合并为一个替换符。
+ if got := SanitizeForTextColumn("GIF89a\x00\xff\xfe tail"); got != "GIF89a\uFFFD tail" {
+ t.Fatalf("unexpected sanitized value: %q", got)
+ }
+}
diff --git a/backend/package.json b/backend/package.json
index e6d7d62b9..e3b00a164 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "@deeix/api",
- "version": "0.4.5",
+ "version": "0.4.6",
"private": true,
"scripts": {
"dev": "make run",
diff --git a/package.json b/package.json
index c10201f69..cc10917a5 100644
--- a/package.json
+++ b/package.json
@@ -1,8 +1,11 @@
{
"name": "@deeix/chat-monorepo",
- "version": "0.4.5",
+ "version": "0.4.6",
"private": true,
"packageManager": "pnpm@10.17.0",
+ "engines": {
+ "node": "^22.23.4 || ^24.22.1 || >=26.12.1"
+ },
"scripts": {
"dev": "turbo run dev",
"dev:api": "turbo run dev --filter=@deeix/api",
diff --git a/packages/api-contract/README.md b/packages/api-contract/README.md
index c21d3f73e..03051e0fe 100644
--- a/packages/api-contract/README.md
+++ b/packages/api-contract/README.md
@@ -13,6 +13,10 @@ pnpm api:check
`pnpm api:generate` updates `backend/docs/{docs.go,swagger.json,swagger.yaml}` and `packages/api-contract/src/types.generated.ts` together. `cd backend && make swagger` invokes this same pipeline.
+Version bumps use it too: after updating `VERSION`, run `make version` and then
+`make version-check` from the workspace root. These commands synchronize package
+manifests and desktop lock metadata together with the generated API versions.
+
DTO field conventions:
- Fields serialized on every response are required by default; do not repeat their requiredness in frontend types.
diff --git a/packages/api-contract/package.json b/packages/api-contract/package.json
index 0a7df1963..406772855 100644
--- a/packages/api-contract/package.json
+++ b/packages/api-contract/package.json
@@ -1,6 +1,6 @@
{
"name": "@deeix/api-contract",
- "version": "0.4.5",
+ "version": "0.4.6",
"private": true,
"type": "module",
"types": "./src/index.ts",
diff --git a/packages/core/package.json b/packages/core/package.json
index 67a617789..e4c5fa654 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
{
"name": "@deeix/core",
- "version": "0.4.5",
+ "version": "0.4.6",
"private": true,
"type": "module",
"description": "Platform-agnostic client logic shared by web, desktop and mobile. No React, no DOM, no React Native.",
@@ -25,7 +25,7 @@
},
"devDependencies": {
"@biomejs/biome": "2.5.11",
- "@types/node": "^25.6.0",
+ "@types/node": "^26.6.5",
"typescript": "7.0.2"
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8037b0f67..abe45ddad 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -157,10 +157,10 @@ importers:
version: 12.42.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
next:
specifier: 16.3.8
- version: 16.3.8(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ version: 16.3.8(@types/node@26.6.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
next-intl:
specifier: ^4.12.0
- version: 4.13.2(@swc/helpers@0.5.23)(next@16.3.8(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(typescript@7.0.2)
+ version: 4.13.2(@swc/helpers@0.5.23)(next@16.3.8(@types/node@26.6.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(typescript@7.0.2)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -214,8 +214,8 @@ importers:
specifier: ^4.3.3
version: 4.3.3
'@types/node':
- specifier: ^25.6.0
- version: 25.9.5
+ specifier: ^26.6.5
+ version: 26.6.5
'@types/react':
specifier: ^19.2.18
version: 19.2.18
@@ -256,8 +256,8 @@ importers:
specifier: 2.5.11
version: 2.5.11
'@types/node':
- specifier: ^25.6.0
- version: 25.9.5
+ specifier: ^26.6.5
+ version: 26.6.5
typescript:
specifier: 7.0.2
version: 7.0.2
@@ -2064,8 +2064,8 @@ packages:
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
- '@types/node@25.9.5':
- resolution: {integrity: sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==}
+ '@types/node@26.6.5':
+ resolution: {integrity: sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==}
'@types/react-dom@19.2.5':
resolution: {integrity: sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==}
@@ -3642,8 +3642,8 @@ packages:
engines: {node: '>=16.20.0'}
hasBin: true
- undici-types@7.24.6:
- resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
+ undici-types@8.9.0:
+ resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==}
unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
@@ -5487,9 +5487,9 @@ snapshots:
'@types/ms@2.1.0': {}
- '@types/node@25.9.5':
+ '@types/node@26.6.5':
dependencies:
- undici-types: 7.24.6
+ undici-types: 8.9.0
'@types/react-dom@19.2.5(@types/react@19.2.18)':
dependencies:
@@ -6729,14 +6729,14 @@ snapshots:
next-intl-swc-plugin-extractor@4.13.2: {}
- next-intl@4.13.2(@swc/helpers@0.5.23)(next@16.3.8(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(typescript@7.0.2):
+ next-intl@4.13.2(@swc/helpers@0.5.23)(next@16.3.8(@types/node@26.6.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(typescript@7.0.2):
dependencies:
'@formatjs/intl-localematcher': 0.8.13
'@parcel/watcher': 2.5.6
'@swc/core': 1.15.43(@swc/helpers@0.5.23)
icu-minify: 4.13.2
negotiator: 1.0.0
- next: 16.3.8(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ next: 16.3.8(@types/node@26.6.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
next-intl-swc-plugin-extractor: 4.13.2
po-parser: 2.1.1
react: 19.2.8
@@ -6751,7 +6751,7 @@ snapshots:
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
- next@16.3.8(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
+ next@16.3.8(@types/node@26.6.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
dependencies:
'@next/env': 16.3.8
'@swc/helpers': 0.5.23
@@ -6770,7 +6770,7 @@ snapshots:
'@next/swc-linux-x64-musl': 16.3.8
'@next/swc-win32-arm64-msvc': 16.3.8
'@next/swc-win32-x64-msvc': 16.3.8
- sharp: 0.35.5(@types/node@25.9.5)
+ sharp: 0.35.5(@types/node@26.6.5)
transitivePeerDependencies:
- '@babel/core'
- '@types/node'
@@ -7192,7 +7192,7 @@ snapshots:
setimmediate@1.0.5: {}
- sharp@0.35.5(@types/node@25.9.5):
+ sharp@0.35.5(@types/node@26.6.5):
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
@@ -7223,7 +7223,7 @@ snapshots:
'@img/sharp-win32-arm64': 0.35.5
'@img/sharp-win32-ia32': 0.35.5
'@img/sharp-win32-x64': 0.35.5
- '@types/node': 25.9.5
+ '@types/node': 26.6.5
optional: true
shiki@4.5.0:
@@ -7445,7 +7445,7 @@ snapshots:
'@typescript/typescript-win32-arm64': 7.0.2
'@typescript/typescript-win32-x64': 7.0.2
- undici-types@7.24.6: {}
+ undici-types@8.9.0: {}
unified@11.0.5:
dependencies:
diff --git a/scripts/sync-version.mjs b/scripts/sync-version.mjs
index d09f7917f..0278c5412 100644
--- a/scripts/sync-version.mjs
+++ b/scripts/sync-version.mjs
@@ -7,8 +7,8 @@ const args = process.argv.slice(2);
const checkOnly = args.includes("--check");
const mode = args.find((arg) => !arg.startsWith("--")) ?? "all";
-// Every versioned target in the workspace. Adding a new app (desktop, mobile)
-// means adding one entry here; nothing else in the release pipeline changes.
+// Low-level manifest synchronization, also called by the API generator.
+// `make version` completes it by regenerating Swagger and TypeScript contracts.
const targets = {
workspace: () => {
syncPackageVersion();
@@ -21,7 +21,7 @@ const targets = {
desktop: () => {
syncPackageVersion("apps", "desktop");
- // The Tauri manifest and the Rust crate carry their own version fields.
+ // Keep the Rust root package's lockfile entry in sync for --locked builds.
syncTauriConfig("apps", "desktop", "src-tauri", "tauri.conf.json");
syncCargoVersion("apps", "desktop", "src-tauri", "Cargo.toml");
},
@@ -99,12 +99,29 @@ function syncCargoVersion(...pathSegments) {
const content = readFileSync(manifestFile, "utf8");
const packageSection = content.indexOf("[package]");
const versionLine = /^version = ".*"/mu.exec(content.slice(packageSection));
- if (packageSection === -1 || !versionLine) {
+ const packageName = /^name = "([^"]+)"/mu.exec(content.slice(packageSection))?.[1];
+ if (packageSection === -1 || !versionLine || !packageName) {
throw new Error(`Unable to update ${manifestFile} package version`);
}
const start = packageSection + versionLine.index;
const next = `${content.slice(0, start)}version = "${version}"${content.slice(start + versionLine[0].length)}`;
writeIfChanged(manifestFile, next);
+ syncCargoLockVersion(join(dirname(manifestFile), "Cargo.lock"), packageName);
+}
+
+function syncCargoLockVersion(lockFile, packageName) {
+ const content = readFileSync(lockFile, "utf8");
+ let matches = 0;
+ const next = content.split(/(?=^\[\[package\]\])/mu).map((entry) => {
+ const name = /^name = "([^"]+)"/mu.exec(entry)?.[1];
+ // A registry or git dependency may share the root package's name. Its
+ // version and every dependency checksum must remain pinned as before.
+ if (name !== packageName || /^source\s*=/mu.test(entry)) return entry;
+ matches++;
+ return replaceOrThrow(entry, /^version = "[^"]+"/mu, `version = "${version}"`, `${lockFile} root package version`);
+ }).join("");
+ if (matches !== 1) throw new Error(`Expected one local ${packageName} package in ${lockFile}`);
+ writeIfChanged(lockFile, next);
}
for (const [name, sync] of Object.entries(targets)) {
@@ -118,7 +135,7 @@ if (checkOnly && mismatches.length > 0) {
for (const filePath of mismatches) {
console.error(`- ${filePath}`);
}
- const fixCommand = mode === "all" ? "pnpm api:generate" : `node scripts/sync-version.mjs ${mode}`;
+ const fixCommand = mode === "all" || mode === "backend" ? "pnpm api:generate" : `node scripts/sync-version.mjs ${mode}`;
console.error(`Run: ${fixCommand}`);
process.exit(1);
}