mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 09:37:28 +00:00
✨ feat(desktop): add local update testing scripts and stable channel API version check (#11474)
* chore: stable updater * ✨ feat: add local update testing scripts and configuration - Introduced scripts for local update testing, including setup, server management, and manifest generation. - Added `dev-app-update.local.yml` for local server configuration. - Implemented `generate-manifest.sh` to create update manifests. - Created `run-test.sh` for streamlined testing process. - Updated `README.md` with instructions for local testing setup and usage. - Enhanced `UpdaterManager` to allow forced use of dev update configuration in packaged apps. Signed-off-by: Innei <tukon479@gmail.com> * 🐛 fix(desktop): update UpdaterManager test mocks for new exports Add missing mock exports for @/modules/updater/configs: - isStableChannel - githubConfig - UPDATE_SERVER_URL Add mock for @/env with getDesktopEnv Add setFeedURL method to autoUpdater mock * ✨ feat: add Conductor setup scripts and configuration * ✨ feat: enhance update modal functionality and refactor modal hooks - Added `useUpdateModal` for managing update modal state and behavior. - Refactored `UpdateModal` to utilize new modal management approach. - Improved `useWatchBroadcast` integration for handling update events. - Removed deprecated `createModalHooks` and related components from `FunctionModal`. - Updated `AddFilesToKnowledgeBase` and `CreateNew` modals to use new modal context for closing behavior. This refactor streamlines modal management and enhances the user experience during update processes. Signed-off-by: Innei <tukon479@gmail.com> * update flow (#11513) * ci: simplify desktop release workflow and add renderer tarball * 👷 ci: fix s3 upload credentials for desktop release * 🐛 fix(ci): use compact jq output for GitHub Actions matrix Add -c flag to jq commands to produce single-line JSON output, fixing "Invalid format" error when setting GITHUB_OUTPUT. * 🐛 fix(ci): add administration permission to detect self-hosted runner The /actions/runners API requires administration:read permission to list repository runners. * 🔧 refactor(ci): use workflow input for self-hosted runner selection Replace API-based runner detection with workflow input parameter since GITHUB_TOKEN lacks permission to call /actions/runners API. - Add `use_self_hosted_mac` input (default: true) - Release events always use self-hosted runner - Manual dispatch can toggle via input * feat(updater): add stable channel support with fallback mechanism - Configure electron-builder to generate stable-mac.yml for stable channel - Update CI workflow to handle both stable and latest manifest files - Implement fallback to GitHub provider when primary S3 provider fails - Reset to primary provider after successful update check * 🐛 fix(updater): remove invalid channel config from electron-builder - Remove unsupported 'channel' property from electron-builder config - Create stable*.yml files from latest*.yml in workflow instead - This ensures electron-updater finds correct manifest for stable channel * 🐛 fix(updater): use correct channel based on provider type - S3 provider: channel='stable' → looks for stable-mac.yml - GitHub provider: channel='latest' → looks for latest-mac.yml This fixes the 404 error when falling back to GitHub releases, which only have latest-mac.yml files. * refactor(env): remove unused OFFICIAL_CLOUD_SERVER and update env defaults Update environment variable handling by removing unused OFFICIAL_CLOUD_SERVER and setting defaults for UPDATE_CHANNEL and UPDATE_SERVER_URL from process.env during build stage. * 🐛 fix(ci): add version prefix to stable manifest URLs for S3 S3 directory structure: stable/{version}/xxx.dmg So stable-mac.yml URLs need version prefix: url: LobeHub-2.1.0-arm64.dmg → url: 2.1.1/LobeHub-2.1.0-arm64.dmg * ✨ feat(ci): add renderer tar manifest for integrity verification Creates stable-renderer.yml with SHA512 checksum for lobehub-renderer.tar.gz This allows the desktop app to verify renderer tarball integrity before extraction. * 🐛 fix(ci): fix YAML syntax error in renderer manifest generation * ✨ feat(ci): archive manifest files in version directory * refactor(ci): update desktop release workflows to streamline build process - Removed unnecessary dependencies in the build job for the desktop beta workflow. - Introduced a new gate job to conditionally proceed with publishing based on the success of previous jobs. - Updated macOS file merging to depend on the new gate job instead of the build job. - Simplified macOS runner selection logic in the stable workflow by using GitHub-hosted runners exclusively. Signed-off-by: Innei <tukon479@gmail.com> * refactor(electron): reorganize titlebar components and update imports - Moved titlebar components to a new directory structure for better organization. - Updated import paths for `SimpleTitleBar`, `TitleBar`, and related constants. - Introduced new components for connection management and navigation within the titlebar. - Added constants for title bar height to maintain consistency across components. This refactor enhances the maintainability of the titlebar code and improves the overall structure of the Electron application. Signed-off-by: Innei <tukon479@gmail.com> * feat(ci): add release notes handling to desktop stable workflow - Enhanced the desktop stable release workflow to include release notes. - Updated output variables to capture release notes from the GitHub event. - Adjusted environment variables in subsequent jobs to utilize the new release notes data. This addition improves the clarity and documentation of releases by ensuring that release notes are included in the workflow process. Signed-off-by: Innei <tukon479@gmail.com> * 🐛 fix: call onClose after knowledge base modal closes * 🧪 test: fix UpdaterManager update channel mocks --------- Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
parent
41801649b6
commit
959c210e86
72 changed files with 2375 additions and 833 deletions
107
.conductor/setup.sh
Executable file
107
.conductor/setup.sh
Executable file
|
|
@ -0,0 +1,107 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Conductor workspace setup script
|
||||
# This script creates symlinks for .env and all node_modules directories
|
||||
|
||||
LOG_FILE="$PWD/.conductor-setup.log"
|
||||
|
||||
log() {
|
||||
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
echo "[$timestamp] $1" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
log "=========================================="
|
||||
log "Conductor Setup Script Started"
|
||||
log "=========================================="
|
||||
log "CONDUCTOR_ROOT_PATH: $CONDUCTOR_ROOT_PATH"
|
||||
log "Current working directory: $PWD"
|
||||
log ""
|
||||
|
||||
# Check if CONDUCTOR_ROOT_PATH is set
|
||||
if [ -z "$CONDUCTOR_ROOT_PATH" ]; then
|
||||
log "ERROR: CONDUCTOR_ROOT_PATH is not set!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Symlink .env file
|
||||
log "--- Symlinking .env file ---"
|
||||
if [ -f "$CONDUCTOR_ROOT_PATH/.env" ]; then
|
||||
ln -sf "$CONDUCTOR_ROOT_PATH/.env" .env
|
||||
if [ -L ".env" ]; then
|
||||
log "SUCCESS: .env symlinked -> $(readlink .env)"
|
||||
else
|
||||
log "ERROR: Failed to create .env symlink"
|
||||
fi
|
||||
else
|
||||
log "WARNING: $CONDUCTOR_ROOT_PATH/.env does not exist, skipping"
|
||||
fi
|
||||
|
||||
log ""
|
||||
log "--- Finding node_modules directories ---"
|
||||
|
||||
# Find all node_modules directories (excluding .pnpm internal and .next build cache)
|
||||
# NODE_MODULES_DIRS=$(find "$CONDUCTOR_ROOT_PATH" -maxdepth 3 -name "node_modules" -type d 2>/dev/null | grep -v ".pnpm" | grep -v ".next")
|
||||
|
||||
# log "Found node_modules directories:"
|
||||
# echo "$NODE_MODULES_DIRS" >> "$LOG_FILE"
|
||||
|
||||
# log ""
|
||||
# log "--- Creating node_modules symlinks ---"
|
||||
|
||||
# # Counter for statistics
|
||||
# total=0
|
||||
# success=0
|
||||
# failed=0
|
||||
|
||||
# for dir in $NODE_MODULES_DIRS; do
|
||||
# total=$((total + 1))
|
||||
|
||||
# # Get relative path by removing CONDUCTOR_ROOT_PATH prefix
|
||||
# rel_path="${dir#$CONDUCTOR_ROOT_PATH/}"
|
||||
# parent_dir=$(dirname "$rel_path")
|
||||
|
||||
# log "Processing: $rel_path"
|
||||
# log " Source: $dir"
|
||||
# log " Parent dir: $parent_dir"
|
||||
|
||||
# # Create parent directory if needed
|
||||
# if [ "$parent_dir" != "." ]; then
|
||||
# if [ ! -d "$parent_dir" ]; then
|
||||
# mkdir -p "$parent_dir"
|
||||
# log " Created parent directory: $parent_dir"
|
||||
# fi
|
||||
# fi
|
||||
|
||||
# # Create symlink
|
||||
# ln -sf "$dir" "$rel_path"
|
||||
|
||||
# # Verify symlink was created
|
||||
# if [ -L "$rel_path" ]; then
|
||||
# log " SUCCESS: $rel_path -> $(readlink "$rel_path")"
|
||||
# success=$((success + 1))
|
||||
# else
|
||||
# log " ERROR: Failed to create symlink for $rel_path"
|
||||
# failed=$((failed + 1))
|
||||
# fi
|
||||
|
||||
# log ""
|
||||
# done
|
||||
|
||||
log "=========================================="
|
||||
log "Setup Complete"
|
||||
log "=========================================="
|
||||
log "Total node_modules: $total"
|
||||
log "Successful symlinks: $success"
|
||||
log "Failed symlinks: $failed"
|
||||
log ""
|
||||
|
||||
# List created symlinks for verification
|
||||
log "--- Verification: Listing symlinks in workspace ---"
|
||||
find . -maxdepth 1 -type l -exec ls -la {} \; 2>/dev/null >> "$LOG_FILE"
|
||||
find ./packages -maxdepth 2 -type l -name "node_modules" -exec ls -la {} \; 2>/dev/null >> "$LOG_FILE"
|
||||
find ./apps -maxdepth 2 -type l -name "node_modules" -exec ls -la {} \; 2>/dev/null >> "$LOG_FILE"
|
||||
find ./e2e -maxdepth 2 -type l -name "node_modules" -exec ls -la {} \; 2>/dev/null >> "$LOG_FILE"
|
||||
|
||||
log ""
|
||||
log "Log file saved to: $LOG_FILE"
|
||||
log "Setup script finished."
|
||||
29
.github/actions/desktop-build-setup/action.yml
vendored
Normal file
29
.github/actions/desktop-build-setup/action.yml
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
name: Desktop Build Setup
|
||||
description: Setup Node.js, pnpm and install dependencies for desktop build
|
||||
|
||||
inputs:
|
||||
node-version:
|
||||
description: Node.js version
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: pnpm install --node-linker=hoisted
|
||||
|
||||
- name: Install deps on Desktop
|
||||
shell: bash
|
||||
run: npm run install-isolated --prefix=./apps/desktop
|
||||
46
.github/actions/desktop-upload-artifacts/action.yml
vendored
Normal file
46
.github/actions/desktop-upload-artifacts/action.yml
vendored
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
name: Desktop Upload Artifacts
|
||||
description: Rename macOS yml for multi-arch and upload build artifacts
|
||||
|
||||
inputs:
|
||||
artifact-name:
|
||||
description: Name for the uploaded artifact
|
||||
required: true
|
||||
retention-days:
|
||||
description: Number of days to retain artifacts
|
||||
required: false
|
||||
default: '5'
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Rename macOS latest-mac.yml for multi-architecture support
|
||||
if: runner.os == 'macOS'
|
||||
shell: bash
|
||||
run: |
|
||||
cd apps/desktop/release
|
||||
if [ -f "latest-mac.yml" ]; then
|
||||
SYSTEM_ARCH=$(uname -m)
|
||||
if [[ "$SYSTEM_ARCH" == "arm64" ]]; then
|
||||
ARCH_SUFFIX="arm64"
|
||||
else
|
||||
ARCH_SUFFIX="x64"
|
||||
fi
|
||||
mv latest-mac.yml "latest-mac-${ARCH_SUFFIX}.yml"
|
||||
echo "✅ Renamed latest-mac.yml to latest-mac-${ARCH_SUFFIX}.yml"
|
||||
fi
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: ${{ inputs.artifact-name }}
|
||||
path: |
|
||||
apps/desktop/release/latest*
|
||||
apps/desktop/release/*.dmg*
|
||||
apps/desktop/release/*.zip*
|
||||
apps/desktop/release/*.exe*
|
||||
apps/desktop/release/*.AppImage
|
||||
apps/desktop/release/*.deb*
|
||||
apps/desktop/release/*.snap*
|
||||
apps/desktop/release/*.rpm*
|
||||
apps/desktop/release/*.tar.gz*
|
||||
retention-days: ${{ inputs.retention-days }}
|
||||
191
.github/workflows/release-desktop-beta.yml
vendored
191
.github/workflows/release-desktop-beta.yml
vendored
|
|
@ -1,22 +1,59 @@
|
|||
name: Release Desktop Beta
|
||||
|
||||
# ============================================
|
||||
# Beta/Nightly 频道发版工作流
|
||||
# ============================================
|
||||
# 触发条件: 发布包含 pre-release 标识的 release
|
||||
# 如: v2.0.0-beta.1, v2.0.0-alpha.1, v2.0.0-rc.1, v2.0.0-nightly.xxx
|
||||
#
|
||||
# 注意: Stable 版本 (如 v2.0.0) 由 release-desktop-stable.yml 处理
|
||||
# ============================================
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published] # 发布 release 时触发构建
|
||||
types: [published]
|
||||
|
||||
# 确保同一时间只运行一个相同的 workflow,取消正在进行的旧的运行
|
||||
concurrency:
|
||||
group: ${{ github.ref }}-${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Add default permissions
|
||||
permissions: read-all
|
||||
|
||||
env:
|
||||
NODE_VERSION: '24.11.1'
|
||||
|
||||
jobs:
|
||||
# ============================================
|
||||
# 检查是否为 Beta/Nightly 版本 (排除 Stable)
|
||||
# ============================================
|
||||
check-beta:
|
||||
name: Check if Beta/Nightly Release
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
is_beta: ${{ steps.check.outputs.is_beta }}
|
||||
version: ${{ steps.check.outputs.version }}
|
||||
steps:
|
||||
- name: Check release tag
|
||||
id: check
|
||||
run: |
|
||||
version="${{ github.event.release.tag_name }}"
|
||||
version="${version#v}"
|
||||
echo "version=${version}" >> $GITHUB_OUTPUT
|
||||
|
||||
# Beta/Nightly 版本包含 beta/alpha/rc/nightly
|
||||
if [[ "$version" == *"beta"* ]] || [[ "$version" == *"alpha"* ]] || [[ "$version" == *"rc"* ]] || [[ "$version" == *"nightly"* ]]; then
|
||||
echo "is_beta=true" >> $GITHUB_OUTPUT
|
||||
echo "✅ Beta/Nightly release detected: $version"
|
||||
else
|
||||
echo "is_beta=false" >> $GITHUB_OUTPUT
|
||||
echo "⏭️ Skipping: $version is a stable release (handled by release-desktop-stable.yml)"
|
||||
fi
|
||||
|
||||
test:
|
||||
name: Code quality check
|
||||
# 添加 PR label 触发条件,只有添加了 trigger:build-desktop 标签的 PR 才会触发构建
|
||||
runs-on: ubuntu-latest # 只在 ubuntu 上运行一次检查
|
||||
needs: [check-beta]
|
||||
if: needs.check-beta.outputs.is_beta == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout base
|
||||
uses: actions/checkout@v6
|
||||
|
|
@ -26,7 +63,7 @@ jobs:
|
|||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
|
|
@ -40,44 +77,9 @@ jobs:
|
|||
- name: Lint
|
||||
run: bun run lint
|
||||
|
||||
version:
|
||||
name: Determine version
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
# 输出版本信息,供后续 job 使用
|
||||
version: ${{ steps.set_version.outputs.version }}
|
||||
is_pr_build: ${{ steps.set_version.outputs.is_pr_build }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
package-manager-cache: false
|
||||
|
||||
# 主要逻辑:确定构建版本号
|
||||
- name: Set version
|
||||
id: set_version
|
||||
run: |
|
||||
# 从 apps/desktop/package.json 读取基础版本号
|
||||
base_version=$(node -p "require('./apps/desktop/package.json').version")
|
||||
|
||||
# Release 事件直接使用 release tag 作为版本号,去掉可能的 v 前缀
|
||||
version="${{ github.event.release.tag_name }}"
|
||||
version="${version#v}"
|
||||
echo "version=${version}" >> $GITHUB_OUTPUT
|
||||
echo "📦 Release Version: ${version}"
|
||||
|
||||
# 输出版本信息总结,方便在 GitHub Actions 界面查看
|
||||
- name: Version Summary
|
||||
run: |
|
||||
echo "🚦 Release Version: ${{ steps.set_version.outputs.version }}"
|
||||
|
||||
build:
|
||||
needs: [version, test]
|
||||
needs: [check-beta]
|
||||
if: needs.check-beta.outputs.is_beta == 'true'
|
||||
name: Build Desktop App
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
|
|
@ -88,117 +90,76 @@ jobs:
|
|||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
- name: Setup build environment
|
||||
uses: ./.github/actions/desktop-build-setup
|
||||
with:
|
||||
run_install: false
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
package-manager-cache: false
|
||||
|
||||
# node-linker=hoisted 模式将可以确保 asar 压缩可用
|
||||
- name: Install dependencies
|
||||
run: pnpm install --node-linker=hoisted
|
||||
|
||||
- name: Install deps on Desktop
|
||||
run: npm run install-isolated --prefix=./apps/desktop
|
||||
|
||||
# 设置 package.json 的版本号
|
||||
- name: Set package version
|
||||
run: npm run workflow:set-desktop-version ${{ needs.version.outputs.version }} beta
|
||||
run: npm run workflow:set-desktop-version ${{ needs.check-beta.outputs.version }} beta
|
||||
|
||||
# macOS 构建处理
|
||||
# macOS 构建
|
||||
- name: Build artifact on macOS
|
||||
if: runner.os == 'macOS'
|
||||
run: npm run desktop:build
|
||||
env:
|
||||
UPDATE_CHANNEL: beta
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: "postgresql://postgres@localhost:5432/postgres"
|
||||
# 默认添加一个加密 SECRET
|
||||
KEY_VAULTS_SECRET: "oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE="
|
||||
# macOS 签名和公证配置
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
# allow provisionally
|
||||
CSC_FOR_PULL_REQUEST: true
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_BETA_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_BETA_DESKTOP_BASE_URL }}
|
||||
|
||||
# Windows 平台构建处理
|
||||
# Windows 构建
|
||||
- name: Build artifact on Windows
|
||||
if: runner.os == 'Windows'
|
||||
run: npm run desktop:build
|
||||
env:
|
||||
UPDATE_CHANNEL: beta
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: "postgresql://postgres@localhost:5432/postgres"
|
||||
KEY_VAULTS_SECRET: "oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE="
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_BETA_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_BETA_DESKTOP_BASE_URL }}
|
||||
|
||||
# 将 TEMP 和 TMP 目录设置到 C 盘
|
||||
TEMP: C:\temp
|
||||
TMP: C:\temp
|
||||
|
||||
# Linux 平台构建处理
|
||||
# Linux 构建
|
||||
- name: Build artifact on Linux
|
||||
if: runner.os == 'Linux'
|
||||
run: npm run desktop:build
|
||||
env:
|
||||
UPDATE_CHANNEL: beta
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: "postgresql://postgres@localhost:5432/postgres"
|
||||
KEY_VAULTS_SECRET: "oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE="
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_BETA_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_BETA_DESKTOP_BASE_URL }}
|
||||
|
||||
# 处理 macOS latest-mac.yml 重命名 (避免多架构覆盖)
|
||||
- name: Rename macOS latest-mac.yml for multi-architecture support
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
cd apps/desktop/release
|
||||
if [ -f "latest-mac.yml" ]; then
|
||||
# 使用系统架构检测,与 electron-builder 输出保持一致
|
||||
SYSTEM_ARCH=$(uname -m)
|
||||
if [[ "$SYSTEM_ARCH" == "arm64" ]]; then
|
||||
ARCH_SUFFIX="arm64"
|
||||
else
|
||||
ARCH_SUFFIX="x64"
|
||||
fi
|
||||
|
||||
mv latest-mac.yml "latest-mac-${ARCH_SUFFIX}.yml"
|
||||
echo "✅ Renamed latest-mac.yml to latest-mac-${ARCH_SUFFIX}.yml (detected: $SYSTEM_ARCH)"
|
||||
ls -la latest-mac-*.yml
|
||||
else
|
||||
echo "⚠️ latest-mac.yml not found, skipping rename"
|
||||
ls -la latest*.yml || echo "No latest*.yml files found"
|
||||
fi
|
||||
|
||||
# 上传构建产物 (工作流处理重命名,不依赖 electron-builder 钩子)
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
- name: Upload artifacts
|
||||
uses: ./.github/actions/desktop-upload-artifacts
|
||||
with:
|
||||
name: release-${{ matrix.os }}
|
||||
path: |
|
||||
apps/desktop/release/latest*
|
||||
apps/desktop/release/*.dmg*
|
||||
apps/desktop/release/*.zip*
|
||||
apps/desktop/release/*.exe*
|
||||
apps/desktop/release/*.AppImage
|
||||
apps/desktop/release/*.deb*
|
||||
apps/desktop/release/*.snap*
|
||||
apps/desktop/release/*.rpm*
|
||||
apps/desktop/release/*.tar.gz*
|
||||
retention-days: 5
|
||||
artifact-name: release-${{ matrix.os }}
|
||||
|
||||
# 汇总门禁: test/build 完成后决定是否继续
|
||||
gate:
|
||||
needs: [check-beta, test, build]
|
||||
if: ${{ needs.check-beta.outputs.is_beta == 'true' && needs.test.result == 'success' && needs.build.result == 'success' }}
|
||||
name: Gate for publish
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Gate passed
|
||||
run: echo "Gate passed"
|
||||
|
||||
# 合并 macOS 多架构 latest-mac.yml 文件
|
||||
merge-mac-files:
|
||||
needs: [build, version]
|
||||
needs: [gate]
|
||||
name: Merge macOS Release Files
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
|
|
@ -210,7 +171,7 @@ jobs:
|
|||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
|
|
|
|||
461
.github/workflows/release-desktop-stable.yml
vendored
Normal file
461
.github/workflows/release-desktop-stable.yml
vendored
Normal file
|
|
@ -0,0 +1,461 @@
|
|||
name: Release Desktop Stable
|
||||
|
||||
# ============================================
|
||||
# Stable 频道发版工作流
|
||||
# ============================================
|
||||
# 触发条件: 发布不含 pre-release 标识的 release (如 v2.0.0)
|
||||
#
|
||||
# 与 Beta 的区别:
|
||||
# 1. 仅响应 stable 版本 tag (不含 beta/alpha/rc/nightly)
|
||||
# 2. 使用 STABLE 专用的 Umami 配置
|
||||
# 3. 额外上传到 S3 更新服务器
|
||||
# 4. 构建时注入 UPDATE_SERVER_URL 让客户端从 S3 检查更新
|
||||
#
|
||||
# 需要配置的 Secrets (S3 相关, 统一 UPDATE_ 前缀):
|
||||
# - UPDATE_AWS_ACCESS_KEY_ID
|
||||
# - UPDATE_AWS_SECRET_ACCESS_KEY
|
||||
# - UPDATE_S3_BUCKET (S3 存储桶名称)
|
||||
# - UPDATE_S3_REGION (可选, 默认 us-east-1)
|
||||
# - UPDATE_S3_ENDPOINT (可选, 用于 R2/MinIO 等 S3 兼容服务)
|
||||
# - UPDATE_SERVER_URL (客户端检查更新的 URL)
|
||||
# ============================================
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version to build (e.g., 2.0.0)'
|
||||
required: true
|
||||
type: string
|
||||
build_mac:
|
||||
description: 'Build macOS (ARM64)'
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
build_windows:
|
||||
description: 'Build Windows'
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
build_linux:
|
||||
description: 'Build Linux'
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
skip_s3_upload:
|
||||
description: 'Skip S3 upload (for testing)'
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
skip_github_release:
|
||||
description: 'Skip GitHub release upload (for testing)'
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.ref }}-${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: read-all
|
||||
|
||||
env:
|
||||
NODE_VERSION: '24.11.1'
|
||||
|
||||
jobs:
|
||||
# ============================================
|
||||
# 检查版本信息
|
||||
# ============================================
|
||||
check-stable:
|
||||
name: Check Release Version
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
is_stable: ${{ steps.check.outputs.is_stable }}
|
||||
version: ${{ steps.check.outputs.version }}
|
||||
is_manual: ${{ steps.check.outputs.is_manual }}
|
||||
release_notes: ${{ steps.check.outputs.release_notes }}
|
||||
steps:
|
||||
- name: Check release info
|
||||
id: check
|
||||
run: |
|
||||
# 判断触发方式
|
||||
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
|
||||
# 手动触发: 使用输入的版本号
|
||||
version="${{ inputs.version }}"
|
||||
echo "is_manual=true" >> $GITHUB_OUTPUT
|
||||
echo "is_stable=true" >> $GITHUB_OUTPUT
|
||||
echo "version=${version}" >> $GITHUB_OUTPUT
|
||||
echo "release_notes=" >> $GITHUB_OUTPUT
|
||||
echo "🔧 Manual trigger: version=${version}"
|
||||
else
|
||||
# Release 触发: 从 tag 提取版本号
|
||||
version="${{ github.event.release.tag_name }}"
|
||||
version="${version#v}"
|
||||
echo "is_manual=false" >> $GITHUB_OUTPUT
|
||||
echo "version=${version}" >> $GITHUB_OUTPUT
|
||||
release_body="${{ github.event.release.body }}"
|
||||
{
|
||||
echo "release_notes<<EOF"
|
||||
printf '%s\n' "$release_body"
|
||||
echo "EOF"
|
||||
} >> $GITHUB_OUTPUT
|
||||
|
||||
# 检查是否为 stable 版本 (不含 beta/alpha/rc/nightly)
|
||||
if [[ "$version" == *"beta"* ]] || [[ "$version" == *"alpha"* ]] || [[ "$version" == *"rc"* ]] || [[ "$version" == *"nightly"* ]]; then
|
||||
echo "is_stable=false" >> $GITHUB_OUTPUT
|
||||
echo "⏭️ Skipping: $version is not a stable release"
|
||||
else
|
||||
echo "is_stable=true" >> $GITHUB_OUTPUT
|
||||
echo "✅ Stable release detected: $version"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ============================================
|
||||
# 配置构建矩阵 (检查自托管 Runner)
|
||||
# ============================================
|
||||
configure-build:
|
||||
needs: [check-stable]
|
||||
if: needs.check-stable.outputs.is_stable == 'true'
|
||||
name: Configure Build Matrix
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
steps:
|
||||
- name: Generate Matrix
|
||||
id: set-matrix
|
||||
run: |
|
||||
# 基础矩阵
|
||||
static_matrix='[]'
|
||||
|
||||
# Windows
|
||||
if [[ "${{ github.event_name }}" != "workflow_dispatch" ]] || [[ "${{ inputs.build_windows }}" == "true" ]]; then
|
||||
static_matrix=$(echo "$static_matrix" | jq -c '. + [{"os": "windows-2025", "name": "windows-2025"}]')
|
||||
fi
|
||||
|
||||
# Linux
|
||||
if [[ "${{ github.event_name }}" != "workflow_dispatch" ]] || [[ "${{ inputs.build_linux }}" == "true" ]]; then
|
||||
static_matrix=$(echo "$static_matrix" | jq -c '. + [{"os": "ubuntu-latest", "name": "ubuntu-latest"}]')
|
||||
fi
|
||||
|
||||
# macOS (ARM64)
|
||||
# 使用 GitHub Hosted Runner
|
||||
if [[ "${{ github.event_name }}" != "workflow_dispatch" ]] || [[ "${{ inputs.build_mac }}" == "true" ]]; then
|
||||
echo "Using GitHub-Hosted Runner for macOS ARM64"
|
||||
arm_entry='{"os": "macos-14", "name": "macos-arm64"}'
|
||||
static_matrix=$(echo "$static_matrix" | jq -c --argjson entry "$arm_entry" '. + [$entry]')
|
||||
fi
|
||||
|
||||
# 输出
|
||||
echo "matrix={\"include\":$static_matrix}" >> $GITHUB_OUTPUT
|
||||
|
||||
# ============================================
|
||||
# 多平台构建
|
||||
# ============================================
|
||||
build:
|
||||
needs: [check-stable, configure-build]
|
||||
if: needs.check-stable.outputs.is_stable == 'true'
|
||||
name: Build Desktop App
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.configure-build.outputs.matrix) }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup build environment
|
||||
uses: ./.github/actions/desktop-build-setup
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Set package version
|
||||
run: npm run workflow:set-desktop-version ${{ needs.check-stable.outputs.version }} stable
|
||||
|
||||
# macOS 构建
|
||||
- name: Build artifact on macOS
|
||||
if: runner.os == 'macOS'
|
||||
run: npm run desktop:build
|
||||
env:
|
||||
UPDATE_CHANNEL: stable
|
||||
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
|
||||
RELEASE_NOTES: ${{ needs.check-stable.outputs.release_notes }}
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
CSC_FOR_PULL_REQUEST: true
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_STABLE_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_STABLE_DESKTOP_BASE_URL }}
|
||||
|
||||
# Windows 构建
|
||||
- name: Build artifact on Windows
|
||||
if: runner.os == 'Windows'
|
||||
run: npm run desktop:build
|
||||
env:
|
||||
UPDATE_CHANNEL: stable
|
||||
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
|
||||
RELEASE_NOTES: ${{ needs.check-stable.outputs.release_notes }}
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_STABLE_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_STABLE_DESKTOP_BASE_URL }}
|
||||
TEMP: C:\temp
|
||||
TMP: C:\temp
|
||||
|
||||
# Linux 构建
|
||||
- name: Build artifact on Linux
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
npm run desktop:build
|
||||
tar -czf apps/desktop/release/lobehub-renderer.tar.gz -C out .
|
||||
env:
|
||||
UPDATE_CHANNEL: stable
|
||||
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
|
||||
RELEASE_NOTES: ${{ needs.check-stable.outputs.release_notes }}
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_STABLE_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_STABLE_DESKTOP_BASE_URL }}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: ./.github/actions/desktop-upload-artifacts
|
||||
with:
|
||||
artifact-name: release-${{ matrix.name }}
|
||||
|
||||
# ============================================
|
||||
# 合并 macOS 多架构文件
|
||||
# ============================================
|
||||
merge-mac-files:
|
||||
needs: [build, check-stable]
|
||||
name: Merge macOS Release Files
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
path: release
|
||||
pattern: release-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: List downloaded artifacts
|
||||
run: ls -R release
|
||||
|
||||
- name: Install yaml only for merge step
|
||||
run: |
|
||||
cd scripts/electronWorkflow
|
||||
if [ ! -f package.json ]; then
|
||||
echo '{"name":"merge-mac-release","private":true}' > package.json
|
||||
fi
|
||||
bun add --no-save yaml@2.8.1
|
||||
|
||||
- name: Merge latest-mac.yml files
|
||||
run: bun run scripts/electronWorkflow/mergeMacReleaseFiles.js
|
||||
|
||||
- name: Upload artifacts with merged macOS files
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: merged-release
|
||||
path: release/
|
||||
retention-days: 1
|
||||
|
||||
# ============================================
|
||||
# 发布到 GitHub Releases
|
||||
# ============================================
|
||||
publish-github:
|
||||
needs: [merge-mac-files, check-stable]
|
||||
name: Publish to GitHub Release
|
||||
runs-on: ubuntu-latest
|
||||
# 手动触发时可选择跳过
|
||||
if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.skip_github_release) }}
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download merged artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: merged-release
|
||||
path: release
|
||||
|
||||
- name: List final artifacts
|
||||
run: ls -R release
|
||||
|
||||
- name: Upload to Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
# 手动触发时使用输入的版本号创建 tag
|
||||
tag_name: ${{ github.event_name == 'workflow_dispatch' && format('v{0}', needs.check-stable.outputs.version) || github.event.release.tag_name }}
|
||||
# 手动触发时创建为 draft
|
||||
draft: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
files: |
|
||||
release/stable*
|
||||
release/latest*
|
||||
release/*.dmg*
|
||||
release/*.zip*
|
||||
release/*.exe*
|
||||
release/*.AppImage
|
||||
release/*.deb*
|
||||
release/*.snap*
|
||||
release/*.rpm*
|
||||
release/*.tar.gz*
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# ============================================
|
||||
# 发布到 S3 更新服务器
|
||||
# ============================================
|
||||
# S3 目录结构:
|
||||
# s3://bucket/
|
||||
# stable/
|
||||
# stable-mac.yml ← electron-updater 检查更新 (stable channel)
|
||||
# stable.yml ← Windows (stable channel)
|
||||
# stable-linux.yml ← Linux (stable channel)
|
||||
# latest-mac.yml ← fallback for GitHub provider
|
||||
# {version}/ ← 版本目录
|
||||
# *.dmg, *.zip, *.exe, ...
|
||||
# ============================================
|
||||
publish-s3:
|
||||
needs: [merge-mac-files, check-stable]
|
||||
name: Publish to S3
|
||||
runs-on: ubuntu-latest
|
||||
# 手动触发时可选择跳过
|
||||
if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.skip_s3_upload) }}
|
||||
steps:
|
||||
- name: Download merged artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: merged-release
|
||||
path: release
|
||||
|
||||
- name: List artifacts to upload
|
||||
run: |
|
||||
echo "📦 Artifacts to upload to S3:"
|
||||
ls -lah release/
|
||||
echo ""
|
||||
echo "📋 Version: ${{ needs.check-stable.outputs.version }}"
|
||||
|
||||
- name: Upload to S3
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.UPDATE_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.UPDATE_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_REGION: ${{ secrets.UPDATE_S3_REGION || 'us-east-1' }}
|
||||
S3_BUCKET: ${{ secrets.UPDATE_S3_BUCKET }}
|
||||
S3_ENDPOINT: ${{ secrets.UPDATE_S3_ENDPOINT }}
|
||||
VERSION: ${{ needs.check-stable.outputs.version }}
|
||||
run: |
|
||||
if [ -z "$S3_BUCKET" ]; then
|
||||
echo "⚠️ UPDATE_S3_BUCKET is not configured, skipping S3 upload"
|
||||
echo ""
|
||||
echo "To enable S3 upload, configure the following secrets:"
|
||||
echo " - UPDATE_AWS_ACCESS_KEY_ID"
|
||||
echo " - UPDATE_AWS_SECRET_ACCESS_KEY"
|
||||
echo " - UPDATE_S3_BUCKET"
|
||||
echo " - UPDATE_S3_REGION (optional, defaults to us-east-1)"
|
||||
echo " - UPDATE_S3_ENDPOINT (optional, for S3-compatible services)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 构建端点参数
|
||||
ENDPOINT_ARG=""
|
||||
if [ -n "$S3_ENDPOINT" ]; then
|
||||
ENDPOINT_ARG="--endpoint-url $S3_ENDPOINT"
|
||||
echo "📡 Using custom S3 endpoint: $S3_ENDPOINT"
|
||||
fi
|
||||
|
||||
echo "🚀 Uploading to S3 bucket: $S3_BUCKET"
|
||||
echo "📁 Target path: s3://$S3_BUCKET/stable/"
|
||||
echo ""
|
||||
|
||||
# 1. 上传安装包到版本目录
|
||||
echo "📦 Uploading release files to s3://$S3_BUCKET/stable/$VERSION/"
|
||||
for file in release/*.dmg release/*.zip release/*.exe release/*.AppImage release/*.deb release/*.rpm release/*.snap release/*.tar.gz; do
|
||||
if [ -f "$file" ]; then
|
||||
filename=$(basename "$file")
|
||||
echo " ↗️ $filename"
|
||||
aws s3 cp "$file" "s3://$S3_BUCKET/stable/$VERSION/$filename" $ENDPOINT_ARG
|
||||
fi
|
||||
done
|
||||
|
||||
# 2. 创建 stable*.yml (从 latest*.yml 复制,并修改 URL 加上版本目录前缀)
|
||||
# electron-updater 在 channel=stable 时会找 stable-mac.yml
|
||||
# S3 目录结构: stable/{version}/xxx.dmg,所以 URL 需要加上 {version}/ 前缀
|
||||
echo ""
|
||||
echo "📋 Creating stable*.yml files from latest*.yml..."
|
||||
for yml in release/latest*.yml; do
|
||||
if [ -f "$yml" ]; then
|
||||
stable_name=$(basename "$yml" | sed 's/latest/stable/')
|
||||
# 复制并修改 URL: 给所有 url 字段加上版本目录前缀
|
||||
# url: xxx.dmg -> url: {VERSION}/xxx.dmg
|
||||
sed "s|url: |url: $VERSION/|g" "$yml" > "release/$stable_name"
|
||||
echo " 📄 Created $stable_name from $(basename $yml) with URL prefix: $VERSION/"
|
||||
fi
|
||||
done
|
||||
|
||||
# 3. 创建 renderer manifest (用于验证 renderer tar 完整性)
|
||||
echo ""
|
||||
echo "📋 Creating renderer manifest..."
|
||||
RENDERER_TAR="release/lobehub-renderer.tar.gz"
|
||||
if [ -f "$RENDERER_TAR" ]; then
|
||||
RENDERER_SHA512=$(shasum -a 512 "$RENDERER_TAR" | awk '{print $1}' | xxd -r -p | base64)
|
||||
RENDERER_SIZE=$(stat -f%z "$RENDERER_TAR" 2>/dev/null || stat -c%s "$RENDERER_TAR")
|
||||
RELEASE_DATE=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
|
||||
echo "version: $VERSION" > "release/stable-renderer.yml"
|
||||
echo "files:" >> "release/stable-renderer.yml"
|
||||
echo " - url: $VERSION/lobehub-renderer.tar.gz" >> "release/stable-renderer.yml"
|
||||
echo " sha512: $RENDERER_SHA512" >> "release/stable-renderer.yml"
|
||||
echo " size: $RENDERER_SIZE" >> "release/stable-renderer.yml"
|
||||
echo "path: $VERSION/lobehub-renderer.tar.gz" >> "release/stable-renderer.yml"
|
||||
echo "sha512: $RENDERER_SHA512" >> "release/stable-renderer.yml"
|
||||
echo "releaseDate: '$RELEASE_DATE'" >> "release/stable-renderer.yml"
|
||||
echo " 📄 Created stable-renderer.yml with SHA512 checksum"
|
||||
else
|
||||
echo " ⚠️ Renderer tar not found, skipping manifest creation"
|
||||
fi
|
||||
|
||||
# 4. 上传 manifest 到根目录和版本目录
|
||||
# 根目录: electron-updater 需要,会被每次发版覆盖
|
||||
# 版本目录: 作为存档保留
|
||||
echo ""
|
||||
echo "📋 Uploading manifest files..."
|
||||
for yml in release/stable*.yml release/latest*.yml; do
|
||||
if [ -f "$yml" ]; then
|
||||
filename=$(basename "$yml")
|
||||
echo " ↗️ $filename -> s3://$S3_BUCKET/stable/$filename"
|
||||
aws s3 cp "$yml" "s3://$S3_BUCKET/stable/$filename" $ENDPOINT_ARG
|
||||
echo " ↗️ $filename -> s3://$S3_BUCKET/stable/$VERSION/$filename (archive)"
|
||||
aws s3 cp "$yml" "s3://$S3_BUCKET/stable/$VERSION/$filename" $ENDPOINT_ARG
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "✅ S3 upload completed!"
|
||||
echo ""
|
||||
echo "📋 Files in s3://$S3_BUCKET/stable/:"
|
||||
aws s3 ls "s3://$S3_BUCKET/stable/" $ENDPOINT_ARG || true
|
||||
echo ""
|
||||
echo "📋 Files in s3://$S3_BUCKET/stable/$VERSION/:"
|
||||
aws s3 ls "s3://$S3_BUCKET/stable/$VERSION/" $ENDPOINT_ARG || true
|
||||
|
|
@ -1,6 +1,16 @@
|
|||
# 开发环境更新配置
|
||||
# 可选择 GitHub 或 Generic provider 进行测试
|
||||
|
||||
# 方式1: GitHub Provider (默认)
|
||||
provider: github
|
||||
owner: lobehub
|
||||
repo: lobe-chat
|
||||
updaterCacheDirName: electron-app-updater
|
||||
allowPrerelease: true
|
||||
channel: nightly
|
||||
|
||||
# 方式2: Generic Provider (测试自定义服务器)
|
||||
# 取消下面的注释,注释掉上面的 GitHub 配置
|
||||
# provider: generic
|
||||
# url: http://localhost:8080
|
||||
# updaterCacheDirName: electron-app-updater
|
||||
|
|
|
|||
|
|
@ -10,19 +10,47 @@ dotenv.config();
|
|||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const packageJSON = JSON.parse(
|
||||
await fs.readFile(path.join(__dirname, 'package.json'), 'utf8')
|
||||
);
|
||||
const packageJSON = JSON.parse(await fs.readFile(path.join(__dirname, 'package.json'), 'utf8'));
|
||||
|
||||
const channel = process.env.UPDATE_CHANNEL;
|
||||
const arch = os.arch();
|
||||
const hasAppleCertificate = Boolean(process.env.CSC_LINK);
|
||||
|
||||
// 自定义更新服务器 URL (用于 stable 频道)
|
||||
const updateServerUrl = process.env.UPDATE_SERVER_URL;
|
||||
|
||||
console.log(`🚄 Build Version ${packageJSON.version}, Channel: ${channel}`);
|
||||
console.log(`🏗️ Building for architecture: ${arch}`);
|
||||
|
||||
const isNightly = channel === 'nightly';
|
||||
const isBeta = packageJSON.name.includes('beta');
|
||||
const isStable = !isNightly && !isBeta;
|
||||
|
||||
// 根据 channel 配置不同的 publish provider
|
||||
// - Stable + UPDATE_SERVER_URL: 使用 generic (自定义 HTTP 服务器)
|
||||
// - Beta/Nightly: 仅使用 GitHub
|
||||
const getPublishConfig = () => {
|
||||
const githubProvider = {
|
||||
owner: 'lobehub',
|
||||
provider: 'github',
|
||||
repo: 'lobe-chat',
|
||||
};
|
||||
|
||||
// Stable channel: 使用自定义服务器 (generic provider)
|
||||
if (isStable && updateServerUrl) {
|
||||
console.log(`📦 Stable channel: Using generic provider (${updateServerUrl})`);
|
||||
const genericProvider = {
|
||||
provider: 'generic',
|
||||
url: updateServerUrl,
|
||||
};
|
||||
// 同时发布到自定义服务器和 GitHub (GitHub 作为备用/镜像)
|
||||
return [genericProvider, githubProvider];
|
||||
}
|
||||
|
||||
// Beta/Nightly channel: 仅使用 GitHub
|
||||
console.log(`📦 ${channel || 'default'} channel: Using GitHub provider`);
|
||||
return [githubProvider];
|
||||
};
|
||||
|
||||
// Keep only these Electron Framework localization folders (*.lproj)
|
||||
// (aligned with previous Electron Forge build config)
|
||||
|
|
@ -221,13 +249,15 @@ const config = {
|
|||
schemes: [protocolScheme],
|
||||
},
|
||||
],
|
||||
publish: [
|
||||
{
|
||||
owner: 'lobehub',
|
||||
provider: 'github',
|
||||
repo: 'lobe-chat',
|
||||
publish: getPublishConfig(),
|
||||
|
||||
// Release notes 配置
|
||||
// 可以通过环境变量 RELEASE_NOTES 传入,或从文件读取
|
||||
// 这会被写入 latest-mac.yml / latest.yml 中,供 generic provider 使用
|
||||
releaseInfo: {
|
||||
releaseNotes: process.env.RELEASE_NOTES || undefined,
|
||||
},
|
||||
],
|
||||
|
||||
win: {
|
||||
executableName: 'LobeHub',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -21,11 +21,12 @@ export default defineConfig({
|
|||
},
|
||||
sourcemap: isDev ? 'inline' : false,
|
||||
},
|
||||
// 这里是关键:在构建时进行文本替换
|
||||
|
||||
define: {
|
||||
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
|
||||
'process.env.OFFICIAL_CLOUD_SERVER': JSON.stringify(process.env.OFFICIAL_CLOUD_SERVER),
|
||||
'process.env.UPDATE_CHANNEL': JSON.stringify(process.env.UPDATE_CHANNEL),
|
||||
|
||||
'process.env.UPDATE_SERVER_URL': JSON.stringify(process.env.UPDATE_SERVER_URL),
|
||||
},
|
||||
|
||||
resolve: {
|
||||
|
|
|
|||
|
|
@ -36,7 +36,8 @@
|
|||
"stylelint": "stylelint \"src/**/*.{js,jsx,ts,tsx}\" --fix",
|
||||
"test": "vitest --run",
|
||||
"type-check": "tsgo --noEmit -p tsconfig.json",
|
||||
"typecheck": "tsgo --noEmit -p tsconfig.json"
|
||||
"typecheck": "tsgo --noEmit -p tsconfig.json",
|
||||
"update-server": "sh scripts/update-test/run-test.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-updater": "^6.6.2",
|
||||
|
|
|
|||
222
apps/desktop/scripts/update-test/README.md
Normal file
222
apps/desktop/scripts/update-test/README.md
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
# 本地更新测试指南
|
||||
|
||||
本目录包含用于在本地测试 Desktop 应用更新功能的工具和脚本。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
scripts/update-test/
|
||||
├── README.md # 本文档
|
||||
├── setup.sh # 一键设置脚本
|
||||
├── start-server.sh # 启动本地更新服务器
|
||||
├── stop-server.sh # 停止本地更新服务器
|
||||
├── generate-manifest.sh # 生成 manifest 和目录结构
|
||||
├── dev-app-update.local.yml # 本地测试用的更新配置模板
|
||||
└── server/ # 本地服务器文件目录 (自动生成)
|
||||
├── stable/ # stable 渠道
|
||||
│ ├── latest-mac.yml
|
||||
│ └── {version}/
|
||||
│ ├── xxx.dmg
|
||||
│ └── xxx.zip
|
||||
├── beta/ # beta 渠道
|
||||
│ └── ...
|
||||
└── nightly/ # nightly 渠道
|
||||
└── ...
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 首次设置
|
||||
|
||||
```bash
|
||||
cd apps/desktop/scripts/update-test
|
||||
chmod +x *.sh
|
||||
./setup.sh
|
||||
```
|
||||
|
||||
### 2. 构建测试包
|
||||
|
||||
```bash
|
||||
# 回到 desktop 目录
|
||||
cd ../..
|
||||
|
||||
# 构建未签名的本地测试包
|
||||
bun run build
|
||||
bun run build-local
|
||||
```
|
||||
|
||||
### 3. 生成更新文件
|
||||
|
||||
```bash
|
||||
cd scripts/update-test
|
||||
|
||||
# 从 release 目录自动检测并生成 (默认 stable 渠道)
|
||||
./generate-manifest.sh --from-release
|
||||
|
||||
# 指定版本号 (用于模拟更新)
|
||||
./generate-manifest.sh --from-release -v 0.0.1
|
||||
|
||||
# 指定渠道
|
||||
./generate-manifest.sh --from-release -c beta -v 2.1.0-beta.1
|
||||
```
|
||||
|
||||
### 4. 启动本地服务器
|
||||
|
||||
```bash
|
||||
./start-server.sh
|
||||
# 服务器默认在 http://localhost:8787 启动
|
||||
```
|
||||
|
||||
### 5. 配置应用使用本地服务器
|
||||
|
||||
```bash
|
||||
# 复制本地测试配置到 desktop 根目录
|
||||
cp dev-app-update.local.yml ../../dev-app-update.yml
|
||||
|
||||
# 或者直接编辑 dev-app-update.yml,确保 URL 指向正确的渠道:
|
||||
# url: http://localhost:8787/stable
|
||||
```
|
||||
|
||||
### 6. 运行应用测试
|
||||
|
||||
```bash
|
||||
cd ../..
|
||||
bun run dev
|
||||
```
|
||||
|
||||
### 7. 测试完成后
|
||||
|
||||
```bash
|
||||
cd scripts/update-test
|
||||
./stop-server.sh
|
||||
|
||||
# 恢复默认的 dev-app-update.yml(可选)
|
||||
cd ../..
|
||||
git checkout dev-app-update.yml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## generate-manifest.sh 用法
|
||||
|
||||
```bash
|
||||
用法: ./generate-manifest.sh [选项]
|
||||
|
||||
选项:
|
||||
-v, --version VERSION 指定版本号 (例如: 2.0.1)
|
||||
-c, --channel CHANNEL 指定渠道 (stable|beta|nightly, 默认: stable)
|
||||
-d, --dmg FILE 指定 DMG 文件名
|
||||
-z, --zip FILE 指定 ZIP 文件名
|
||||
-n, --notes TEXT 指定 release notes
|
||||
-f, --from-release 从 release 目录自动复制文件
|
||||
-h, --help 显示帮助信息
|
||||
|
||||
示例:
|
||||
./generate-manifest.sh --from-release
|
||||
./generate-manifest.sh -v 2.0.1 -c stable --from-release
|
||||
./generate-manifest.sh -v 2.1.0-beta.1 -c beta --from-release
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 详细说明
|
||||
|
||||
### 关于 macOS 签名验证
|
||||
|
||||
本地测试的包未经签名和公证,macOS 会阻止运行。解决方法:
|
||||
|
||||
#### 方法 1:临时禁用 Gatekeeper(推荐)
|
||||
|
||||
```bash
|
||||
# 禁用
|
||||
sudo spctl --master-disable
|
||||
|
||||
# 测试完成后务必重新启用!
|
||||
sudo spctl --master-enable
|
||||
```
|
||||
|
||||
#### 方法 2:手动移除隔离属性
|
||||
|
||||
```bash
|
||||
# 对下载的 DMG 或解压后的 .app 执行
|
||||
xattr -cr /path/to/YourApp.app
|
||||
```
|
||||
|
||||
#### 方法 3:系统偏好设置
|
||||
|
||||
1. 打开「系统偏好设置」→「安全性与隐私」→「通用」
|
||||
2. 点击「仍要打开」允许未签名的应用
|
||||
|
||||
### 自定义 Release Notes
|
||||
|
||||
编辑 `server/{channel}/latest-mac.yml` 中的 `releaseNotes` 字段:
|
||||
|
||||
```yaml
|
||||
releaseNotes: |
|
||||
## 🎉 v2.0.1 测试版本
|
||||
|
||||
### ✨ 新功能
|
||||
- 功能 A
|
||||
- 功能 B
|
||||
|
||||
### 🐛 修复
|
||||
- 修复问题 X
|
||||
```
|
||||
|
||||
### 测试不同场景
|
||||
|
||||
| 场景 | 操作 |
|
||||
| ------------ | ----------------------------------------------------- |
|
||||
| 有新版本可用 | 设置 manifest 中的 `version` 大于当前应用版本 (0.0.0) |
|
||||
| 无新版本 | 设置 `version` 小于或等于当前版本 |
|
||||
| 下载失败 | 删除 server/{channel}/{version}/ 中的 DMG 文件 |
|
||||
| 网络错误 | 停止本地服务器 |
|
||||
| 测试不同渠道 | 修改 dev-app-update.yml 中的 URL 指向不同渠道 |
|
||||
|
||||
### 环境变量
|
||||
|
||||
也可以通过环境变量指定更新服务器:
|
||||
|
||||
```bash
|
||||
UPDATE_SERVER_URL=http://localhost:8787/stable bun run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 1. 服务器启动失败
|
||||
|
||||
```bash
|
||||
# 检查端口是否被占用
|
||||
lsof -i :8787
|
||||
|
||||
# 使用其他端口
|
||||
PORT=9000 ./start-server.sh
|
||||
```
|
||||
|
||||
### 2. 更新检测不到
|
||||
|
||||
- 确认 `dev-app-update.yml` 中的 URL 包含渠道路径 (如 `/stable`)
|
||||
- 确认 manifest 中的版本号大于当前版本 (0.0.0)
|
||||
- 查看日志:`tail -f ~/Library/Logs/lobehub-desktop-dev/main.log`
|
||||
|
||||
### 3. 请求了错误的 yml 文件
|
||||
|
||||
- 如果请求的是 `stable-mac.yml` 而不是 `latest-mac.yml`,说明代码中设置了 channel
|
||||
- 确保在 dev 模式下运行,代码不会设置 `autoUpdater.channel`
|
||||
|
||||
### 4. 下载后无法安装
|
||||
|
||||
- 确认已禁用 Gatekeeper 或移除隔离属性
|
||||
- 确认 DMG 文件完整
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
⚠️ **安全提醒**:
|
||||
|
||||
1. 测试完成后务必重新启用 Gatekeeper
|
||||
2. 这些脚本仅用于本地开发测试
|
||||
3. 不要将未签名的包分发给其他用户
|
||||
18
apps/desktop/scripts/update-test/dev-app-update.local.yml
Normal file
18
apps/desktop/scripts/update-test/dev-app-update.local.yml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# 本地更新测试配置
|
||||
# 将此文件复制到 apps/desktop/dev-app-update.yml 以使用本地服务器测试
|
||||
#
|
||||
# 使用方法:
|
||||
# cp scripts/update-test/dev-app-update.local.yml dev-app-update.yml
|
||||
#
|
||||
# 恢复默认配置:
|
||||
# git checkout dev-app-update.yml
|
||||
|
||||
provider: generic
|
||||
# URL 格式: http://localhost:8787/{channel}
|
||||
# 可选渠道: stable, beta, nightly
|
||||
url: http://localhost:8787/stable
|
||||
updaterCacheDirName: lobehub-desktop-local-test
|
||||
|
||||
# 设置 channel 为 stable 以匹配生产环境行为
|
||||
# stable channel 会找 stable-mac.yml
|
||||
channel: stable
|
||||
277
apps/desktop/scripts/update-test/generate-manifest.sh
Executable file
277
apps/desktop/scripts/update-test/generate-manifest.sh
Executable file
|
|
@ -0,0 +1,277 @@
|
|||
#!/bin/bash
|
||||
|
||||
# ============================================
|
||||
# 生成更新 manifest 文件 ({channel}-mac.yml)
|
||||
#
|
||||
# 目录结构:
|
||||
# server/
|
||||
# {channel}/
|
||||
# {channel}-mac.yml (e.g., stable-mac.yml)
|
||||
# {version}/
|
||||
# xxx.dmg
|
||||
# xxx.zip
|
||||
# ============================================
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SERVER_DIR="$SCRIPT_DIR/server"
|
||||
DESKTOP_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
|
||||
RELEASE_DIR="$DESKTOP_DIR/release"
|
||||
|
||||
# 默认值
|
||||
VERSION=""
|
||||
CHANNEL="stable"
|
||||
DMG_FILE=""
|
||||
ZIP_FILE=""
|
||||
RELEASE_NOTES=""
|
||||
FROM_RELEASE=false
|
||||
|
||||
# 帮助信息
|
||||
show_help() {
|
||||
echo "用法: $0 [选项]"
|
||||
echo ""
|
||||
echo "选项:"
|
||||
echo " -v, --version VERSION 指定版本号 (例如: 2.0.1)"
|
||||
echo " -c, --channel CHANNEL 指定渠道 (stable|beta|nightly, 默认: stable)"
|
||||
echo " -d, --dmg FILE 指定 DMG 文件名"
|
||||
echo " -z, --zip FILE 指定 ZIP 文件名"
|
||||
echo " -n, --notes TEXT 指定 release notes"
|
||||
echo " -f, --from-release 从 release 目录自动复制文件"
|
||||
echo " -h, --help 显示帮助信息"
|
||||
echo ""
|
||||
echo "示例:"
|
||||
echo " $0 --from-release"
|
||||
echo " $0 -v 2.0.1 -c stable -d LobeHub-2.0.1-arm64.dmg"
|
||||
echo " $0 -v 2.1.0-beta.1 -c beta --from-release"
|
||||
echo ""
|
||||
echo "生成的目录结构:"
|
||||
echo " server/"
|
||||
echo " {channel}/"
|
||||
echo " {channel}-mac.yml (e.g., stable-mac.yml)"
|
||||
echo " {version}/"
|
||||
echo " xxx.dmg"
|
||||
echo " xxx.zip"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# 计算 SHA512
|
||||
calc_sha512() {
|
||||
local file="$1"
|
||||
if [ -f "$file" ]; then
|
||||
shasum -a 512 "$file" | awk '{print $1}' | xxd -r -p | base64
|
||||
else
|
||||
echo "placeholder-sha512-file-not-found"
|
||||
fi
|
||||
}
|
||||
|
||||
# 获取文件大小
|
||||
get_file_size() {
|
||||
local file="$1"
|
||||
if [ -f "$file" ]; then
|
||||
stat -f%z "$file" 2>/dev/null || stat --printf="%s" "$file" 2>/dev/null || echo "0"
|
||||
else
|
||||
echo "0"
|
||||
fi
|
||||
}
|
||||
|
||||
# 解析参数
|
||||
FROM_RELEASE=false
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-v|--version)
|
||||
VERSION="$2"
|
||||
shift 2
|
||||
;;
|
||||
-c|--channel)
|
||||
CHANNEL="$2"
|
||||
shift 2
|
||||
;;
|
||||
-d|--dmg)
|
||||
DMG_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-z|--zip)
|
||||
ZIP_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-n|--notes)
|
||||
RELEASE_NOTES="$2"
|
||||
shift 2
|
||||
;;
|
||||
-f|--from-release)
|
||||
FROM_RELEASE=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
show_help
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "未知参数: $1"
|
||||
show_help
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "🔧 生成更新 manifest 文件..."
|
||||
echo " 渠道: $CHANNEL"
|
||||
echo ""
|
||||
|
||||
# 渠道目录
|
||||
CHANNEL_DIR="$SERVER_DIR/$CHANNEL"
|
||||
|
||||
# 自动从 release 目录检测和复制
|
||||
if [ "$FROM_RELEASE" = true ]; then
|
||||
echo "📂 从 release 目录检测文件..."
|
||||
|
||||
if [ ! -d "$RELEASE_DIR" ]; then
|
||||
echo "❌ release 目录不存在: $RELEASE_DIR"
|
||||
echo " 请先运行构建命令"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 查找 DMG 文件
|
||||
DMG_PATH=$(find "$RELEASE_DIR" -maxdepth 1 -name "*.dmg" -type f | head -1)
|
||||
if [ -n "$DMG_PATH" ]; then
|
||||
DMG_FILE=$(basename "$DMG_PATH")
|
||||
echo " 找到 DMG: $DMG_FILE"
|
||||
fi
|
||||
|
||||
# 查找 ZIP 文件
|
||||
ZIP_PATH=$(find "$RELEASE_DIR" -maxdepth 1 -name "*-mac.zip" -type f | head -1)
|
||||
if [ -n "$ZIP_PATH" ]; then
|
||||
ZIP_FILE=$(basename "$ZIP_PATH")
|
||||
echo " 找到 ZIP: $ZIP_FILE"
|
||||
fi
|
||||
|
||||
# 从文件名提取版本号
|
||||
# 文件名格式: lobehub-desktop-dev-0.0.0-arm64.dmg
|
||||
# 版本号格式: x.y.z 或 x.y.z-beta.1 等
|
||||
if [ -z "$VERSION" ] && [ -n "$DMG_FILE" ]; then
|
||||
# 先尝试匹配带预发布标签的版本 (如 2.0.0-beta.1)
|
||||
VERSION=$(echo "$DMG_FILE" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc|nightly)\.[0-9]+' | head -1)
|
||||
# 如果没有预发布标签,只匹配基本版本号 (如 2.0.0)
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION=$(echo "$DMG_FILE" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# 设置默认版本号
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION="0.0.1"
|
||||
echo "⚠️ 未指定版本号,使用默认值: $VERSION"
|
||||
fi
|
||||
|
||||
# 版本目录
|
||||
VERSION_DIR="$CHANNEL_DIR/$VERSION"
|
||||
|
||||
# 创建目录结构
|
||||
echo ""
|
||||
echo "📁 创建目录结构..."
|
||||
mkdir -p "$VERSION_DIR"
|
||||
echo " $CHANNEL_DIR/"
|
||||
echo " $VERSION/"
|
||||
|
||||
# 复制文件到版本目录
|
||||
if [ "$FROM_RELEASE" = true ]; then
|
||||
if [ -n "$DMG_PATH" ] && [ -f "$DMG_PATH" ]; then
|
||||
echo " 复制 $DMG_FILE -> $VERSION/"
|
||||
cp "$DMG_PATH" "$VERSION_DIR/"
|
||||
fi
|
||||
|
||||
if [ -n "$ZIP_PATH" ] && [ -f "$ZIP_PATH" ]; then
|
||||
echo " 复制 $ZIP_FILE -> $VERSION/"
|
||||
cp "$ZIP_PATH" "$VERSION_DIR/"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 设置默认 release notes
|
||||
if [ -z "$RELEASE_NOTES" ]; then
|
||||
RELEASE_NOTES="## 🎉 v$VERSION 本地测试版本
|
||||
|
||||
这是一个用于本地测试更新功能的模拟版本。
|
||||
|
||||
### ✨ 新功能
|
||||
- 测试自动更新功能
|
||||
- 验证更新流程
|
||||
|
||||
### 🐛 修复
|
||||
- 本地测试环境配置"
|
||||
fi
|
||||
|
||||
# 生成 {channel}-mac.yml (e.g., stable-mac.yml)
|
||||
MANIFEST_FILE="$CHANNEL-mac.yml"
|
||||
echo ""
|
||||
echo "📝 生成 $CHANNEL/$MANIFEST_FILE..."
|
||||
|
||||
DMG_SHA512=""
|
||||
DMG_SIZE="0"
|
||||
ZIP_SHA512=""
|
||||
ZIP_SIZE="0"
|
||||
|
||||
if [ -n "$DMG_FILE" ] && [ -f "$VERSION_DIR/$DMG_FILE" ]; then
|
||||
echo " 计算 DMG SHA512..."
|
||||
DMG_SHA512=$(calc_sha512 "$VERSION_DIR/$DMG_FILE")
|
||||
DMG_SIZE=$(get_file_size "$VERSION_DIR/$DMG_FILE")
|
||||
fi
|
||||
|
||||
if [ -n "$ZIP_FILE" ] && [ -f "$VERSION_DIR/$ZIP_FILE" ]; then
|
||||
echo " 计算 ZIP SHA512..."
|
||||
ZIP_SHA512=$(calc_sha512 "$VERSION_DIR/$ZIP_FILE")
|
||||
ZIP_SIZE=$(get_file_size "$VERSION_DIR/$ZIP_FILE")
|
||||
fi
|
||||
|
||||
# 写入 manifest 文件 (放在渠道目录下)
|
||||
cat > "$CHANNEL_DIR/$MANIFEST_FILE" << EOF
|
||||
version: $VERSION
|
||||
files:
|
||||
EOF
|
||||
|
||||
if [ -n "$DMG_FILE" ]; then
|
||||
cat >> "$CHANNEL_DIR/$MANIFEST_FILE" << EOF
|
||||
- url: $VERSION/$DMG_FILE
|
||||
sha512: ${DMG_SHA512:-placeholder}
|
||||
size: $DMG_SIZE
|
||||
EOF
|
||||
fi
|
||||
|
||||
if [ -n "$ZIP_FILE" ]; then
|
||||
cat >> "$CHANNEL_DIR/$MANIFEST_FILE" << EOF
|
||||
- url: $VERSION/$ZIP_FILE
|
||||
sha512: ${ZIP_SHA512:-placeholder}
|
||||
size: $ZIP_SIZE
|
||||
EOF
|
||||
fi
|
||||
|
||||
cat >> "$CHANNEL_DIR/$MANIFEST_FILE" << EOF
|
||||
path: $VERSION/${DMG_FILE:-LobeHub-$VERSION-arm64.dmg}
|
||||
sha512: ${DMG_SHA512:-placeholder}
|
||||
releaseDate: '$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")'
|
||||
releaseNotes: |
|
||||
$(echo "$RELEASE_NOTES" | sed 's/^/ /')
|
||||
EOF
|
||||
|
||||
echo "✅ 已生成 $CHANNEL_DIR/$MANIFEST_FILE"
|
||||
|
||||
# 显示生成的文件内容
|
||||
echo ""
|
||||
echo "📋 文件内容:"
|
||||
echo "----------------------------------------"
|
||||
cat "$CHANNEL_DIR/$MANIFEST_FILE"
|
||||
echo "----------------------------------------"
|
||||
|
||||
# 显示目录结构
|
||||
echo ""
|
||||
echo "📁 目录结构:"
|
||||
find "$CHANNEL_DIR" -type f | sed "s|$SERVER_DIR/||" | sort
|
||||
|
||||
echo ""
|
||||
echo "✅ 完成!"
|
||||
echo ""
|
||||
echo "下一步:"
|
||||
echo " 1. 启动服务器: ./start-server.sh"
|
||||
echo " 2. 确认 dev-app-update.yml 的 URL 为: http://localhost:8787/$CHANNEL"
|
||||
echo " 3. 运行应用: cd ../.. && bun run dev"
|
||||
105
apps/desktop/scripts/update-test/run-test.sh
Executable file
105
apps/desktop/scripts/update-test/run-test.sh
Executable file
|
|
@ -0,0 +1,105 @@
|
|||
#!/bin/bash
|
||||
|
||||
# ============================================
|
||||
# 一键启动本地更新测试
|
||||
# ============================================
|
||||
#
|
||||
# 此脚本会:
|
||||
# 1. 设置测试环境
|
||||
# 2. 从 release 目录复制文件
|
||||
# 3. 生成 manifest
|
||||
# 4. 启动本地服务器
|
||||
# 5. 配置应用使用本地服务器
|
||||
# 6. 启动应用
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DESKTOP_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
|
||||
|
||||
echo "============================================"
|
||||
echo "🧪 本地更新测试 - 一键启动"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
|
||||
# 检查 macOS Gatekeeper 状态
|
||||
check_gatekeeper() {
|
||||
if command -v spctl &> /dev/null; then
|
||||
STATUS=$(spctl --status 2>&1 || true)
|
||||
if [[ "$STATUS" == *"enabled"* ]]; then
|
||||
echo "⚠️ 警告: macOS Gatekeeper 已启用"
|
||||
echo ""
|
||||
echo " 未签名的应用可能无法安装。你可以:"
|
||||
echo " 1. 临时禁用: sudo spctl --master-disable"
|
||||
echo " 2. 或者在安装后手动允许应用"
|
||||
echo ""
|
||||
read -p "是否继续?[y/N] " -n 1 -r
|
||||
echo ""
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "✅ Gatekeeper 已禁用,可以安装未签名应用"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# 步骤 1: 设置
|
||||
echo "📦 步骤 1/5: 设置测试环境..."
|
||||
cd "$SCRIPT_DIR"
|
||||
chmod +x *.sh
|
||||
mkdir -p server
|
||||
|
||||
# 步骤 2: 检查 release 目录
|
||||
echo ""
|
||||
echo "📂 步骤 2/5: 检查构建产物..."
|
||||
if [ ! -d "$DESKTOP_DIR/release" ] || [ -z "$(ls -A "$DESKTOP_DIR/release"/*.dmg 2>/dev/null)" ]; then
|
||||
echo "❌ 未找到构建产物"
|
||||
echo ""
|
||||
echo "请先构建应用:"
|
||||
echo " cd $DESKTOP_DIR"
|
||||
echo " npm run build-local"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 步骤 3: 生成 manifest
|
||||
echo ""
|
||||
echo "📝 步骤 3/5: 生成 manifest 文件..."
|
||||
./generate-manifest.sh --from-release
|
||||
|
||||
# 步骤 4: 启动服务器
|
||||
echo ""
|
||||
echo "🚀 步骤 4/5: 启动本地服务器..."
|
||||
./start-server.sh
|
||||
|
||||
# 步骤 5: 配置并启动应用
|
||||
echo ""
|
||||
echo "⚙️ 步骤 5/5: 配置应用..."
|
||||
cp "$SCRIPT_DIR/dev-app-update.local.yml" "$DESKTOP_DIR/dev-app-update.yml"
|
||||
echo "✅ 已更新 dev-app-update.yml"
|
||||
|
||||
# 检查 Gatekeeper
|
||||
echo ""
|
||||
check_gatekeeper
|
||||
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo "✅ 准备完成!"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo "现在可以运行应用进行测试:"
|
||||
echo ""
|
||||
echo " cd $DESKTOP_DIR"
|
||||
echo " npm run dev"
|
||||
echo ""
|
||||
echo "或者直接运行:"
|
||||
read -p "是否现在启动应用?[Y/n] " -n 1 -r
|
||||
echo ""
|
||||
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
|
||||
echo ""
|
||||
echo "🚀 启动应用..."
|
||||
cd "$DESKTOP_DIR"
|
||||
npm run dev
|
||||
fi
|
||||
111
apps/desktop/scripts/update-test/setup.sh
Executable file
111
apps/desktop/scripts/update-test/setup.sh
Executable file
|
|
@ -0,0 +1,111 @@
|
|||
#!/bin/bash
|
||||
|
||||
# ============================================
|
||||
# 本地更新测试 - 一键设置脚本
|
||||
# ============================================
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SERVER_DIR="$SCRIPT_DIR/server"
|
||||
|
||||
echo "🚀 设置本地更新测试环境..."
|
||||
|
||||
# 创建服务器目录
|
||||
mkdir -p "$SERVER_DIR"
|
||||
echo "✅ 创建服务器目录: $SERVER_DIR"
|
||||
|
||||
# 设置脚本执行权限
|
||||
chmod +x "$SCRIPT_DIR"/*.sh
|
||||
echo "✅ 设置脚本执行权限"
|
||||
|
||||
# 检查是否安装了 serve
|
||||
if ! command -v npx &> /dev/null; then
|
||||
echo "❌ 需要安装 Node.js 和 npm"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 创建示例 latest-mac.yml
|
||||
cat > "$SERVER_DIR/latest-mac.yml" << 'EOF'
|
||||
version: 99.0.0
|
||||
files:
|
||||
- url: LobeHub-99.0.0-arm64.dmg
|
||||
sha512: placeholder-sha512-will-be-replaced
|
||||
size: 100000000
|
||||
- url: LobeHub-99.0.0-arm64-mac.zip
|
||||
sha512: placeholder-sha512-will-be-replaced
|
||||
size: 100000000
|
||||
path: LobeHub-99.0.0-arm64.dmg
|
||||
sha512: placeholder-sha512-will-be-replaced
|
||||
releaseDate: '2026-01-15T10:00:00.000Z'
|
||||
releaseNotes: |
|
||||
## 🎉 v99.0.0 本地测试版本
|
||||
|
||||
这是一个用于本地测试更新功能的模拟版本。
|
||||
|
||||
### ✨ 新功能
|
||||
- 测试功能 A
|
||||
- 测试功能 B
|
||||
|
||||
### 🐛 修复
|
||||
- 修复测试问题 X
|
||||
EOF
|
||||
echo "✅ 创建示例 latest-mac.yml"
|
||||
|
||||
# 创建 Windows 版本的 manifest (可选)
|
||||
cat > "$SERVER_DIR/latest.yml" << 'EOF'
|
||||
version: 99.0.0
|
||||
files:
|
||||
- url: LobeHub-99.0.0-setup.exe
|
||||
sha512: placeholder-sha512-will-be-replaced
|
||||
size: 100000000
|
||||
path: LobeHub-99.0.0-setup.exe
|
||||
sha512: placeholder-sha512-will-be-replaced
|
||||
releaseDate: '2026-01-15T10:00:00.000Z'
|
||||
releaseNotes: |
|
||||
## 🎉 v99.0.0 本地测试版本
|
||||
|
||||
这是一个用于本地测试更新功能的模拟版本。
|
||||
EOF
|
||||
echo "✅ 创建示例 latest.yml (Windows)"
|
||||
|
||||
# 创建本地测试用的 dev-app-update.yml
|
||||
cat > "$SCRIPT_DIR/dev-app-update.local.yml" << 'EOF'
|
||||
# 本地更新测试配置
|
||||
# 将此文件复制到 apps/desktop/dev-app-update.yml 以使用本地服务器测试
|
||||
|
||||
provider: generic
|
||||
url: http://localhost:8787
|
||||
updaterCacheDirName: lobehub-desktop-local-test
|
||||
EOF
|
||||
echo "✅ 创建本地测试配置文件"
|
||||
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo "✅ 设置完成!"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo "下一步操作:"
|
||||
echo ""
|
||||
echo "1. 构建测试包:"
|
||||
echo " cd $(dirname "$SCRIPT_DIR")"
|
||||
echo " npm run build-local"
|
||||
echo ""
|
||||
echo "2. 复制构建产物到服务器目录:"
|
||||
echo " cp release/*.dmg scripts/update-test/server/"
|
||||
echo " cp release/*.zip scripts/update-test/server/"
|
||||
echo ""
|
||||
echo "3. 更新 manifest 文件(可选):"
|
||||
echo " cd scripts/update-test"
|
||||
echo " ./generate-manifest.sh"
|
||||
echo ""
|
||||
echo "4. 启动本地服务器:"
|
||||
echo " ./start-server.sh"
|
||||
echo ""
|
||||
echo "5. 配置应用使用本地服务器:"
|
||||
echo " cp dev-app-update.local.yml ../../dev-app-update.yml"
|
||||
echo ""
|
||||
echo "6. 运行应用:"
|
||||
echo " cd ../.."
|
||||
echo " npm run dev"
|
||||
echo ""
|
||||
70
apps/desktop/scripts/update-test/start-server.sh
Executable file
70
apps/desktop/scripts/update-test/start-server.sh
Executable file
|
|
@ -0,0 +1,70 @@
|
|||
#!/bin/bash
|
||||
|
||||
# ============================================
|
||||
# 启动本地更新服务器
|
||||
# ============================================
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SERVER_DIR="$SCRIPT_DIR/server"
|
||||
PID_FILE="$SCRIPT_DIR/.server.pid"
|
||||
LOG_FILE="$SCRIPT_DIR/.server.log"
|
||||
PORT="${PORT:-8787}"
|
||||
|
||||
# 检查服务器目录
|
||||
if [ ! -d "$SERVER_DIR" ]; then
|
||||
echo "❌ 服务器目录不存在,请先运行 ./setup.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查是否已经在运行
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
OLD_PID=$(cat "$PID_FILE")
|
||||
if ps -p "$OLD_PID" > /dev/null 2>&1; then
|
||||
echo "⚠️ 服务器已经在运行 (PID: $OLD_PID)"
|
||||
echo " 地址: http://localhost:$PORT"
|
||||
echo ""
|
||||
echo " 如需重启,请先运行 ./stop-server.sh"
|
||||
exit 0
|
||||
else
|
||||
rm -f "$PID_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "🚀 启动本地更新服务器..."
|
||||
echo " 目录: $SERVER_DIR"
|
||||
echo " 端口: $PORT"
|
||||
echo ""
|
||||
|
||||
# 列出服务器目录中的文件
|
||||
echo "📦 可用文件:"
|
||||
ls -la "$SERVER_DIR" | grep -v "^d" | grep -v "^total" | awk '{print " " $NF}'
|
||||
echo ""
|
||||
|
||||
# 启动服务器 (后台运行)
|
||||
cd "$SERVER_DIR"
|
||||
nohup npx serve -p "$PORT" --cors -n > "$LOG_FILE" 2>&1 &
|
||||
SERVER_PID=$!
|
||||
echo "$SERVER_PID" > "$PID_FILE"
|
||||
|
||||
# 等待服务器启动
|
||||
sleep 2
|
||||
|
||||
# 检查是否启动成功
|
||||
if ps -p "$SERVER_PID" > /dev/null 2>&1; then
|
||||
echo "✅ 服务器已启动!"
|
||||
echo ""
|
||||
echo " 地址: http://localhost:$PORT"
|
||||
echo " PID: $SERVER_PID"
|
||||
echo " 日志: $LOG_FILE"
|
||||
echo ""
|
||||
echo "📋 测试 URL:"
|
||||
echo " latest-mac.yml: http://localhost:$PORT/latest-mac.yml"
|
||||
echo " latest.yml: http://localhost:$PORT/latest.yml"
|
||||
echo ""
|
||||
echo "🛑 停止服务器: ./stop-server.sh"
|
||||
else
|
||||
echo "❌ 服务器启动失败"
|
||||
echo " 查看日志: cat $LOG_FILE"
|
||||
rm -f "$PID_FILE"
|
||||
exit 1
|
||||
fi
|
||||
33
apps/desktop/scripts/update-test/stop-server.sh
Executable file
33
apps/desktop/scripts/update-test/stop-server.sh
Executable file
|
|
@ -0,0 +1,33 @@
|
|||
#!/bin/bash
|
||||
|
||||
# ============================================
|
||||
# 停止本地更新服务器
|
||||
# ============================================
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PID_FILE="$SCRIPT_DIR/.server.pid"
|
||||
LOG_FILE="$SCRIPT_DIR/.server.log"
|
||||
|
||||
if [ ! -f "$PID_FILE" ]; then
|
||||
echo "ℹ️ 服务器未运行 (找不到 PID 文件)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PID=$(cat "$PID_FILE")
|
||||
|
||||
if ps -p "$PID" > /dev/null 2>&1; then
|
||||
echo "🛑 停止服务器 (PID: $PID)..."
|
||||
kill "$PID"
|
||||
sleep 1
|
||||
|
||||
# 强制终止(如果还在运行)
|
||||
if ps -p "$PID" > /dev/null 2>&1; then
|
||||
kill -9 "$PID" 2>/dev/null
|
||||
fi
|
||||
|
||||
echo "✅ 服务器已停止"
|
||||
else
|
||||
echo "ℹ️ 服务器进程已不存在"
|
||||
fi
|
||||
|
||||
rm -f "$PID_FILE"
|
||||
|
|
@ -2,11 +2,21 @@ import log from 'electron-log';
|
|||
import { autoUpdater } from 'electron-updater';
|
||||
|
||||
import { isDev, isWindows } from '@/const/env';
|
||||
import { UPDATE_CHANNEL as channel, updaterConfig } from '@/modules/updater/configs';
|
||||
import { getDesktopEnv } from '@/env';
|
||||
import {
|
||||
UPDATE_SERVER_URL,
|
||||
UPDATE_CHANNEL as channel,
|
||||
githubConfig,
|
||||
isStableChannel,
|
||||
updaterConfig,
|
||||
} from '@/modules/updater/configs';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import type { App as AppCore } from '../App';
|
||||
|
||||
// Allow forcing dev update config via env (for testing updates in packaged app)
|
||||
const FORCE_DEV_UPDATE_CONFIG = getDesktopEnv().FORCE_DEV_UPDATE_CONFIG;
|
||||
|
||||
// Create logger
|
||||
const logger = createLogger('core:UpdaterManager');
|
||||
|
||||
|
|
@ -16,6 +26,7 @@ export class UpdaterManager {
|
|||
private downloading: boolean = false;
|
||||
private updateAvailable: boolean = false;
|
||||
private isManualCheck: boolean = false;
|
||||
private usingFallbackProvider: boolean = false;
|
||||
|
||||
constructor(app: AppCore) {
|
||||
this.app = app;
|
||||
|
|
@ -42,16 +53,26 @@ export class UpdaterManager {
|
|||
// Configure autoUpdater
|
||||
autoUpdater.autoDownload = false; // Set to false, we'll control downloads manually
|
||||
autoUpdater.autoInstallOnAppQuit = false;
|
||||
|
||||
autoUpdater.channel = channel;
|
||||
autoUpdater.allowPrerelease = channel !== 'stable';
|
||||
autoUpdater.allowDowngrade = false;
|
||||
|
||||
// Enable test mode in development environment
|
||||
if (isDev) {
|
||||
logger.info(`Running in dev mode, forcing update check, channel: ${autoUpdater.channel}`);
|
||||
// Allow testing updates in development environment
|
||||
// Enable test mode in development environment or when forced via env
|
||||
// IMPORTANT: This must be set BEFORE channel configuration so that
|
||||
// dev-app-update.yml takes precedence over programmatic configuration
|
||||
const useDevConfig = isDev || FORCE_DEV_UPDATE_CONFIG;
|
||||
if (useDevConfig) {
|
||||
// In dev mode, use dev-app-update.yml for all configuration including channel
|
||||
// Don't set channel here - let dev-app-update.yml control it (defaults to "latest")
|
||||
autoUpdater.forceDevUpdateConfig = true;
|
||||
logger.info(
|
||||
`Using dev update config (isDev=${isDev}, FORCE_DEV_UPDATE_CONFIG=${FORCE_DEV_UPDATE_CONFIG})`,
|
||||
);
|
||||
logger.info('Dev mode: Using dev-app-update.yml for update configuration');
|
||||
} else {
|
||||
// Only configure channel and update provider programmatically in production
|
||||
// Note: channel is configured in configureUpdateProvider based on provider type
|
||||
autoUpdater.allowPrerelease = channel !== 'stable';
|
||||
logger.info(`Production mode: channel=${channel}, allowPrerelease=${channel !== 'stable'}`);
|
||||
this.configureUpdateProvider();
|
||||
}
|
||||
|
||||
// Register events
|
||||
|
|
@ -291,6 +312,79 @@ export class UpdaterManager {
|
|||
}, 300);
|
||||
};
|
||||
|
||||
/**
|
||||
* Configure update provider based on channel
|
||||
* - Stable channel + UPDATE_SERVER_URL: Use generic HTTP provider (S3) as primary, channel=stable
|
||||
* - Other channels (beta/nightly) or no S3: Use GitHub provider, channel=latest
|
||||
*
|
||||
* Important: S3 has stable-mac.yml, GitHub has latest-mac.yml
|
||||
*/
|
||||
private configureUpdateProvider() {
|
||||
if (isStableChannel && UPDATE_SERVER_URL && !this.usingFallbackProvider) {
|
||||
// Stable channel uses custom update server (generic HTTP) as primary
|
||||
// S3 has stable-mac.yml, so we set channel to 'stable'
|
||||
autoUpdater.channel = 'stable';
|
||||
logger.info(`Configuring generic provider for stable channel (primary)`);
|
||||
logger.info(`Update server URL: ${UPDATE_SERVER_URL}`);
|
||||
logger.info(`Channel set to: stable (will look for stable-mac.yml)`);
|
||||
|
||||
autoUpdater.setFeedURL({
|
||||
provider: 'generic',
|
||||
url: UPDATE_SERVER_URL,
|
||||
});
|
||||
} else {
|
||||
// Beta/nightly channels use GitHub, or fallback to GitHub if UPDATE_SERVER_URL not configured
|
||||
// GitHub releases have latest-mac.yml, so we use default channel (latest)
|
||||
autoUpdater.channel = 'latest';
|
||||
const reason = this.usingFallbackProvider ? '(fallback from S3)' : '';
|
||||
logger.info(`Configuring GitHub provider for ${channel} channel ${reason}`);
|
||||
logger.info(`Channel set to: latest (will look for latest-mac.yml)`);
|
||||
|
||||
autoUpdater.setFeedURL({
|
||||
owner: githubConfig.owner,
|
||||
provider: 'github',
|
||||
repo: githubConfig.repo,
|
||||
});
|
||||
|
||||
logger.info(`GitHub update URL configured: ${githubConfig.owner}/${githubConfig.repo}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to fallback provider (GitHub) and retry update check
|
||||
* Called when primary provider (S3) fails
|
||||
*/
|
||||
private switchToFallbackAndRetry = async () => {
|
||||
// Only fallback if we're on stable channel with S3 configured and haven't already fallen back
|
||||
if (!isStableChannel || !UPDATE_SERVER_URL || this.usingFallbackProvider) {
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.info('Primary update server (S3) failed, switching to GitHub fallback...');
|
||||
this.usingFallbackProvider = true;
|
||||
this.configureUpdateProvider();
|
||||
|
||||
// Retry update check with fallback provider
|
||||
try {
|
||||
await autoUpdater.checkForUpdates();
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Fallback provider (GitHub) also failed:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Reset to primary provider for next update check
|
||||
*/
|
||||
private resetToPrimaryProvider = () => {
|
||||
if (this.usingFallbackProvider) {
|
||||
logger.info('Resetting to primary update provider (S3)');
|
||||
this.usingFallbackProvider = false;
|
||||
this.configureUpdateProvider();
|
||||
}
|
||||
};
|
||||
|
||||
private registerEvents() {
|
||||
logger.debug('Registering updater events');
|
||||
|
||||
|
|
@ -302,6 +396,9 @@ export class UpdaterManager {
|
|||
logger.info(`Update available: ${info.version}`);
|
||||
this.updateAvailable = true;
|
||||
|
||||
// Reset to primary provider for next check cycle
|
||||
this.resetToPrimaryProvider();
|
||||
|
||||
if (this.isManualCheck) {
|
||||
this.mainWindow.broadcast('manualUpdateAvailable', info);
|
||||
} else {
|
||||
|
|
@ -313,13 +410,27 @@ export class UpdaterManager {
|
|||
|
||||
autoUpdater.on('update-not-available', (info) => {
|
||||
logger.info(`Update not available. Current: ${info.version}`);
|
||||
|
||||
// Reset to primary provider for next check cycle
|
||||
this.resetToPrimaryProvider();
|
||||
|
||||
if (this.isManualCheck) {
|
||||
this.mainWindow.broadcast('manualUpdateNotAvailable', info);
|
||||
}
|
||||
});
|
||||
|
||||
autoUpdater.on('error', (err) => {
|
||||
autoUpdater.on('error', async (err) => {
|
||||
logger.error('Error in auto-updater:', err);
|
||||
|
||||
// Try fallback to GitHub if S3 failed
|
||||
if (!this.usingFallbackProvider && isStableChannel && UPDATE_SERVER_URL) {
|
||||
logger.info('Attempting fallback to GitHub provider...');
|
||||
const fallbackSucceeded = await this.switchToFallbackAndRetry();
|
||||
if (fallbackSucceeded) {
|
||||
return; // Fallback initiated, don't report error yet
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isManualCheck) {
|
||||
this.mainWindow.broadcast('updateError', err.message);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ vi.mock('electron-updater', () => ({
|
|||
logger: null as any,
|
||||
on: vi.fn(),
|
||||
quitAndInstall: vi.fn(),
|
||||
setFeedURL: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
@ -62,6 +63,12 @@ vi.mock('@/utils/logger', () => ({
|
|||
// Mock updater configs
|
||||
vi.mock('@/modules/updater/configs', () => ({
|
||||
UPDATE_CHANNEL: 'stable',
|
||||
UPDATE_SERVER_URL: 'https://mock.update.server',
|
||||
githubConfig: {
|
||||
owner: 'lobehub',
|
||||
repo: 'lobe-chat',
|
||||
},
|
||||
isStableChannel: true,
|
||||
updaterConfig: {
|
||||
app: {
|
||||
autoCheckUpdate: false,
|
||||
|
|
@ -73,6 +80,13 @@ vi.mock('@/modules/updater/configs', () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
// Mock env
|
||||
vi.mock('@/env', () => ({
|
||||
getDesktopEnv: () => ({
|
||||
FORCE_DEV_UPDATE_CONFIG: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock isDev
|
||||
vi.mock('@/const/env', () => ({
|
||||
isDev: false,
|
||||
|
|
@ -454,9 +468,11 @@ describe('UpdaterManager', () => {
|
|||
vi.mocked(autoUpdater.checkForUpdates).mockResolvedValue({} as any);
|
||||
await updaterManager.checkForUpdates({ manual: true });
|
||||
|
||||
vi.mocked(autoUpdater.checkForUpdates).mockRejectedValueOnce(new Error('Fallback failed'));
|
||||
|
||||
const error = new Error('Update error');
|
||||
const handler = registeredEvents.get('error');
|
||||
handler?.(error);
|
||||
await handler?.(error);
|
||||
|
||||
expect(mockBroadcast).toHaveBeenCalledWith('updateError', 'Update error');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -59,29 +59,37 @@ const envNumber = (defaultValue: number) =>
|
|||
*/
|
||||
export const getDesktopEnv = memoize(() =>
|
||||
createEnv({
|
||||
client: {},
|
||||
clientPrefix: 'PUBLIC_',
|
||||
emptyStringAsUndefined: true,
|
||||
isServer: true,
|
||||
runtimeEnv: process.env,
|
||||
server: {
|
||||
DEBUG_VERBOSE: envBoolean(false),
|
||||
|
||||
// escape hatch: allow testing static renderer in dev via env
|
||||
DESKTOP_RENDERER_STATIC: envBoolean(false),
|
||||
|
||||
// Force use dev-app-update.yml even in packaged app (for testing updates)
|
||||
FORCE_DEV_UPDATE_CONFIG: envBoolean(false),
|
||||
|
||||
// mcp client
|
||||
MCP_TOOL_TIMEOUT: envNumber(60_000),
|
||||
|
||||
// keep optional to preserve existing behavior:
|
||||
// - unset NODE_ENV should behave like "not production" in logger runtime paths
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).optional(),
|
||||
|
||||
// escape hatch: allow testing static renderer in dev via env
|
||||
DESKTOP_RENDERER_STATIC: envBoolean(false),
|
||||
|
||||
// updater
|
||||
UPDATE_CHANNEL: z.string().optional(),
|
||||
|
||||
// mcp client
|
||||
MCP_TOOL_TIMEOUT: envNumber(60_000),
|
||||
|
||||
// cloud server url (can be overridden for selfhost/dev)
|
||||
OFFICIAL_CLOUD_SERVER: z.string().optional().default('https://app.lobehub.com'),
|
||||
|
||||
// updater
|
||||
// process.env.xxx will replace in build stage
|
||||
UPDATE_CHANNEL: z.string().optional().default(process.env.UPDATE_CHANNEL),
|
||||
|
||||
// Custom update server URL (for stable channel)
|
||||
// e.g., https://releases.lobehub.com/stable or https://your-bucket.s3.amazonaws.com/releases
|
||||
UPDATE_SERVER_URL: z.string().optional().default(process.env.UPDATE_SERVER_URL),
|
||||
},
|
||||
clientPrefix: 'PUBLIC_',
|
||||
client: {},
|
||||
runtimeEnv: process.env,
|
||||
emptyStringAsUndefined: true,
|
||||
isServer: true,
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,20 @@ import { isDev } from '@/const/env';
|
|||
import { getDesktopEnv } from '@/env';
|
||||
|
||||
// 更新频道(stable, beta, alpha 等)
|
||||
export const UPDATE_CHANNEL = getDesktopEnv().UPDATE_CHANNEL;
|
||||
export const UPDATE_CHANNEL = getDesktopEnv().UPDATE_CHANNEL || 'stable';
|
||||
|
||||
// 判断是否为 stable 频道
|
||||
export const isStableChannel = UPDATE_CHANNEL === 'stable' || !UPDATE_CHANNEL;
|
||||
|
||||
// 自定义更新服务器 URL (用于 stable 频道)
|
||||
// e.g., https://releases.lobehub.com/stable
|
||||
export const UPDATE_SERVER_URL = getDesktopEnv().UPDATE_SERVER_URL;
|
||||
|
||||
// GitHub 配置 (用于 beta/nightly 频道,或作为 fallback)
|
||||
export const githubConfig = {
|
||||
owner: 'lobehub',
|
||||
repo: 'lobe-chat',
|
||||
};
|
||||
|
||||
export const updaterConfig = {
|
||||
// 应用更新配置
|
||||
|
|
|
|||
5
conductor.json
Normal file
5
conductor.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"scripts": {
|
||||
"setup": "bash \"$CONDUCTOR_ROOT_PATH/.conductor/setup.sh\""
|
||||
}
|
||||
}
|
||||
|
|
@ -54,11 +54,11 @@
|
|||
"funds.packages.expiresIn": "Expires in {{days}} days",
|
||||
"funds.packages.expiresToday": "Expires today",
|
||||
"funds.packages.expiringSoon": "Expiring soon",
|
||||
"funds.packages.gift": "Gift",
|
||||
"funds.packages.giftedOn": "Gifted on {{date}}",
|
||||
"funds.packages.noPackages": "No credit packages",
|
||||
"funds.packages.purchaseFirst": "Purchase your first credit package",
|
||||
"funds.packages.purchasedOn": "Purchased on {{date}}",
|
||||
"funds.packages.gift": "Gift",
|
||||
"funds.packages.giftedOn": "Gifted on {{date}}",
|
||||
"funds.packages.sort.amountAsc": "Amount: Low to High",
|
||||
"funds.packages.sort.amountDesc": "Amount: High to Low",
|
||||
"funds.packages.sort.balanceAsc": "Balance: Low to High",
|
||||
|
|
|
|||
|
|
@ -54,11 +54,11 @@
|
|||
"funds.packages.expiresIn": "{{days}} 天后过期",
|
||||
"funds.packages.expiresToday": "今日过期",
|
||||
"funds.packages.expiringSoon": "即将过期",
|
||||
"funds.packages.gift": "赠送",
|
||||
"funds.packages.giftedOn": "赠送于 {{date}}",
|
||||
"funds.packages.noPackages": "暂无积分包",
|
||||
"funds.packages.purchaseFirst": "购买您的第一个积分包",
|
||||
"funds.packages.purchasedOn": "购买于 {{date}}",
|
||||
"funds.packages.gift": "赠送",
|
||||
"funds.packages.giftedOn": "赠送于 {{date}}",
|
||||
"funds.packages.sort.amountAsc": "金额:从低到高",
|
||||
"funds.packages.sort.amountDesc": "金额:从高到低",
|
||||
"funds.packages.sort.balanceAsc": "余额:从低到高",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { ActionIcon, CopyButton, Flexbox, Markdown, ScrollShadow } from '@lobehub/ui';
|
||||
import { ActionIcon, CopyButton, Flexbox, Markdown, ScrollShadow, TooltipGroup } from '@lobehub/ui';
|
||||
import { Button } from 'antd';
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
import { Maximize2, Minimize2, NotebookText, PencilLine } from 'lucide-react';
|
||||
|
|
@ -85,6 +85,7 @@ const DocumentCard = memo<DocumentCardProps>(({ document }) => {
|
|||
<Flexbox flex={1}>
|
||||
<div className={styles.title}>{document.title}</div>
|
||||
</Flexbox>
|
||||
<TooltipGroup>
|
||||
<Flexbox gap={4} horizontal>
|
||||
<CopyButton
|
||||
content={document.content}
|
||||
|
|
@ -98,6 +99,7 @@ const DocumentCard = memo<DocumentCardProps>(({ document }) => {
|
|||
title={t('builtins.lobe-notebook.actions.edit')}
|
||||
/>
|
||||
</Flexbox>
|
||||
</TooltipGroup>
|
||||
</Flexbox>
|
||||
{/* Content */}
|
||||
<ScrollShadow className={styles.content} offset={12} size={12} style={{ maxHeight: 400 }}>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useLayoutEffect, useRef } from 'react';
|
||||
|
||||
import { MainBroadcastEventKey, MainBroadcastParams } from './events';
|
||||
|
||||
|
|
@ -21,11 +21,17 @@ export const useWatchBroadcast = <T extends MainBroadcastEventKey>(
|
|||
event: T,
|
||||
handler: (data: MainBroadcastParams<T>) => void,
|
||||
) => {
|
||||
const handlerRef = useRef<typeof handler>(handler);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
handlerRef.current = handler;
|
||||
}, [handler]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.electron) return;
|
||||
|
||||
const listener = (e: any, data: MainBroadcastParams<T>) => {
|
||||
handler(data);
|
||||
const listener = (_e: any, data: MainBroadcastParams<T>) => {
|
||||
handlerRef.current(data);
|
||||
};
|
||||
|
||||
window.electron.ipcRenderer.on(event, listener);
|
||||
|
|
@ -33,5 +39,5 @@ export const useWatchBroadcast = <T extends MainBroadcastEventKey>(
|
|||
return () => {
|
||||
window.electron.ipcRenderer.removeListener(event, listener);
|
||||
};
|
||||
}, []);
|
||||
}, [event]);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ const qiniuChatModels: AIChatModelCard[] = [
|
|||
},
|
||||
contextWindowTokens: 65_536,
|
||||
description:
|
||||
"DeepSeek R1 is DeepSeek’s latest open model with very strong reasoning, matching OpenAI’s o1 on math, programming, and reasoning tasks.",
|
||||
'DeepSeek R1 is DeepSeek’s latest open model with very strong reasoning, matching OpenAI’s o1 on math, programming, and reasoning tasks.',
|
||||
displayName: 'DeepSeek R1',
|
||||
enabled: true,
|
||||
id: 'deepseek-r1',
|
||||
|
|
@ -89,7 +89,7 @@ const qiniuChatModels: AIChatModelCard[] = [
|
|||
displayName: 'LongCat Flash Chat',
|
||||
enabled: true,
|
||||
id: 'meituan/longcat-flash-chat',
|
||||
maxOutput: 65536,
|
||||
maxOutput: 65_536,
|
||||
pricing: {
|
||||
currency: 'CNY',
|
||||
units: [
|
||||
|
|
@ -112,7 +112,7 @@ const qiniuChatModels: AIChatModelCard[] = [
|
|||
},
|
||||
contextWindowTokens: 200_000,
|
||||
description:
|
||||
'GLM-4.7 is Zhipu\'s latest flagship model, offering improved general capabilities, simpler and more natural replies, and a more immersive writing experience.',
|
||||
"GLM-4.7 is Zhipu's latest flagship model, offering improved general capabilities, simpler and more natural replies, and a more immersive writing experience.",
|
||||
displayName: 'GLM-4.7',
|
||||
enabled: true,
|
||||
id: 'z-ai/glm-4.7',
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';
|
|||
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
|
||||
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
|
||||
import { PgInstrumentation } from '@opentelemetry/instrumentation-pg';
|
||||
import { resourceFromAttributes, DetectedResourceAttributes } from '@opentelemetry/resources';
|
||||
import { DetectedResourceAttributes, resourceFromAttributes } from '@opentelemetry/resources';
|
||||
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
|
||||
import { NodeSDK } from '@opentelemetry/sdk-node';
|
||||
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
|
||||
|
|
@ -16,48 +16,42 @@ export function attributesForVercel(): DetectedResourceAttributes {
|
|||
// Vercel.
|
||||
// https://vercel.com/docs/projects/environment-variables/system-environment-variables
|
||||
// Vercel Env set as top level attribute for simplicity. One of 'production', 'preview' or 'development'.
|
||||
env: process.env.VERCEL_ENV || process.env.NEXT_PUBLIC_VERCEL_ENV,
|
||||
'env': process.env.VERCEL_ENV || process.env.NEXT_PUBLIC_VERCEL_ENV,
|
||||
|
||||
"vercel.branch_host":
|
||||
process.env.VERCEL_BRANCH_URL ||
|
||||
process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL ||
|
||||
undefined,
|
||||
"vercel.deployment_id": process.env.VERCEL_DEPLOYMENT_ID || undefined,
|
||||
"vercel.host":
|
||||
process.env.VERCEL_URL ||
|
||||
process.env.NEXT_PUBLIC_VERCEL_URL ||
|
||||
undefined,
|
||||
"vercel.project_id": process.env.VERCEL_PROJECT_ID || undefined,
|
||||
"vercel.region": process.env.VERCEL_REGION,
|
||||
"vercel.runtime": process.env.NEXT_RUNTIME || "nodejs",
|
||||
"vercel.sha":
|
||||
process.env.VERCEL_GIT_COMMIT_SHA ||
|
||||
process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA,
|
||||
'vercel.branch_host':
|
||||
process.env.VERCEL_BRANCH_URL || process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL || undefined,
|
||||
'vercel.deployment_id': process.env.VERCEL_DEPLOYMENT_ID || undefined,
|
||||
'vercel.host': process.env.VERCEL_URL || process.env.NEXT_PUBLIC_VERCEL_URL || undefined,
|
||||
'vercel.project_id': process.env.VERCEL_PROJECT_ID || undefined,
|
||||
'vercel.region': process.env.VERCEL_REGION,
|
||||
'vercel.runtime': process.env.NEXT_RUNTIME || 'nodejs',
|
||||
'vercel.sha':
|
||||
process.env.VERCEL_GIT_COMMIT_SHA || process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA,
|
||||
|
||||
'service.version': process.env.VERCEL_DEPLOYMENT_ID,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function attributesForNodejs(): DetectedResourceAttributes {
|
||||
return {
|
||||
// Node.
|
||||
"node.ci": process.env.CI ? true : undefined,
|
||||
"node.env": process.env.NODE_ENV,
|
||||
}
|
||||
'node.ci': process.env.CI ? true : undefined,
|
||||
'node.env': process.env.NODE_ENV,
|
||||
};
|
||||
}
|
||||
|
||||
export function attributesForEnv(): DetectedResourceAttributes {
|
||||
return {
|
||||
...attributesForVercel(),
|
||||
...attributesForNodejs(),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function attributesCommon(): DetectedResourceAttributes {
|
||||
return {
|
||||
[ATTR_SERVICE_NAME]: 'lobe-chat',
|
||||
...attributesForEnv(),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function debugLogLevelFromString(level?: string | null): DiagLogLevel | undefined {
|
||||
|
|
@ -69,26 +63,38 @@ function debugLogLevelFromString(level?: string | null): DiagLogLevel | undefine
|
|||
}
|
||||
|
||||
switch (level.toLowerCase()) {
|
||||
case 'none':
|
||||
case 'none': {
|
||||
return DiagLogLevel.NONE;
|
||||
case 'error':
|
||||
}
|
||||
case 'error': {
|
||||
return DiagLogLevel.ERROR;
|
||||
case 'warn':
|
||||
}
|
||||
case 'warn': {
|
||||
return DiagLogLevel.WARN;
|
||||
case 'info':
|
||||
}
|
||||
case 'info': {
|
||||
return DiagLogLevel.INFO;
|
||||
case 'debug':
|
||||
}
|
||||
case 'debug': {
|
||||
return DiagLogLevel.DEBUG;
|
||||
case 'verbose':
|
||||
}
|
||||
case 'verbose': {
|
||||
return DiagLogLevel.VERBOSE;
|
||||
case 'all':
|
||||
}
|
||||
case 'all': {
|
||||
return DiagLogLevel.ALL;
|
||||
default:
|
||||
}
|
||||
default: {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function register(options?: { debug?: true | DiagLogLevel; name?: string; version?: string }) {
|
||||
export function register(options?: {
|
||||
debug?: true | DiagLogLevel;
|
||||
name?: string;
|
||||
version?: string;
|
||||
}) {
|
||||
const attributes = attributesCommon();
|
||||
|
||||
if (typeof options?.name !== 'undefined') {
|
||||
|
|
@ -102,11 +108,7 @@ export function register(options?: { debug?: true | DiagLogLevel; name?: string;
|
|||
|
||||
diag.setLogger(
|
||||
new DiagConsoleLogger(),
|
||||
!!levelFromEnv
|
||||
? levelFromEnv
|
||||
: options?.debug === true
|
||||
? DiagLogLevel.DEBUG
|
||||
: options?.debug,
|
||||
!!levelFromEnv ? levelFromEnv : options?.debug === true ? DiagLogLevel.DEBUG : options?.debug,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ import path from 'node:path';
|
|||
import YAML from 'yaml';
|
||||
|
||||
// 配置
|
||||
const FILE_NAME = 'latest-mac.yml';
|
||||
// Support both stable-mac.yml (stable channel) and latest-mac.yml (fallback)
|
||||
const STABLE_outputFileName = 'stable-mac.yml';
|
||||
const LATEST_outputFileName = 'latest-mac.yml';
|
||||
const RELEASE_DIR = path.resolve('release');
|
||||
|
||||
/**
|
||||
|
|
@ -85,11 +87,23 @@ async function main() {
|
|||
const releaseFiles = fs.readdirSync(RELEASE_DIR);
|
||||
console.log(`📂 Files in release directory: ${releaseFiles.join(', ')}`);
|
||||
|
||||
// 2. 查找所有 latest-mac*.yml 文件
|
||||
const macYmlFiles = releaseFiles.filter(
|
||||
// 2. 查找所有 stable-mac*.yml 和 latest-mac*.yml 文件
|
||||
// Prioritize stable-mac*.yml, fallback to latest-mac*.yml
|
||||
const stableMacYmlFiles = releaseFiles.filter(
|
||||
(f) => f.startsWith('stable-mac') && f.endsWith('.yml'),
|
||||
);
|
||||
const latestMacYmlFiles = releaseFiles.filter(
|
||||
(f) => f.startsWith('latest-mac') && f.endsWith('.yml'),
|
||||
);
|
||||
console.log(`🔍 Found macOS YAML files: ${macYmlFiles.join(', ')}`);
|
||||
|
||||
// Use stable files if available, otherwise use latest
|
||||
const macYmlFiles = stableMacYmlFiles.length > 0 ? stableMacYmlFiles : latestMacYmlFiles;
|
||||
const outputFileName =
|
||||
stableMacYmlFiles.length > 0 ? STABLE_outputFileName : LATEST_outputFileName;
|
||||
|
||||
console.log(`🔍 Found stable macOS YAML files: ${stableMacYmlFiles.join(', ') || 'none'}`);
|
||||
console.log(`🔍 Found latest macOS YAML files: ${latestMacYmlFiles.join(', ') || 'none'}`);
|
||||
console.log(`🔍 Using files: ${macYmlFiles.join(', ')} -> ${outputFileName}`);
|
||||
|
||||
if (macYmlFiles.length === 0) {
|
||||
console.log('⚠️ No macOS YAML files found, skipping merge');
|
||||
|
|
@ -115,7 +129,7 @@ async function main() {
|
|||
} else if (platform === 'both') {
|
||||
console.log(`✅ Found already merged file: ${fileName}`);
|
||||
// 如果已经是合并后的文件,直接复制为最终文件
|
||||
writeLocalFile(path.join(RELEASE_DIR, FILE_NAME), content);
|
||||
writeLocalFile(path.join(RELEASE_DIR, outputFileName), content);
|
||||
return;
|
||||
} else {
|
||||
console.log(`⚠️ Unknown platform type: ${platform} in ${fileName}`);
|
||||
|
|
@ -136,13 +150,13 @@ async function main() {
|
|||
|
||||
if (x64Files.length === 0) {
|
||||
console.log('⚠️ No x64 files found, using ARM64 only');
|
||||
writeLocalFile(path.join(RELEASE_DIR, FILE_NAME), arm64Files[0].content);
|
||||
writeLocalFile(path.join(RELEASE_DIR, outputFileName), arm64Files[0].content);
|
||||
return;
|
||||
}
|
||||
|
||||
if (arm64Files.length === 0) {
|
||||
console.log('⚠️ No ARM64 files found, using x64 only');
|
||||
writeLocalFile(path.join(RELEASE_DIR, FILE_NAME), x64Files[0].content);
|
||||
writeLocalFile(path.join(RELEASE_DIR, outputFileName), x64Files[0].content);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -154,7 +168,7 @@ async function main() {
|
|||
const mergedContent = mergeYamlFiles(x64File.yaml, arm64File.yaml);
|
||||
|
||||
// 6. 保存合并后的文件
|
||||
const mergedFilePath = path.join(RELEASE_DIR, FILE_NAME);
|
||||
const mergedFilePath = path.join(RELEASE_DIR, outputFileName);
|
||||
writeLocalFile(mergedFilePath, mergedContent);
|
||||
|
||||
// 7. 验证合并结果
|
||||
|
|
|
|||
13
src/app/(backend)/api/version/route.ts
Normal file
13
src/app/(backend)/api/version/route.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
|
||||
import pkg from '../../../../../package.json';
|
||||
|
||||
export interface VersionResponseData {
|
||||
version: string;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
version: pkg.version,
|
||||
} satisfies VersionResponseData);
|
||||
}
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
'use client';
|
||||
|
||||
import { TITLE_BAR_HEIGHT } from '@lobechat/desktop-bridge';
|
||||
import { Center, Flexbox, Text } from '@lobehub/ui';
|
||||
import { Divider } from 'antd';
|
||||
import { cx } from 'antd-style';
|
||||
import type { FC, PropsWithChildren } from 'react';
|
||||
|
||||
import { SimpleTitleBar, TITLE_BAR_HEIGHT } from '@/features/ElectronTitlebar';
|
||||
import SimpleTitleBar from '@/features/Electron/titlebar/SimpleTitleBar';
|
||||
import LangButton from '@/features/User/UserPanel/LangButton';
|
||||
import ThemeButton from '@/features/User/UserPanel/ThemeButton';
|
||||
import { useIsDark } from '@/hooks/useIsDark';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { TITLE_BAR_HEIGHT } from '@lobechat/desktop-bridge';
|
||||
import { Flexbox } from '@lobehub/ui';
|
||||
import { cx } from 'antd-style';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
|
@ -12,7 +13,7 @@ import Loading from '@/components/Loading/BrandTextLoading';
|
|||
import { isDesktop } from '@/const/version';
|
||||
import { BANNER_HEIGHT } from '@/features/AlertBanner/CloudBanner';
|
||||
import DesktopNavigationBridge from '@/features/DesktopNavigationBridge';
|
||||
import TitleBar, { TITLE_BAR_HEIGHT } from '@/features/ElectronTitlebar';
|
||||
import TitleBar from '@/features/Electron/titlebar/TitleBar';
|
||||
import HotkeyHelperPanel from '@/features/HotkeyHelperPanel';
|
||||
import NavPanel from '@/features/NavPanel';
|
||||
import { useFeedbackModal } from '@/hooks/useFeedbackModal';
|
||||
|
|
|
|||
|
|
@ -187,7 +187,6 @@ const CronJobScheduleConfig = memo<CronJobScheduleConfigProps>(
|
|||
style={{ maxWidth: 300, minWidth: 200 }}
|
||||
value={timezone}
|
||||
/>
|
||||
|
||||
</Flexbox>
|
||||
|
||||
{/* Max Executions */}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,9 @@ const ThreadHydration = memo(() => {
|
|||
// should open portal automatically when portalThread is set
|
||||
useEffect(() => {
|
||||
if (!!portalThread && !useChatStore.getState().showPortal) {
|
||||
useChatStore.getState().pushPortalView({ threadId: portalThread, type: PortalViewType.Thread });
|
||||
useChatStore
|
||||
.getState()
|
||||
.pushPortalView({ threadId: portalThread, type: PortalViewType.Thread });
|
||||
}
|
||||
}, [portalThread]);
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,9 @@ const ThreadHydration = memo(() => {
|
|||
// should open portal automatically when portalThread is set
|
||||
useEffect(() => {
|
||||
if (!!portalThread && !useChatStore.getState().showPortal) {
|
||||
useChatStore.getState().pushPortalView({ threadId: portalThread, type: PortalViewType.Thread });
|
||||
useChatStore
|
||||
.getState()
|
||||
.pushPortalView({ threadId: portalThread, type: PortalViewType.Thread });
|
||||
}
|
||||
}, [portalThread]);
|
||||
|
||||
|
|
|
|||
|
|
@ -259,10 +259,7 @@ export const mobileRoutes: RouteConfig[] = [
|
|||
{
|
||||
children: [
|
||||
{
|
||||
element: dynamicElement(
|
||||
() => import('../../share/t/[id]'),
|
||||
'Mobile > Share > Topic',
|
||||
),
|
||||
element: dynamicElement(() => import('../../share/t/[id]'), 'Mobile > Share > Topic'),
|
||||
path: ':id',
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -403,10 +403,7 @@ export const desktopRoutes: RouteConfig[] = [
|
|||
{
|
||||
children: [
|
||||
{
|
||||
element: dynamicElement(
|
||||
() => import('../share/t/[id]'),
|
||||
'Desktop > Share > Topic',
|
||||
),
|
||||
element: dynamicElement(() => import('../share/t/[id]'), 'Desktop > Share > Topic'),
|
||||
path: ':id',
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,48 +0,0 @@
|
|||
import { App } from 'antd';
|
||||
import { type ModalFuncProps } from 'antd/es/modal/interface';
|
||||
import { type MutableRefObject, type ReactNode, type RefObject, useRef } from 'react';
|
||||
|
||||
import { closeIcon, styles } from './style';
|
||||
|
||||
interface CreateModalProps extends ModalFuncProps {
|
||||
content: ReactNode;
|
||||
}
|
||||
|
||||
interface ModalInstance {
|
||||
destroy: (...args: any[]) => void;
|
||||
}
|
||||
|
||||
type PropsFunc<T = undefined> = (
|
||||
instance: MutableRefObject<ModalInstance | undefined>,
|
||||
props?: T,
|
||||
) => CreateModalProps;
|
||||
|
||||
const createModal = <T>(params: CreateModalProps | PropsFunc<T>) => {
|
||||
const useModal = () => {
|
||||
const { modal } = App.useApp();
|
||||
const instanceRef = useRef<ModalInstance>(null);
|
||||
|
||||
const open = (outProps?: T) => {
|
||||
const props =
|
||||
typeof params === 'function'
|
||||
? params(instanceRef as RefObject<ModalInstance>, outProps)
|
||||
: params;
|
||||
|
||||
instanceRef.current = modal.confirm({
|
||||
className: styles.content,
|
||||
closable: true,
|
||||
closeIcon,
|
||||
footer: false,
|
||||
icon: null,
|
||||
wrapClassName: styles.wrap,
|
||||
...props,
|
||||
});
|
||||
};
|
||||
|
||||
return { open };
|
||||
};
|
||||
|
||||
return useModal;
|
||||
};
|
||||
|
||||
export { createModal };
|
||||
|
|
@ -1 +0,0 @@
|
|||
export * from './createModalHooks';
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
import { Icon } from '@lobehub/ui';
|
||||
import { createStaticStyles , responsive } from 'antd-style';
|
||||
import { XIcon } from 'lucide-react';
|
||||
|
||||
const prefixCls = 'ant';
|
||||
|
||||
export const styles = createStaticStyles(({ css, cssVar }) => {
|
||||
return {
|
||||
content: css`
|
||||
.${prefixCls}-modal-container {
|
||||
overflow: hidden;
|
||||
|
||||
width: min(90vw, 450px);
|
||||
padding: 0;
|
||||
border: 1px solid ${cssVar.colorSplit};
|
||||
border-radius: ${cssVar.borderRadiusLG};
|
||||
|
||||
background: ${cssVar.colorBgLayout};
|
||||
|
||||
${responsive.sm} {
|
||||
width: unset;
|
||||
}
|
||||
}
|
||||
.${prefixCls}-modal-confirm-title {
|
||||
display: block;
|
||||
padding-block: 16px 0;
|
||||
padding-inline: 16px;
|
||||
}
|
||||
.${prefixCls}-modal-confirm-btns {
|
||||
margin-block-start: 0;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.${prefixCls}-modal-confirm-paragraph {
|
||||
max-width: 100%;
|
||||
}
|
||||
`,
|
||||
wrap: css`
|
||||
overflow: hidden auto;
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
||||
export const closeIcon = <Icon icon={XIcon} size={20} />;
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { TITLE_BAR_HEIGHT } from '@lobechat/desktop-bridge';
|
||||
import { exportFile } from '@lobechat/utils/client';
|
||||
import { Block, Button, Flexbox, Highlighter, Segmented } from '@lobehub/ui';
|
||||
import { Drawer } from 'antd';
|
||||
|
|
@ -7,7 +8,6 @@ import { memo, useCallback, useState } from 'react';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { isDesktop } from '@/const/version';
|
||||
import { TITLE_BAR_HEIGHT } from '@/features/ElectronTitlebar';
|
||||
|
||||
const styles = createStaticStyles(({ css }) => ({
|
||||
container: css`
|
||||
|
|
|
|||
|
|
@ -25,10 +25,20 @@ const GroupItem = memo<GroupItemProps>(
|
|||
toggleMessageEditing(item.id, true);
|
||||
}}
|
||||
>
|
||||
<ContentBlock {...item} assistantId={assistantId} disableEditing={disableEditing} error={error} />
|
||||
<ContentBlock
|
||||
{...item}
|
||||
assistantId={assistantId}
|
||||
disableEditing={disableEditing}
|
||||
error={error}
|
||||
/>
|
||||
</Flexbox>
|
||||
) : (
|
||||
<ContentBlock {...item} assistantId={assistantId} disableEditing={disableEditing} error={error} />
|
||||
<ContentBlock
|
||||
{...item}
|
||||
assistantId={assistantId}
|
||||
disableEditing={disableEditing}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
},
|
||||
isEqual,
|
||||
|
|
|
|||
|
|
@ -33,7 +33,16 @@ export interface InspectorProps {
|
|||
* Tool message component - adapts Tool message data to use AssistantGroup/Tool components
|
||||
*/
|
||||
const Tool = memo<InspectorProps>(
|
||||
({ arguments: requestArgs, apiName, disableEditing, messageId, toolCallId, index, identifier, type }) => {
|
||||
({
|
||||
arguments: requestArgs,
|
||||
apiName,
|
||||
disableEditing,
|
||||
messageId,
|
||||
toolCallId,
|
||||
index,
|
||||
identifier,
|
||||
type,
|
||||
}) => {
|
||||
const [showDebug, setShowDebug] = useState(false);
|
||||
const [showPluginRender, setShowPluginRender] = useState(false);
|
||||
const [expand, setExpand] = useState(true);
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { useLocation, useNavigate } from 'react-router-dom';
|
|||
|
||||
import { useElectronStore } from '@/store/electron';
|
||||
|
||||
import { getRouteMetadata } from '../helpers/routeMetadata';
|
||||
import { getRouteMetadata } from './routeMetadata';
|
||||
|
||||
/**
|
||||
* Hook to manage navigation history in Electron desktop app
|
||||
|
|
@ -10,7 +10,7 @@ import { systemStatusSelectors } from '@/store/global/selectors';
|
|||
import { electronStylish } from '@/styles/electron';
|
||||
import { isMacOS } from '@/utils/platform';
|
||||
|
||||
import { useNavigationHistory } from '../hooks/useNavigationHistory';
|
||||
import { useNavigationHistory } from '../navigation/useNavigationHistory';
|
||||
import RecentlyViewed from './RecentlyViewed';
|
||||
|
||||
const isMac = isMacOS();
|
||||
|
|
@ -9,7 +9,7 @@ import { useNavigate } from 'react-router-dom';
|
|||
import { useElectronStore } from '@/store/electron';
|
||||
import type { HistoryEntry } from '@/store/electron/actions/navigationHistory';
|
||||
|
||||
import { getRouteIcon } from '../helpers/routeMetadata';
|
||||
import { getRouteIcon } from '../navigation/routeMetadata';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
container: css`
|
||||
|
|
@ -1,18 +1,19 @@
|
|||
import { TITLE_BAR_HEIGHT } from '@lobechat/desktop-bridge';
|
||||
import { useWatchBroadcast } from '@lobechat/electron-client-ipc';
|
||||
import { Flexbox } from '@lobehub/ui';
|
||||
import { Divider } from 'antd';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { memo, useMemo, useRef } from 'react';
|
||||
|
||||
import { useElectronStore } from '@/store/electron';
|
||||
import { electronStylish } from '@/styles/electron';
|
||||
import { isMacOS } from '@/utils/platform';
|
||||
|
||||
import Connection from './Connection';
|
||||
import Connection from '../connection/Connection';
|
||||
import { useWatchThemeUpdate } from '../system/useWatchThemeUpdate';
|
||||
import { useUpdateModal } from '../updater/UpdateModal';
|
||||
import { UpdateNotification } from '../updater/UpdateNotification';
|
||||
import NavigationBar from './NavigationBar';
|
||||
import { UpdateModal } from './UpdateModal';
|
||||
import { UpdateNotification } from './UpdateNotification';
|
||||
import WinControl from './WinControl';
|
||||
import { useWatchThemeUpdate } from './hooks/useWatchThemeUpdate';
|
||||
|
||||
const isMac = isMacOS();
|
||||
|
||||
|
|
@ -25,6 +26,19 @@ const TitleBar = memo(() => {
|
|||
initElectronAppState();
|
||||
useWatchThemeUpdate();
|
||||
|
||||
const { open: openUpdateModal } = useUpdateModal();
|
||||
const updateModalOpenRef = useRef(false);
|
||||
|
||||
useWatchBroadcast('manualUpdateCheckStart', () => {
|
||||
if (updateModalOpenRef.current) return;
|
||||
updateModalOpenRef.current = true;
|
||||
openUpdateModal({
|
||||
onAfterClose: () => {
|
||||
updateModalOpenRef.current = false;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const showWinControl = isAppStateInit && !isMac;
|
||||
|
||||
const padding = useMemo(() => {
|
||||
|
|
@ -59,12 +73,8 @@ const TitleBar = memo(() => {
|
|||
</>
|
||||
)}
|
||||
</Flexbox>
|
||||
<UpdateModal />
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
export default TitleBar;
|
||||
|
||||
export { default as SimpleTitleBar } from './SimpleTitleBar';
|
||||
export { TITLE_BAR_HEIGHT } from '@lobechat/desktop-bridge';
|
||||
5
src/features/Electron/titlebar/WinControl.tsx
Normal file
5
src/features/Electron/titlebar/WinControl.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
const WinControl = () => {
|
||||
return <div style={{ width: 132 }} />;
|
||||
};
|
||||
|
||||
export default WinControl;
|
||||
299
src/features/Electron/updater/UpdateModal.tsx
Normal file
299
src/features/Electron/updater/UpdateModal.tsx
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
import {
|
||||
type ProgressInfo,
|
||||
type UpdateInfo,
|
||||
useWatchBroadcast,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
import { Button, Flexbox, type ModalInstance, createModal } from '@lobehub/ui';
|
||||
import { App, Progress, Spin } from 'antd';
|
||||
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { autoUpdateService } from '@/services/electron/autoUpdate';
|
||||
import { formatSpeed } from '@/utils/format';
|
||||
|
||||
type UpdateStage = 'checking' | 'available' | 'latest' | 'downloading' | 'downloaded';
|
||||
|
||||
interface ModalUpdateOptions {
|
||||
closable?: boolean;
|
||||
keyboard?: boolean;
|
||||
maskClosable?: boolean;
|
||||
title?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface UpdateModalContentProps {
|
||||
onClose: () => void;
|
||||
setModalProps: (props: ModalUpdateOptions) => void;
|
||||
}
|
||||
|
||||
const UpdateModalContent = memo<UpdateModalContentProps>(({ onClose, setModalProps }) => {
|
||||
const { t } = useTranslation(['electron', 'common']);
|
||||
const { modal } = App.useApp();
|
||||
const errorHandledRef = useRef(false);
|
||||
const isClosingRef = useRef(false);
|
||||
|
||||
const [stage, setStage] = useState<UpdateStage>('checking');
|
||||
const [updateAvailableInfo, setUpdateAvailableInfo] = useState<UpdateInfo | null>(null);
|
||||
const [downloadedInfo, setDownloadedInfo] = useState<UpdateInfo | null>(null);
|
||||
const [progress, setProgress] = useState<ProgressInfo | null>(null);
|
||||
const [latestVersionInfo, setLatestVersionInfo] = useState<UpdateInfo | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const isDownloading = stage === 'downloading';
|
||||
const modalTitle = (() => {
|
||||
switch (stage) {
|
||||
case 'checking': {
|
||||
return t('updater.checkingUpdate');
|
||||
}
|
||||
case 'available': {
|
||||
return t('updater.newVersionAvailable');
|
||||
}
|
||||
case 'downloading': {
|
||||
return t('updater.downloadingUpdate');
|
||||
}
|
||||
case 'downloaded': {
|
||||
return t('updater.updateReady');
|
||||
}
|
||||
case 'latest': {
|
||||
return t('updater.isLatestVersion');
|
||||
}
|
||||
default: {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
setModalProps({
|
||||
closable: !isDownloading,
|
||||
keyboard: !isDownloading,
|
||||
maskClosable: !isDownloading,
|
||||
title: modalTitle,
|
||||
});
|
||||
}, [setModalProps, stage, t]);
|
||||
|
||||
useWatchBroadcast('manualUpdateAvailable', (info: UpdateInfo) => {
|
||||
if (isClosingRef.current) return;
|
||||
setStage('available');
|
||||
setUpdateAvailableInfo(info);
|
||||
setDownloadedInfo(null);
|
||||
setLatestVersionInfo(null);
|
||||
});
|
||||
|
||||
useWatchBroadcast('manualUpdateNotAvailable', (info: UpdateInfo) => {
|
||||
if (isClosingRef.current) return;
|
||||
setStage('latest');
|
||||
setLatestVersionInfo(info);
|
||||
setUpdateAvailableInfo(null);
|
||||
setDownloadedInfo(null);
|
||||
setProgress(null);
|
||||
});
|
||||
|
||||
useWatchBroadcast('updateDownloadStart', () => {
|
||||
if (isClosingRef.current) return;
|
||||
setStage('downloading');
|
||||
setProgress({ bytesPerSecond: 0, percent: 0, total: 0, transferred: 0 });
|
||||
setUpdateAvailableInfo(null);
|
||||
setLatestVersionInfo(null);
|
||||
});
|
||||
|
||||
useWatchBroadcast('updateDownloadProgress', (progressInfo: ProgressInfo) => {
|
||||
if (isClosingRef.current) return;
|
||||
setProgress(progressInfo);
|
||||
});
|
||||
|
||||
useWatchBroadcast('updateDownloaded', (info: UpdateInfo) => {
|
||||
if (isClosingRef.current) return;
|
||||
setStage('downloaded');
|
||||
setDownloadedInfo(info);
|
||||
setProgress(null);
|
||||
setUpdateAvailableInfo(null);
|
||||
setLatestVersionInfo(null);
|
||||
});
|
||||
|
||||
useWatchBroadcast('updateError', (message: string) => {
|
||||
if (isClosingRef.current || errorHandledRef.current) return;
|
||||
errorHandledRef.current = true;
|
||||
isClosingRef.current = true;
|
||||
onClose();
|
||||
modal.error({ content: message, title: t('updater.updateError') });
|
||||
});
|
||||
|
||||
const closeModal = () => {
|
||||
if (isClosingRef.current) return;
|
||||
errorHandledRef.current = true;
|
||||
isClosingRef.current = true;
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!updateAvailableInfo) return;
|
||||
autoUpdateService.downloadUpdate();
|
||||
};
|
||||
|
||||
const handleInstallNow = () => {
|
||||
autoUpdateService.installNow();
|
||||
closeModal();
|
||||
};
|
||||
|
||||
const handleInstallLater = () => {
|
||||
autoUpdateService.installLater();
|
||||
closeModal();
|
||||
};
|
||||
|
||||
const renderReleaseNotes = (notes?: UpdateInfo['releaseNotes']) => {
|
||||
if (!notes) return null;
|
||||
return (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: notes as string }}
|
||||
style={{
|
||||
borderRadius: 4,
|
||||
marginTop: 8,
|
||||
maxHeight: 300,
|
||||
overflow: 'auto',
|
||||
padding: '8px 12px',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderBody = () => {
|
||||
switch (stage) {
|
||||
case 'checking': {
|
||||
return (
|
||||
<Spin spinning>
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
{t('updater.checkingUpdateDesc')}
|
||||
</div>
|
||||
</Spin>
|
||||
);
|
||||
}
|
||||
case 'available': {
|
||||
return (
|
||||
<>
|
||||
<h4>
|
||||
{t('updater.newVersionAvailableDesc', { version: updateAvailableInfo?.version })}
|
||||
</h4>
|
||||
{renderReleaseNotes(updateAvailableInfo?.releaseNotes)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
case 'downloading': {
|
||||
const percent = progress ? Math.round(progress.percent) : 0;
|
||||
return (
|
||||
<div style={{ padding: '20px 0' }}>
|
||||
<Progress percent={percent} status="active" />
|
||||
<div style={{ fontSize: 12, marginTop: 8, textAlign: 'center' }}>
|
||||
{t('updater.downloadingUpdateDesc', { percent })}
|
||||
{progress && progress.bytesPerSecond > 0 && (
|
||||
<span>{formatSpeed(progress.bytesPerSecond)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case 'downloaded': {
|
||||
return (
|
||||
<>
|
||||
<h4>{t('updater.updateReadyDesc', { version: downloadedInfo?.version })}</h4>
|
||||
{renderReleaseNotes(downloadedInfo?.releaseNotes)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
case 'latest': {
|
||||
return <p>{t('updater.isLatestVersionDesc', { version: latestVersionInfo?.version })}</p>;
|
||||
}
|
||||
default: {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const renderActions = () => {
|
||||
if (stage === 'downloading') return null;
|
||||
|
||||
let actions: React.ReactNode[] = [];
|
||||
|
||||
if (stage === 'checking') {
|
||||
actions = [
|
||||
<Button key="cancel" onClick={closeModal}>
|
||||
{t('cancel', { ns: 'common' })}
|
||||
</Button>,
|
||||
];
|
||||
}
|
||||
|
||||
if (stage === 'available') {
|
||||
actions = [
|
||||
<Button key="cancel" onClick={closeModal}>
|
||||
{t('cancel', { ns: 'common' })}
|
||||
</Button>,
|
||||
<Button key="download" onClick={handleDownload} type="primary">
|
||||
{t('updater.downloadNewVersion')}
|
||||
</Button>,
|
||||
];
|
||||
}
|
||||
|
||||
if (stage === 'downloaded') {
|
||||
actions = [
|
||||
<Button key="later" onClick={handleInstallLater}>
|
||||
{t('updater.installLater')}
|
||||
</Button>,
|
||||
<Button key="now" onClick={handleInstallNow} type="primary">
|
||||
{t('updater.restartAndInstall')}
|
||||
</Button>,
|
||||
];
|
||||
}
|
||||
|
||||
if (stage === 'latest') {
|
||||
actions = [
|
||||
<Button key="ok" onClick={closeModal} type="primary">
|
||||
{t('ok', { ns: 'common' })}
|
||||
</Button>,
|
||||
];
|
||||
}
|
||||
|
||||
if (actions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Flexbox gap={8} horizontal justify="end">
|
||||
{actions}
|
||||
</Flexbox>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Flexbox gap={16} style={{ padding: 16 }}>
|
||||
<div>{renderBody()}</div>
|
||||
{renderActions()}
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
UpdateModalContent.displayName = 'UpdateModalContent';
|
||||
|
||||
interface UpdateModalOpenProps {
|
||||
onAfterClose?: () => void;
|
||||
}
|
||||
|
||||
export const useUpdateModal = () => {
|
||||
const instanceRef = useRef<ModalInstance | null>(null);
|
||||
|
||||
const open = useCallback((props?: UpdateModalOpenProps) => {
|
||||
const setModalProps = (nextProps: ModalUpdateOptions) => {
|
||||
instanceRef.current?.update?.(nextProps);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
instanceRef.current?.close();
|
||||
};
|
||||
|
||||
instanceRef.current = createModal({
|
||||
afterClose: props?.onAfterClose,
|
||||
children: <UpdateModalContent onClose={handleClose} setModalProps={setModalProps} />,
|
||||
footer: null,
|
||||
keyboard: true,
|
||||
maskClosable: true,
|
||||
title: '',
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { open };
|
||||
};
|
||||
|
|
@ -1,274 +0,0 @@
|
|||
import {
|
||||
type ProgressInfo,
|
||||
type UpdateInfo,
|
||||
useWatchBroadcast,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
import { Button } from '@lobehub/ui';
|
||||
import { App, Modal, Progress, Spin } from 'antd';
|
||||
import React, { memo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { autoUpdateService } from '@/services/electron/autoUpdate';
|
||||
import { formatSpeed } from '@/utils/format';
|
||||
|
||||
export const UpdateModal = memo(() => {
|
||||
const { t } = useTranslation(['electron', 'common']);
|
||||
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
// 仅用于手动触发的更新流程(用户从设置页点“检查更新”)
|
||||
const manualFlowRef = useRef(false);
|
||||
const [updateAvailableInfo, setUpdateAvailableInfo] = useState<UpdateInfo | null>(null);
|
||||
const [downloadedInfo, setDownloadedInfo] = useState<UpdateInfo | null>(null);
|
||||
const [progress, setProgress] = useState<ProgressInfo | null>(null);
|
||||
const [latestVersionInfo, setLatestVersionInfo] = useState<UpdateInfo | null>(null); // State for latest version modal
|
||||
const { modal } = App.useApp();
|
||||
// --- Event Listeners ---
|
||||
|
||||
useWatchBroadcast('manualUpdateCheckStart', () => {
|
||||
console.log('[Manual Update] Check Start');
|
||||
manualFlowRef.current = true;
|
||||
|
||||
setIsChecking(true);
|
||||
setUpdateAvailableInfo(null);
|
||||
setDownloadedInfo(null);
|
||||
setProgress(null);
|
||||
setLatestVersionInfo(null); // Reset latest version info
|
||||
// Optional: Show a brief notification that check has started
|
||||
// notification.info({ message: t('updater.checking') });
|
||||
});
|
||||
|
||||
useWatchBroadcast('manualUpdateAvailable', (info: UpdateInfo) => {
|
||||
console.log('[Manual Update] Available:', info);
|
||||
// Only react if it's part of a manual check flow (i.e., isChecking was true)
|
||||
// No need to check isChecking here as this event is specific
|
||||
setIsChecking(false);
|
||||
setUpdateAvailableInfo(info);
|
||||
});
|
||||
|
||||
useWatchBroadcast('manualUpdateNotAvailable', (info) => {
|
||||
console.log('[Manual Update] Not Available:', info);
|
||||
// Only react if it's part of a manual check flow
|
||||
// No need to check isChecking here as this event is specific
|
||||
setIsChecking(false);
|
||||
manualFlowRef.current = false;
|
||||
|
||||
setLatestVersionInfo(info); // Set info for the modal
|
||||
// notification.success({
|
||||
// description: t('updater.isLatestVersionDesc', { version: info.version }),
|
||||
// message: t('updater.isLatestVersion'),
|
||||
// });
|
||||
});
|
||||
|
||||
useWatchBroadcast('updateError', (message: string) => {
|
||||
console.log('[Manual Update] Error:', message);
|
||||
// Only react if it's part of a manual check/download flow
|
||||
if (isChecking || isDownloading) {
|
||||
setIsChecking(false);
|
||||
setIsDownloading(false);
|
||||
// Show error modal or notification
|
||||
modal.error({ content: message, title: t('updater.updateError') });
|
||||
setLatestVersionInfo(null); // Ensure other modals are closed on error
|
||||
setUpdateAvailableInfo(null);
|
||||
setDownloadedInfo(null);
|
||||
manualFlowRef.current = false;
|
||||
}
|
||||
});
|
||||
|
||||
useWatchBroadcast('updateDownloadStart', () => {
|
||||
console.log('[Manual Update] Download Start');
|
||||
// This event implies a manual download was triggered (likely from the 'updateAvailable' modal)
|
||||
manualFlowRef.current = true;
|
||||
|
||||
setIsDownloading(true);
|
||||
setUpdateAvailableInfo(null); // Hide the 'download' button modal
|
||||
setProgress({ bytesPerSecond: 0, percent: 0, total: 0, transferred: 0 }); // Reset progress
|
||||
setLatestVersionInfo(null); // Ensure other modals are closed
|
||||
// Optional: Show notification that download started
|
||||
// notification.info({ message: t('updater.downloadingUpdate') });
|
||||
});
|
||||
|
||||
useWatchBroadcast('updateDownloadProgress', (progressInfo: ProgressInfo) => {
|
||||
console.log('[Manual Update] Progress:', progressInfo);
|
||||
// Only update progress if we are in the manual download state
|
||||
setProgress(progressInfo);
|
||||
});
|
||||
|
||||
useWatchBroadcast('updateDownloaded', (info: UpdateInfo) => {
|
||||
console.log('[Manual Update] Downloaded:', info);
|
||||
// 仅在手动流程里展示阻塞式的“更新就绪”弹窗
|
||||
if (manualFlowRef.current) {
|
||||
setIsChecking(false);
|
||||
setIsDownloading(false);
|
||||
setDownloadedInfo(info);
|
||||
setProgress(null); // Clear progress
|
||||
setLatestVersionInfo(null); // Ensure other modals are closed
|
||||
setUpdateAvailableInfo(null);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Render Logic ---
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!updateAvailableInfo) return;
|
||||
// No need to set states here, 'updateDownloadStart' will handle it
|
||||
autoUpdateService.downloadUpdate();
|
||||
};
|
||||
|
||||
const handleInstallNow = () => {
|
||||
setDownloadedInfo(null); // Close modal immediately
|
||||
autoUpdateService.installNow();
|
||||
manualFlowRef.current = false;
|
||||
};
|
||||
|
||||
const handleInstallLater = () => {
|
||||
// No need to set state here, 'updateWillInstallLater' handles it
|
||||
autoUpdateService.installLater();
|
||||
setDownloadedInfo(null); // Close the modal after clicking
|
||||
manualFlowRef.current = false;
|
||||
};
|
||||
|
||||
const closeAvailableModal = () => setUpdateAvailableInfo(null);
|
||||
const closeDownloadedModal = () => setDownloadedInfo(null);
|
||||
const closeLatestVersionModal = () => setLatestVersionInfo(null);
|
||||
|
||||
const handleCancelCheck = () => {
|
||||
setIsChecking(false);
|
||||
setUpdateAvailableInfo(null);
|
||||
setDownloadedInfo(null);
|
||||
setProgress(null);
|
||||
setLatestVersionInfo(null);
|
||||
manualFlowRef.current = false;
|
||||
};
|
||||
|
||||
const renderCheckingModal = () => (
|
||||
<Modal
|
||||
closable
|
||||
footer={[
|
||||
<Button key="cancel" onClick={handleCancelCheck}>
|
||||
{t('cancel', { ns: 'common' })}
|
||||
</Button>,
|
||||
]}
|
||||
onCancel={handleCancelCheck}
|
||||
open={isChecking}
|
||||
title={t('updater.checkingUpdate')}
|
||||
>
|
||||
<Spin spinning={true}>
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
{t('updater.checkingUpdateDesc')}
|
||||
</div>
|
||||
</Spin>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
const renderAvailableModal = () => (
|
||||
<Modal
|
||||
footer={[
|
||||
<Button key="cancel" onClick={closeAvailableModal}>
|
||||
{t('cancel', { ns: 'common' })}
|
||||
</Button>,
|
||||
<Button key="download" onClick={handleDownload} type="primary">
|
||||
{t('updater.downloadNewVersion')}
|
||||
</Button>,
|
||||
]}
|
||||
onCancel={closeAvailableModal}
|
||||
open={!!updateAvailableInfo}
|
||||
title={t('updater.newVersionAvailable')}
|
||||
>
|
||||
<h4>{t('updater.newVersionAvailableDesc', { version: updateAvailableInfo?.version })}</h4>
|
||||
{updateAvailableInfo?.releaseNotes && (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: updateAvailableInfo.releaseNotes as string }}
|
||||
style={{
|
||||
// background:theme
|
||||
borderRadius: 4,
|
||||
marginTop: 8,
|
||||
maxHeight: 300,
|
||||
overflow: 'auto',
|
||||
padding: '8px 12px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
|
||||
const renderDownloadingModal = () => {
|
||||
const percent = progress ? Math.round(progress.percent) : 0;
|
||||
return (
|
||||
<Modal
|
||||
closable={false}
|
||||
footer={null}
|
||||
maskClosable={false}
|
||||
open={isDownloading && !downloadedInfo}
|
||||
title={t('updater.downloadingUpdate')}
|
||||
>
|
||||
<div style={{ padding: '20px 0' }}>
|
||||
<Progress percent={percent} status="active" />
|
||||
<div style={{ fontSize: 12, marginTop: 8, textAlign: 'center' }}>
|
||||
{t('updater.downloadingUpdateDesc', { percent })}
|
||||
{progress && progress.bytesPerSecond > 0 && (
|
||||
<span>{formatSpeed(progress.bytesPerSecond)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const renderDownloadedModal = () => (
|
||||
<Modal
|
||||
footer={[
|
||||
<Button key="later" onClick={handleInstallLater}>
|
||||
{t('updater.installLater')}
|
||||
</Button>,
|
||||
<Button key="now" onClick={handleInstallNow} type="primary">
|
||||
{t('updater.restartAndInstall')}
|
||||
</Button>,
|
||||
]}
|
||||
onCancel={closeDownloadedModal} // Allow closing if they don't want to decide now
|
||||
open={!!downloadedInfo}
|
||||
title={t('updater.updateReady')}
|
||||
>
|
||||
<h4>{t('updater.updateReadyDesc', { version: downloadedInfo?.version })}</h4>
|
||||
{downloadedInfo?.releaseNotes && (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: downloadedInfo.releaseNotes as string }}
|
||||
style={{
|
||||
borderRadius: 4,
|
||||
marginTop: 8,
|
||||
maxHeight: 300,
|
||||
overflow: 'auto',
|
||||
padding: '8px 12px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
|
||||
// New modal for "latest version"
|
||||
const renderLatestVersionModal = () => (
|
||||
<Modal
|
||||
footer={[
|
||||
<Button key="ok" onClick={closeLatestVersionModal} type="primary">
|
||||
{t('ok', { ns: 'common' })}
|
||||
</Button>,
|
||||
]}
|
||||
onCancel={closeLatestVersionModal}
|
||||
open={!!latestVersionInfo}
|
||||
title={t('updater.isLatestVersion')}
|
||||
>
|
||||
<p>{t('updater.isLatestVersionDesc', { version: latestVersionInfo?.version })}</p>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderCheckingModal()}
|
||||
{renderAvailableModal()}
|
||||
{renderDownloadingModal()}
|
||||
{renderDownloadedModal()}
|
||||
{renderLatestVersionModal()}
|
||||
{/* Error state is handled by Modal.error currently */}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
// const useStyles = createStyles(({ css, cx, token }) => {
|
||||
// const icon = css`
|
||||
// display: flex;
|
||||
// align-items: center;
|
||||
// justify-content: center;
|
||||
//
|
||||
// width: ${TITLE_BAR_HEIGHT * 1.2}px;
|
||||
// min-height: ${TITLE_BAR_HEIGHT}px;
|
||||
//
|
||||
// color: ${token.colorTextSecondary};
|
||||
//
|
||||
// transition: all ease-in-out 100ms;
|
||||
//
|
||||
// -webkit-app-region: no-drag;
|
||||
//
|
||||
// &:hover {
|
||||
// color: ${token.colorText};
|
||||
// background: ${token.colorFillTertiary};
|
||||
// }
|
||||
//
|
||||
// &:active {
|
||||
// color: ${token.colorText};
|
||||
// background: ${token.colorFillSecondary};
|
||||
// }
|
||||
// `;
|
||||
// return {
|
||||
// close: cx(
|
||||
// icon,
|
||||
// css`
|
||||
// padding-inline-end: 2px;
|
||||
//
|
||||
// &:hover {
|
||||
// color: ${token.colorTextLightSolid};
|
||||
//
|
||||
// /* win11 的色值,亮暗色均不变 */
|
||||
// background: #d33328;
|
||||
// }
|
||||
//
|
||||
// &:active {
|
||||
// color: ${token.colorTextLightSolid};
|
||||
//
|
||||
// /* win11 的色值 */
|
||||
// background: #8b2b25;
|
||||
// }
|
||||
// `,
|
||||
// ),
|
||||
// container: css`
|
||||
// cursor: pointer;
|
||||
// display: flex;
|
||||
// `,
|
||||
// icon,
|
||||
// };
|
||||
// });
|
||||
|
||||
const WinControl = () => {
|
||||
return <div style={{ width: 132 }} />;
|
||||
|
||||
// const { styles } = useStyles();
|
||||
//
|
||||
// return (
|
||||
// <div className={styles.container}>
|
||||
// <div
|
||||
// className={styles.icon}
|
||||
// onClick={() => {
|
||||
// electronSystemService.minimizeWindow();
|
||||
// }}
|
||||
// >
|
||||
// <Minus absoluteStrokeWidth size={14} strokeWidth={1.2} />
|
||||
// </div>
|
||||
// <div
|
||||
// className={styles.icon}
|
||||
// onClick={() => {
|
||||
// electronSystemService.maximizeWindow();
|
||||
// }}
|
||||
// >
|
||||
// <Square absoluteStrokeWidth size={10} strokeWidth={1.2} />
|
||||
// </div>
|
||||
// <div
|
||||
// className={styles.close}
|
||||
// onClick={() => {
|
||||
// electronSystemService.closeWindow();
|
||||
// }}
|
||||
// >
|
||||
// <XIcon absoluteStrokeWidth size={14} strokeWidth={1.2} />
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
};
|
||||
|
||||
export default WinControl;
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import { renderHook } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useAddFilesToKnowledgeBaseModal } from './index';
|
||||
|
||||
const mockCreateModal = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@lobehub/ui', () => ({
|
||||
Flexbox: () => null,
|
||||
Icon: () => null,
|
||||
createModal: mockCreateModal,
|
||||
useModalContext: () => ({ close: vi.fn() }),
|
||||
}));
|
||||
|
||||
describe('useAddFilesToKnowledgeBaseModal', () => {
|
||||
it('should forward onClose to createModal afterClose', () => {
|
||||
const onClose = vi.fn();
|
||||
const { result } = renderHook(() => useAddFilesToKnowledgeBaseModal());
|
||||
|
||||
result.current.open({ fileIds: ['file-1'], onClose });
|
||||
|
||||
expect(mockCreateModal).toHaveBeenCalledWith(expect.objectContaining({ afterClose: onClose }));
|
||||
});
|
||||
});
|
||||
|
|
@ -1,10 +1,8 @@
|
|||
import { Flexbox, Icon } from '@lobehub/ui';
|
||||
import { Flexbox, Icon, createModal, useModalContext } from '@lobehub/ui';
|
||||
import { BookUp2Icon } from 'lucide-react';
|
||||
import { Suspense, memo } from 'react';
|
||||
import { Suspense, memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { createModal } from '@/components/FunctionModal';
|
||||
|
||||
import SelectForm from './SelectForm';
|
||||
|
||||
interface AddFilesToKnowledgeBaseModalProps {
|
||||
|
|
@ -16,12 +14,11 @@ interface AddFilesToKnowledgeBaseModalProps {
|
|||
interface ModalContentProps {
|
||||
fileIds: string[];
|
||||
knowledgeBaseId?: string;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const ModalContent = memo<ModalContentProps>(({ fileIds, knowledgeBaseId, onClose }) => {
|
||||
const ModalContent = memo<ModalContentProps>(({ fileIds, knowledgeBaseId }) => {
|
||||
const { t } = useTranslation('knowledgeBase');
|
||||
|
||||
const { close } = useModalContext();
|
||||
return (
|
||||
<>
|
||||
<Flexbox gap={8} horizontal paddingBlock={16} paddingInline={16} style={{ paddingBottom: 0 }}>
|
||||
|
|
@ -29,7 +26,7 @@ const ModalContent = memo<ModalContentProps>(({ fileIds, knowledgeBaseId, onClos
|
|||
{t('addToKnowledgeBase.title')}
|
||||
</Flexbox>
|
||||
<Flexbox padding={16} style={{ paddingTop: 0 }}>
|
||||
<SelectForm fileIds={fileIds} knowledgeBaseId={knowledgeBaseId} onClose={onClose} />
|
||||
<SelectForm fileIds={fileIds} knowledgeBaseId={knowledgeBaseId} onClose={close} />
|
||||
</Flexbox>
|
||||
</>
|
||||
);
|
||||
|
|
@ -37,19 +34,19 @@ const ModalContent = memo<ModalContentProps>(({ fileIds, knowledgeBaseId, onClos
|
|||
|
||||
ModalContent.displayName = 'AddFilesToKnowledgeBaseModalContent';
|
||||
|
||||
export const useAddFilesToKnowledgeBaseModal = createModal<AddFilesToKnowledgeBaseModalProps>(
|
||||
(instance, params) => ({
|
||||
content: (
|
||||
export const useAddFilesToKnowledgeBaseModal = () => {
|
||||
const open = useCallback((params?: AddFilesToKnowledgeBaseModalProps) => {
|
||||
createModal({
|
||||
afterClose: params?.onClose,
|
||||
children: (
|
||||
<Suspense fallback={<div style={{ minHeight: 200 }} />}>
|
||||
<ModalContent
|
||||
fileIds={params?.fileIds || []}
|
||||
knowledgeBaseId={params?.knowledgeBaseId}
|
||||
onClose={() => {
|
||||
instance.current?.destroy();
|
||||
params?.onClose?.();
|
||||
}}
|
||||
/>
|
||||
<ModalContent fileIds={params?.fileIds || []} knowledgeBaseId={params?.knowledgeBaseId} />
|
||||
</Suspense>
|
||||
),
|
||||
}),
|
||||
);
|
||||
footer: null,
|
||||
title: null,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { open };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,41 +1,37 @@
|
|||
import { Flexbox } from '@lobehub/ui';
|
||||
import { Suspense, memo } from 'react';
|
||||
|
||||
import { createModal } from '@/components/FunctionModal';
|
||||
import { Flexbox, createModal, useModalContext } from '@lobehub/ui';
|
||||
import { Suspense, memo, useCallback } from 'react';
|
||||
|
||||
import CreateForm from './CreateForm';
|
||||
|
||||
interface ModalContentProps {
|
||||
onClose?: () => void;
|
||||
onSuccess?: (id: string) => void;
|
||||
}
|
||||
|
||||
const ModalContent = memo<ModalContentProps>(({ onClose, onSuccess }) => {
|
||||
const ModalContent = memo<ModalContentProps>(({ onSuccess }) => {
|
||||
const { close } = useModalContext();
|
||||
|
||||
return (
|
||||
<Flexbox paddingInline={16} style={{ paddingBottom: 16 }}>
|
||||
<CreateForm onClose={onClose} onSuccess={onSuccess} />
|
||||
<CreateForm onClose={close} onSuccess={onSuccess} />
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
ModalContent.displayName = 'KnowledgeBaseCreateModalContent';
|
||||
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
export const useCreateNewModal = createModal<{ onSuccess?: (id: string) => void }>(
|
||||
(instance, props) => {
|
||||
return {
|
||||
content: (
|
||||
export const useCreateNewModal = () => {
|
||||
const open = useCallback((props?: { onSuccess?: (id: string) => void }) => {
|
||||
createModal({
|
||||
children: (
|
||||
<Suspense fallback={<div style={{ minHeight: 200 }} />}>
|
||||
<ModalContent
|
||||
onClose={() => {
|
||||
instance.current?.destroy();
|
||||
}}
|
||||
onSuccess={props?.onSuccess}
|
||||
/>
|
||||
<ModalContent onSuccess={props?.onSuccess} />
|
||||
</Suspense>
|
||||
),
|
||||
focusTriggerAfterClose: true,
|
||||
footer: false,
|
||||
footer: null,
|
||||
title: null,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { open };
|
||||
};
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { TITLE_BAR_HEIGHT } from '@lobechat/desktop-bridge';
|
||||
import { Alert, Button, Drawer, Flexbox, Icon, Segmented, Tag } from '@lobehub/ui';
|
||||
import { App, Form, Popconfirm } from 'antd';
|
||||
import { useResponsive } from 'antd-style';
|
||||
|
|
@ -7,7 +8,6 @@ import { Trans, useTranslation } from 'react-i18next';
|
|||
|
||||
import { WIKI_PLUGIN_GUIDE } from '@/const/url';
|
||||
import { isDesktop } from '@/const/version';
|
||||
import { TITLE_BAR_HEIGHT } from '@/features/ElectronTitlebar';
|
||||
import { type LobeToolCustomPlugin } from '@/types/tool/plugin';
|
||||
|
||||
import MCPManifestForm from './MCPManifestForm';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { TITLE_BAR_HEIGHT } from '@lobechat/desktop-bridge';
|
||||
import {
|
||||
ConfigProvider,
|
||||
FontLoader,
|
||||
|
|
@ -19,7 +20,6 @@ import { type ReactNode, memo, useEffect, useMemo, useState } from 'react';
|
|||
import AntdStaticMethods from '@/components/AntdStaticMethods';
|
||||
import { LOBE_THEME_NEUTRAL_COLOR, LOBE_THEME_PRIMARY_COLOR } from '@/const/theme';
|
||||
import { isDesktop } from '@/const/version';
|
||||
import { TITLE_BAR_HEIGHT } from '@/features/ElectronTitlebar';
|
||||
import { useIsDark } from '@/hooks/useIsDark';
|
||||
import { getUILocaleAndResources } from '@/libs/getUILocaleAndResources';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ import {
|
|||
type UpdateAiProviderParams,
|
||||
} from '@/types/aiProvider';
|
||||
|
||||
|
||||
export type ProviderModelListItem = {
|
||||
abilities: ModelAbilities;
|
||||
approximatePricePerImage?: number;
|
||||
|
|
|
|||
|
|
@ -203,7 +203,6 @@ describe('chatDockSlice', () => {
|
|||
});
|
||||
});
|
||||
|
||||
|
||||
describe('closeToolUI', () => {
|
||||
it('should pop ToolUI view from stack', () => {
|
||||
const { result } = renderHook(() => useChatStore());
|
||||
|
|
@ -267,5 +266,4 @@ describe('chatDockSlice', () => {
|
|||
expect(result.current.showPortal).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -42,13 +42,10 @@ export const chatPortalSlice: StateCreator<
|
|||
[],
|
||||
ChatPortalAction
|
||||
> = (set, get) => ({
|
||||
|
||||
|
||||
clearPortalStack: () => {
|
||||
set({ portalStack: [], showPortal: false }, false, 'clearPortalStack');
|
||||
},
|
||||
|
||||
|
||||
closeArtifact: () => {
|
||||
const { portalStack } = get();
|
||||
if (getCurrentViewType(portalStack) === PortalViewType.Artifact) {
|
||||
|
|
@ -56,7 +53,6 @@ closeArtifact: () => {
|
|||
}
|
||||
},
|
||||
|
||||
|
||||
closeDocument: () => {
|
||||
const { portalStack } = get();
|
||||
if (getCurrentViewType(portalStack) === PortalViewType.Document) {
|
||||
|
|
@ -64,7 +60,6 @@ closeDocument: () => {
|
|||
}
|
||||
},
|
||||
|
||||
|
||||
closeFilePreview: () => {
|
||||
const { portalStack } = get();
|
||||
if (getCurrentViewType(portalStack) === PortalViewType.FilePreview) {
|
||||
|
|
@ -72,7 +67,6 @@ closeFilePreview: () => {
|
|||
}
|
||||
},
|
||||
|
||||
|
||||
closeMessageDetail: () => {
|
||||
const { portalStack } = get();
|
||||
if (getCurrentViewType(portalStack) === PortalViewType.MessageDetail) {
|
||||
|
|
@ -80,7 +74,6 @@ closeMessageDetail: () => {
|
|||
}
|
||||
},
|
||||
|
||||
|
||||
closeNotebook: () => {
|
||||
const { portalStack } = get();
|
||||
if (getCurrentViewType(portalStack) === PortalViewType.Notebook) {
|
||||
|
|
@ -88,9 +81,6 @@ closeNotebook: () => {
|
|||
}
|
||||
},
|
||||
|
||||
|
||||
|
||||
|
||||
closeToolUI: () => {
|
||||
const { portalStack } = get();
|
||||
if (getCurrentViewType(portalStack) === PortalViewType.ToolUI) {
|
||||
|
|
@ -98,14 +88,10 @@ closeToolUI: () => {
|
|||
}
|
||||
},
|
||||
|
||||
|
||||
|
||||
goBack: () => {
|
||||
get().popPortalView();
|
||||
},
|
||||
|
||||
|
||||
|
||||
goHome: () => {
|
||||
set(
|
||||
{
|
||||
|
|
@ -117,44 +103,31 @@ goHome: () => {
|
|||
);
|
||||
},
|
||||
|
||||
|
||||
|
||||
// ============== Convenience Methods (using stack operations) ==============
|
||||
openArtifact: (artifact) => {
|
||||
get().pushPortalView({ artifact, type: PortalViewType.Artifact });
|
||||
},
|
||||
|
||||
|
||||
|
||||
|
||||
openDocument: (documentId) => {
|
||||
get().pushPortalView({ documentId, type: PortalViewType.Document });
|
||||
},
|
||||
|
||||
|
||||
|
||||
|
||||
openFilePreview: (file) => {
|
||||
get().pushPortalView({ file, type: PortalViewType.FilePreview });
|
||||
},
|
||||
|
||||
|
||||
|
||||
openMessageDetail: (messageId) => {
|
||||
get().pushPortalView({ messageId, type: PortalViewType.MessageDetail });
|
||||
},
|
||||
|
||||
|
||||
openNotebook: () => {
|
||||
get().pushPortalView({ type: PortalViewType.Notebook });
|
||||
},
|
||||
|
||||
|
||||
openToolUI: (messageId, identifier) => {
|
||||
get().pushPortalView({ identifier, messageId, type: PortalViewType.ToolUI });
|
||||
},
|
||||
|
||||
|
||||
popPortalView: () => {
|
||||
const { portalStack } = get();
|
||||
|
||||
|
|
|
|||
|
|
@ -150,7 +150,10 @@ describe('thread action', () => {
|
|||
expect(result.current.threadStartMessageId).toBe('message-id');
|
||||
expect(result.current.portalThreadId).toBeUndefined();
|
||||
expect(result.current.startToForkThread).toBe(true);
|
||||
expect(pushPortalViewSpy).toHaveBeenCalledWith({ type: 'thread', startMessageId: 'message-id' });
|
||||
expect(pushPortalViewSpy).toHaveBeenCalledWith({
|
||||
type: 'thread',
|
||||
startMessageId: 'message-id',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,12 @@
|
|||
// Disable the auto sort key eslint rule to make the code more logic and readable
|
||||
import { LOADING_FLAT } from '@lobechat/const';
|
||||
import { chainSummaryTitle } from '@lobechat/prompts';
|
||||
import { type CreateMessageParams, type IThreadType, type ThreadItem, type UIChatMessage } from '@lobechat/types';
|
||||
import {
|
||||
type CreateMessageParams,
|
||||
type IThreadType,
|
||||
type ThreadItem,
|
||||
type UIChatMessage,
|
||||
} from '@lobechat/types';
|
||||
import isEqual from 'fast-deep-equal';
|
||||
import type { SWRResponse } from 'swr';
|
||||
import { type StateCreator } from 'zustand/vanilla';
|
||||
|
|
|
|||
Loading…
Reference in a new issue