mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-26 13:19:34 +07:00
♻️ refactor: restructure electron build workflow with i18n codemod
- Remove desktop-build-electron.yml workflow - Add verify-electron-codemod.yml workflow for i18n transformation - Add i18nDynamicToStatic modifier for dynamic to static i18n conversion - Update build workflows to use new i18n modifier approach - Update README and package.json configurations
This commit is contained in:
85
.github/workflows/desktop-build-electron.yml
vendored
85
.github/workflows/desktop-build-electron.yml
vendored
@@ -1,85 +0,0 @@
|
||||
name: Desktop Next Build
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- next
|
||||
pull_request:
|
||||
paths:
|
||||
- 'apps/desktop/**'
|
||||
- 'scripts/electronWorkflow/**'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'bun.lockb'
|
||||
- 'src/**'
|
||||
- 'packages/**'
|
||||
- '.github/workflows/desktop-build-electron.yml'
|
||||
|
||||
concurrency:
|
||||
group: desktop-electron-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
NODE_VERSION: 24.11.1
|
||||
BUN_VERSION: 1.2.23
|
||||
|
||||
jobs:
|
||||
build-next:
|
||||
name: Build desktop Next bundle
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
UPDATE_CHANNEL: nightly
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_NIGHTLY_DESKTOP_PROJECT_ID || 'dummy-desktop-project' }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_NIGHTLY_DESKTOP_BASE_URL || 'https://analytics.example.com' }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-store
|
||||
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache pnpm store
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ env.NODE_VERSION }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-${{ env.NODE_VERSION }}-
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Setup bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --node-linker=hoisted
|
||||
|
||||
- name: Install desktop dependencies
|
||||
run: |
|
||||
cd apps/desktop
|
||||
bun run install-isolated
|
||||
|
||||
- name: Build desktop Next.js bundle
|
||||
run: bun run desktop:build-electron
|
||||
6
.github/workflows/manual-build-desktop.yml
vendored
6
.github/workflows/manual-build-desktop.yml
vendored
@@ -123,7 +123,7 @@ jobs:
|
||||
run: npm run workflow:set-desktop-version ${{ needs.version.outputs.version }} ${{ inputs.channel }}
|
||||
|
||||
- name: Build artifact on macOS
|
||||
run: npm run desktop:build
|
||||
run: npm run desktop:package:app
|
||||
env:
|
||||
UPDATE_CHANNEL: ${{ inputs.channel }}
|
||||
APP_URL: http://localhost:3015
|
||||
@@ -193,7 +193,7 @@ jobs:
|
||||
run: npm run workflow:set-desktop-version ${{ needs.version.outputs.version }} ${{ inputs.channel }}
|
||||
|
||||
- name: Build artifact on Windows
|
||||
run: npm run desktop:build
|
||||
run: npm run desktop:package:app
|
||||
env:
|
||||
UPDATE_CHANNEL: ${{ inputs.channel }}
|
||||
APP_URL: http://localhost:3015
|
||||
@@ -246,7 +246,7 @@ jobs:
|
||||
run: npm run workflow:set-desktop-version ${{ needs.version.outputs.version }} ${{ inputs.channel }}
|
||||
|
||||
- name: Build artifact on Linux
|
||||
run: npm run desktop:build
|
||||
run: npm run desktop:package:app
|
||||
env:
|
||||
UPDATE_CHANNEL: ${{ inputs.channel }}
|
||||
APP_URL: http://localhost:3015
|
||||
|
||||
6
.github/workflows/pr-build-desktop.yml
vendored
6
.github/workflows/pr-build-desktop.yml
vendored
@@ -131,7 +131,7 @@ jobs:
|
||||
# 注意:fork 的 PR 无法访问 secrets,会构建未签名版本
|
||||
- name: Build artifact on macOS
|
||||
if: runner.os == 'macOS'
|
||||
run: npm run desktop:build
|
||||
run: npm run desktop:package:app
|
||||
env:
|
||||
# 设置更新通道,PR构建为nightly,否则为stable
|
||||
UPDATE_CHANNEL: 'nightly'
|
||||
@@ -155,7 +155,7 @@ jobs:
|
||||
# 注意:fork 的 PR 无法访问 secrets,会构建未签名版本
|
||||
- name: Build artifact on Windows
|
||||
if: runner.os == 'Windows'
|
||||
run: npm run desktop:build
|
||||
run: npm run desktop:package:app
|
||||
env:
|
||||
# 设置更新通道,PR构建为nightly,否则为stable
|
||||
UPDATE_CHANNEL: 'nightly'
|
||||
@@ -171,7 +171,7 @@ jobs:
|
||||
# Linux 平台构建处理
|
||||
- name: Build artifact on Linux
|
||||
if: runner.os == 'Linux'
|
||||
run: npm run desktop:build
|
||||
run: npm run desktop:package:app
|
||||
env:
|
||||
# 设置更新通道,PR构建为nightly,否则为stable
|
||||
UPDATE_CHANNEL: 'nightly'
|
||||
|
||||
6
.github/workflows/release-desktop-beta.yml
vendored
6
.github/workflows/release-desktop-beta.yml
vendored
@@ -101,7 +101,7 @@ jobs:
|
||||
# macOS 构建
|
||||
- name: Build artifact on macOS
|
||||
if: runner.os == 'macOS'
|
||||
run: npm run desktop:build
|
||||
run: npm run desktop:package:app
|
||||
env:
|
||||
UPDATE_CHANNEL: beta
|
||||
APP_URL: http://localhost:3015
|
||||
@@ -119,7 +119,7 @@ jobs:
|
||||
# Windows 构建
|
||||
- name: Build artifact on Windows
|
||||
if: runner.os == 'Windows'
|
||||
run: npm run desktop:build
|
||||
run: npm run desktop:package:app
|
||||
env:
|
||||
UPDATE_CHANNEL: beta
|
||||
APP_URL: http://localhost:3015
|
||||
@@ -133,7 +133,7 @@ jobs:
|
||||
# Linux 构建
|
||||
- name: Build artifact on Linux
|
||||
if: runner.os == 'Linux'
|
||||
run: npm run desktop:build
|
||||
run: npm run desktop:package:app
|
||||
env:
|
||||
UPDATE_CHANNEL: beta
|
||||
APP_URL: http://localhost:3015
|
||||
|
||||
6
.github/workflows/release-desktop-stable.yml
vendored
6
.github/workflows/release-desktop-stable.yml
vendored
@@ -188,7 +188,7 @@ jobs:
|
||||
# macOS 构建
|
||||
- name: Build artifact on macOS
|
||||
if: runner.os == 'macOS'
|
||||
run: npm run desktop:build
|
||||
run: npm run desktop:package:app
|
||||
env:
|
||||
UPDATE_CHANNEL: stable
|
||||
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
|
||||
@@ -208,7 +208,7 @@ jobs:
|
||||
# Windows 构建
|
||||
- name: Build artifact on Windows
|
||||
if: runner.os == 'Windows'
|
||||
run: npm run desktop:build
|
||||
run: npm run desktop:package:app
|
||||
env:
|
||||
UPDATE_CHANNEL: stable
|
||||
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
|
||||
@@ -225,7 +225,7 @@ jobs:
|
||||
- name: Build artifact on Linux
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
npm run desktop:build
|
||||
npm run desktop:package:app
|
||||
tar -czf apps/desktop/release/lobehub-renderer.tar.gz -C out .
|
||||
env:
|
||||
UPDATE_CHANNEL: stable
|
||||
|
||||
109
.github/workflows/verify-electron-codemod.yml
vendored
Normal file
109
.github/workflows/verify-electron-codemod.yml
vendored
Normal file
@@ -0,0 +1,109 @@
|
||||
name: Verify Electron i18n Codemod
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
|
||||
concurrency:
|
||||
group: verify-electron-codemod-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
NODE_VERSION: 24.11.1
|
||||
BUN_VERSION: 1.2.23
|
||||
|
||||
jobs:
|
||||
verify-codemod:
|
||||
name: Verify i18n codemod on temp workspace
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-store
|
||||
run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache pnpm store
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ env.NODE_VERSION }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-${{ env.NODE_VERSION }}-
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Setup bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --node-linker=hoisted
|
||||
|
||||
- name: Run i18n codemod in temp workspace
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
echo "Using TEMP_DIR=$TMP_DIR"
|
||||
echo "TMP_DIR=$TMP_DIR" >> "$GITHUB_ENV"
|
||||
|
||||
mkdir -p "$TMP_DIR/src/utils/i18n" "$TMP_DIR/src/libs" "$TMP_DIR/src/const" "$TMP_DIR/src/locales/default"
|
||||
|
||||
cp -R scripts "$TMP_DIR/"
|
||||
cp src/utils/i18n/loadI18nNamespaceModule.ts "$TMP_DIR/src/utils/i18n/"
|
||||
cp src/libs/getUILocaleAndResources.ts "$TMP_DIR/src/libs/"
|
||||
cp src/const/locale.ts "$TMP_DIR/src/const/"
|
||||
cp src/locales/resources.ts "$TMP_DIR/src/locales/"
|
||||
cp src/locales/default/index.ts "$TMP_DIR/src/locales/default/"
|
||||
cp -R locales "$TMP_DIR/"
|
||||
|
||||
bun "$TMP_DIR/scripts/electronWorkflow/modifiers/i18nDynamicToStatic.mts" "$TMP_DIR"
|
||||
|
||||
- name: Assert dynamic imports removed
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
test -n "${TMP_DIR:-}"
|
||||
|
||||
if grep -F 'import(`@/locales/default/${ns}`)' "$TMP_DIR/src/utils/i18n/loadI18nNamespaceModule.ts"; then
|
||||
echo "Found dynamic default locale import in loadI18nNamespaceModule.ts"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -F 'import(`@/../locales/${normalizeLocale(lng)}/${ns}.json`)' "$TMP_DIR/src/utils/i18n/loadI18nNamespaceModule.ts"; then
|
||||
echo "Found dynamic locale import in loadI18nNamespaceModule.ts"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -F 'await import(`@/../locales/${locale}/ui.json`)' "$TMP_DIR/src/libs/getUILocaleAndResources.ts"; then
|
||||
echo "Found dynamic ui.json import in getUILocaleAndResources.ts"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -F "await import('@lobehub/ui/es/i18n/resources/index')" "$TMP_DIR/src/libs/getUILocaleAndResources.ts"; then
|
||||
echo "Found dynamic @lobehub/ui import in getUILocaleAndResources.ts"
|
||||
exit 1
|
||||
fi
|
||||
@@ -29,7 +29,7 @@ LobeHub Desktop is a cross-platform desktop application for [LobeChat](https://g
|
||||
pnpm install-isolated
|
||||
|
||||
# Start development server
|
||||
pnpm electron:dev
|
||||
pnpm dev
|
||||
|
||||
# Type checking
|
||||
pnpm type-check
|
||||
@@ -51,19 +51,20 @@ cp .env.desktop .env
|
||||
|
||||
### Build Commands
|
||||
|
||||
| Command | Description |
|
||||
| ------------------ | --------------------------------------- |
|
||||
| `pnpm build` | Build for all platforms |
|
||||
| `pnpm build:mac` | Build for macOS (Intel + Apple Silicon) |
|
||||
| `pnpm build:win` | Build for Windows |
|
||||
| `pnpm build:linux` | Build for Linux |
|
||||
| `pnpm build-local` | Local development build |
|
||||
| Command | Description |
|
||||
| -------------------------- | ------------------------------------------- |
|
||||
| `pnpm build:main` | Build main/preload (dist output only) |
|
||||
| `pnpm package:mac` | Package for macOS (Intel + Apple Silicon) |
|
||||
| `pnpm package:win` | Package for Windows |
|
||||
| `pnpm package:linux` | Package for Linux |
|
||||
| `pnpm package:local` | Local packaging build (no ASAR) |
|
||||
| `pnpm package:local:reuse` | Local packaging build reusing existing dist |
|
||||
|
||||
### Development Workflow
|
||||
|
||||
```bash
|
||||
# 1. Development
|
||||
pnpm electron:dev # Start with hot reload
|
||||
pnpm dev # Start with hot reload
|
||||
|
||||
# 2. Code Quality
|
||||
pnpm lint # ESLint checking
|
||||
@@ -74,8 +75,8 @@ pnpm type-check # TypeScript validation
|
||||
pnpm test # Run Vitest tests
|
||||
|
||||
# 4. Build & Package
|
||||
pnpm build # Production build
|
||||
pnpm build-local # Local testing build
|
||||
pnpm build:main # Production build (dist only)
|
||||
pnpm package:local # Local testing package
|
||||
```
|
||||
|
||||
## 🎯 Release Channels
|
||||
|
||||
@@ -29,7 +29,7 @@ LobeHub Desktop 是 [LobeChat](https://github.com/lobehub/lobe-chat) 的跨平
|
||||
pnpm install-isolated
|
||||
|
||||
# 启动开发服务器
|
||||
pnpm electron:dev
|
||||
pnpm dev
|
||||
|
||||
# 类型检查
|
||||
pnpm type-check
|
||||
@@ -51,19 +51,20 @@ cp .env.desktop .env
|
||||
|
||||
### 构建命令
|
||||
|
||||
| 命令 | 描述 |
|
||||
| ------------------ | ---------------------------------- |
|
||||
| `pnpm build` | 构建所有平台 |
|
||||
| `pnpm build:mac` | 构建 macOS (Intel + Apple Silicon) |
|
||||
| `pnpm build:win` | 构建 Windows |
|
||||
| `pnpm build:linux` | 构建 Linux |
|
||||
| `pnpm build-local` | 本地开发构建 |
|
||||
| 命令 | 描述 |
|
||||
| -------------------------- | ---------------------------------- |
|
||||
| `pnpm build:main` | 构建 main/preload(仅产出 dist) |
|
||||
| `pnpm package:mac` | 打包 macOS (Intel + Apple Silicon) |
|
||||
| `pnpm package:win` | 打包 Windows |
|
||||
| `pnpm package:linux` | 打包 Linux |
|
||||
| `pnpm package:local` | 本地打包(不打 ASAR) |
|
||||
| `pnpm package:local:reuse` | 本地打包复用已有 dist |
|
||||
|
||||
### 开发工作流
|
||||
|
||||
```bash
|
||||
# 1. 开发
|
||||
pnpm electron:dev # 启动热重载开发服务器
|
||||
pnpm dev # 启动热重载开发服务器
|
||||
|
||||
# 2. 代码质量
|
||||
pnpm lint # ESLint 检查
|
||||
@@ -74,8 +75,8 @@ pnpm type-check # TypeScript 验证
|
||||
pnpm test # 运行 Vitest 测试
|
||||
|
||||
# 4. 构建和打包
|
||||
pnpm build # 生产构建
|
||||
pnpm build-local # 本地测试构建
|
||||
pnpm build:main # 生产构建(仅 dist)
|
||||
pnpm package:local # 本地测试打包
|
||||
```
|
||||
|
||||
## 🎯 发布渠道
|
||||
|
||||
@@ -11,16 +11,10 @@
|
||||
"author": "LobeHub",
|
||||
"main": "./dist/main/index.js",
|
||||
"scripts": {
|
||||
"build": "electron-vite build",
|
||||
"build:linux": "npm run build && electron-builder --linux --config electron-builder.mjs --publish never",
|
||||
"build:mac": "npm run build && electron-builder --mac --config electron-builder.mjs --publish never",
|
||||
"build:mac:local": "npm run build && UPDATE_CHANNEL=nightly electron-builder --mac --config electron-builder.mjs --publish never",
|
||||
"build:win": "npm run build && electron-builder --win --config electron-builder.mjs --publish never",
|
||||
"build-local": "npm run build && electron-builder --dir --config electron-builder.mjs --c.mac.notarize=false -c.mac.identity=null --c.asar=false",
|
||||
"build:main": "electron-vite build",
|
||||
"build:run-unpack": "electron .",
|
||||
"dev": "electron-vite dev",
|
||||
"dev:static": "cross-env DESKTOP_RENDERER_STATIC=1 npm run electron:dev",
|
||||
"electron:dev": "electron-vite dev",
|
||||
"electron:run-unpack": "electron .",
|
||||
"dev:static": "cross-env DESKTOP_RENDERER_STATIC=1 npm run dev",
|
||||
"format": "prettier --write ",
|
||||
"i18n": "tsx scripts/i18nWorkflow/index.ts && lobe-i18n",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
@@ -32,6 +26,12 @@
|
||||
"lint:md": "remark . --silent --output",
|
||||
"lint:style": "stylelint \"{src,tests}/**/*.{js,jsx,ts,tsx}\" --fix",
|
||||
"lint:ts": "eslint \"{src,tests}/**/*.{js,jsx,ts,tsx}\" --fix",
|
||||
"package:linux": "npm run build:main && electron-builder --linux --config electron-builder.mjs --publish never",
|
||||
"package:local": "npm run build:main && electron-builder --dir --config electron-builder.mjs --c.mac.notarize=false -c.mac.identity=null --c.asar=false",
|
||||
"package:local:reuse": "electron-builder --dir --config electron-builder.mjs --c.mac.notarize=false -c.mac.identity=null --c.asar=false",
|
||||
"package:mac": "npm run build:main && electron-builder --mac --config electron-builder.mjs --publish never",
|
||||
"package:mac:local": "npm run build:main && UPDATE_CHANNEL=nightly electron-builder --mac --config electron-builder.mjs --publish never",
|
||||
"package:win": "npm run build:main && electron-builder --win --config electron-builder.mjs --publish never",
|
||||
"start": "electron-vite preview",
|
||||
"stylelint": "stylelint \"src/**/*.{js,jsx,ts,tsx}\" --fix",
|
||||
"test": "vitest --run",
|
||||
|
||||
@@ -41,8 +41,8 @@ chmod +x *.sh
|
||||
cd ../..
|
||||
|
||||
# 构建未签名的本地测试包
|
||||
bun run build
|
||||
bun run build-local
|
||||
bun run build:main
|
||||
bun run package:local
|
||||
```
|
||||
|
||||
如果需要模拟 CI 的渠道构建(Nightly / Beta / Stable),可以使用根目录脚本:
|
||||
|
||||
18
package.json
18
package.json
@@ -37,7 +37,6 @@
|
||||
"postbuild": "npm run build-sitemap && npm run build-migrate-db",
|
||||
"build:analyze": "NODE_OPTIONS=--max-old-space-size=81920 ANALYZE=true next build --webpack",
|
||||
"build:docker": "npm run prebuild && NODE_OPTIONS=--max-old-space-size=8192 DOCKER=true next build --webpack && npm run build-sitemap",
|
||||
"build:electron": "cross-env NODE_OPTIONS=--max-old-space-size=8192 NEXT_PUBLIC_IS_DESKTOP_APP=1 tsx scripts/electronWorkflow/buildNextApp.mts",
|
||||
"build:vercel": "tsx scripts/prebuild.mts && npm run lint:ts && npm run lint:style && npm run type-check:tsc && npm run lint:circular && cross-env NODE_OPTIONS=--max-old-space-size=6144 next build --webpack && npm run postbuild",
|
||||
"build-migrate-db": "bun run db:migrate",
|
||||
"build-sitemap": "tsx ./scripts/buildSitemapIndex/index.ts",
|
||||
@@ -46,16 +45,21 @@
|
||||
"db:migrate": "MIGRATION_DB=1 tsx ./scripts/migrateServerDB/index.ts",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"db:visualize": "dbdocs build docs/development/database-schema.dbml --project lobe-chat",
|
||||
"desktop:build": "npm run desktop:build-next && npm run desktop:prepare-dist && npm run desktop:build-electron",
|
||||
"desktop:build:all": "npm run desktop:build:renderer:all && npm run desktop:build:main",
|
||||
"desktop:build:main": "npm run build:main --prefix=./apps/desktop",
|
||||
"desktop:build:renderer": "cross-env NODE_OPTIONS=--max-old-space-size=8192 NEXT_PUBLIC_IS_DESKTOP_APP=1 tsx scripts/electronWorkflow/buildNextApp.mts",
|
||||
"desktop:build:renderer:all": "npm run desktop:build:renderer && npm run desktop:build:renderer:prepare",
|
||||
"desktop:build:renderer:prepare": "tsx scripts/electronWorkflow/moveNextExports.ts",
|
||||
"desktop:build-channel": "tsx scripts/electronWorkflow/buildDesktopChannel.ts",
|
||||
"desktop:build-electron": "tsx scripts/electronWorkflow/buildElectron.ts",
|
||||
"desktop:build-local": "npm run desktop:build-next && npm run desktop:prepare-dist && npm run build-local --prefix=./apps/desktop",
|
||||
"desktop:build-next": "npm run build:electron",
|
||||
"desktop:prepare-dist": "tsx scripts/electronWorkflow/moveNextExports.ts",
|
||||
"desktop:main:build": "npm run desktop:main:build --prefix=./apps/desktop",
|
||||
"desktop:package:app": "npm run desktop:build:renderer:all && npm run desktop:package:app:platform",
|
||||
"desktop:package:app:platform": "tsx scripts/electronWorkflow/buildElectron.ts",
|
||||
"desktop:package:local": "npm run desktop:build:renderer:all && npm run package:local --prefix=./apps/desktop",
|
||||
"desktop:package:local:reuse": "npm run package:local:reuse --prefix=./apps/desktop",
|
||||
"dev": "next dev -p 3010",
|
||||
"dev:bun": "bun --bun next dev -p 3010",
|
||||
"dev:desktop": "cross-env NEXT_PUBLIC_IS_DESKTOP_APP=1 tsx scripts/runNextDesktop.mts dev -p 3015",
|
||||
"dev:desktop:static": "cross-env DESKTOP_RENDERER_STATIC=1 npm run electron:dev --prefix=./apps/desktop",
|
||||
"dev:desktop:static": "cross-env DESKTOP_RENDERER_STATIC=1 npm run dev --prefix=./apps/desktop",
|
||||
"dev:docker": "docker compose -f docker-compose/dev/docker-compose.yml up -d --wait postgresql redis rustfs searxng",
|
||||
"dev:docker:down": "docker compose -f docker-compose/dev/docker-compose.yml down",
|
||||
"dev:docker:reset": "docker compose -f docker-compose/dev/docker-compose.yml down -v && rm -rf docker-compose/dev/data && npm run dev:docker && pnpm db:migrate",
|
||||
|
||||
@@ -115,7 +115,7 @@ const main = async () => {
|
||||
|
||||
try {
|
||||
runCommand(`npm run workflow:set-desktop-version ${version} ${channel}`);
|
||||
runCommand('npm run desktop:build', { UPDATE_CHANNEL: channel });
|
||||
runCommand('npm run desktop:package:app', { UPDATE_CHANNEL: channel });
|
||||
} catch (error) {
|
||||
console.error('❌ Build failed:', error);
|
||||
process.exit(1);
|
||||
|
||||
@@ -17,17 +17,17 @@ const buildElectron = () => {
|
||||
// Determine build command based on platform
|
||||
switch (platform) {
|
||||
case 'darwin': {
|
||||
buildCommand = 'npm run build:mac --prefix=./apps/desktop';
|
||||
buildCommand = 'npm run package:mac --prefix=./apps/desktop';
|
||||
console.log('📦 Building macOS desktop application...');
|
||||
break;
|
||||
}
|
||||
case 'win32': {
|
||||
buildCommand = 'npm run build:win --prefix=./apps/desktop';
|
||||
buildCommand = 'npm run package:win --prefix=./apps/desktop';
|
||||
console.log('📦 Building Windows desktop application...');
|
||||
break;
|
||||
}
|
||||
case 'linux': {
|
||||
buildCommand = 'npm run build:linux --prefix=./apps/desktop';
|
||||
buildCommand = 'npm run package:linux --prefix=./apps/desktop';
|
||||
console.log('📦 Building Linux desktop application...');
|
||||
break;
|
||||
}
|
||||
|
||||
430
scripts/electronWorkflow/modifiers/i18nDynamicToStatic.mts
Normal file
430
scripts/electronWorkflow/modifiers/i18nDynamicToStatic.mts
Normal file
@@ -0,0 +1,430 @@
|
||||
/* eslint-disable no-undef */
|
||||
import { Lang, parse } from '@ast-grep/napi';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'node:path';
|
||||
|
||||
import { invariant, isDirectRun, runStandalone, updateFile, writeFileEnsuring } from './utils.mjs';
|
||||
|
||||
interface I18nMetadata {
|
||||
defaultLang: string;
|
||||
locales: string[];
|
||||
namespaces: string[];
|
||||
}
|
||||
|
||||
type CodeEdit = { end: number; start: number; text: string };
|
||||
|
||||
const toIdentifier = (value: string) => value.replaceAll(/\W/g, '_');
|
||||
|
||||
const extractDefaultLang = (code: string): string => {
|
||||
const match = code.match(/export const DEFAULT_LANG = '([^']+)'/);
|
||||
if (!match) throw new Error('[convertI18nDynamicToStatic] Failed to extract DEFAULT_LANG');
|
||||
return match[1];
|
||||
};
|
||||
|
||||
const extractLocales = (code: string): string[] => {
|
||||
const match = code.match(/export const locales = \[([\S\s]*?)] as const;/);
|
||||
if (!match) throw new Error('[convertI18nDynamicToStatic] Failed to extract locales array');
|
||||
|
||||
const locales: string[] = [];
|
||||
const regex = /'([^']+)'/g;
|
||||
let result: RegExpExecArray | null;
|
||||
|
||||
// eslint-disable-next-line no-cond-assign
|
||||
while ((result = regex.exec(match[1])) !== null) {
|
||||
locales.push(result[1]);
|
||||
}
|
||||
|
||||
invariant(locales.length > 0, '[convertI18nDynamicToStatic] No locales found');
|
||||
return locales;
|
||||
};
|
||||
|
||||
const extractNamespaces = (code: string): string[] => {
|
||||
const match = code.match(/const resources = {([\S\s]*?)} as const;/);
|
||||
if (!match)
|
||||
throw new Error('[convertI18nDynamicToStatic] Failed to extract default resources map');
|
||||
|
||||
const namespaces = new Set<string>();
|
||||
|
||||
for (const rawLine of match[1].split('\n')) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('//')) continue;
|
||||
|
||||
const withoutComma = line.replace(/,$/, '').trim();
|
||||
|
||||
if (withoutComma.includes(':')) {
|
||||
const keyPart = withoutComma.split(':')[0].trim();
|
||||
const keyMatch = keyPart.match(/^'([^']+)'$/);
|
||||
if (keyMatch) namespaces.add(keyMatch[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
const identifierMatch = withoutComma.match(/^(\w+)$/);
|
||||
if (identifierMatch) namespaces.add(identifierMatch[1]);
|
||||
}
|
||||
|
||||
invariant(namespaces.size > 0, '[convertI18nDynamicToStatic] No namespaces found');
|
||||
return [...namespaces].sort();
|
||||
};
|
||||
|
||||
const loadI18nMetadata = async (TEMP_DIR: string): Promise<I18nMetadata> => {
|
||||
const defaultLangPath = path.join(TEMP_DIR, 'src/const/locale.ts');
|
||||
const localesPath = path.join(TEMP_DIR, 'src/locales/resources.ts');
|
||||
const defaultResourcesPath = path.join(TEMP_DIR, 'src/locales/default/index.ts');
|
||||
|
||||
const [defaultLangCode, localesCode, defaultResourcesCode] = await Promise.all([
|
||||
fs.readFile(defaultLangPath, 'utf8'),
|
||||
fs.readFile(localesPath, 'utf8'),
|
||||
fs.readFile(defaultResourcesPath, 'utf8'),
|
||||
]);
|
||||
|
||||
const defaultLang = extractDefaultLang(defaultLangCode);
|
||||
const locales = extractLocales(localesCode);
|
||||
const namespaces = extractNamespaces(defaultResourcesCode);
|
||||
|
||||
return { defaultLang, locales, namespaces };
|
||||
};
|
||||
|
||||
const generateLocaleNamespaceImports = (metadata: I18nMetadata) => {
|
||||
const importLines: string[] = [];
|
||||
const localeEntries: string[] = [];
|
||||
|
||||
for (const locale of metadata.locales) {
|
||||
if (locale === metadata.defaultLang) continue;
|
||||
|
||||
const namespaceEntries: string[] = [];
|
||||
|
||||
for (const ns of metadata.namespaces) {
|
||||
const alias = `locale_${toIdentifier(locale)}__${toIdentifier(ns)}`;
|
||||
importLines.push(`import ${alias} from '@/../locales/${locale}/${ns}.json';`);
|
||||
namespaceEntries.push(` '${ns}': { default: ${alias} },`);
|
||||
}
|
||||
|
||||
localeEntries.push(` '${locale}': {\n${namespaceEntries.join('\n')}\n },`);
|
||||
}
|
||||
|
||||
return {
|
||||
imports: importLines.join('\n'),
|
||||
localeEntries: localeEntries.join('\n'),
|
||||
};
|
||||
};
|
||||
|
||||
const generateBusinessUiImports = (metadata: I18nMetadata) => {
|
||||
const importLines: string[] = [];
|
||||
const mapEntries: string[] = [];
|
||||
|
||||
for (const locale of metadata.locales) {
|
||||
const alias = `ui_${toIdentifier(locale)}`;
|
||||
importLines.push(`import ${alias} from '@/../locales/${locale}/ui.json';`);
|
||||
mapEntries.push(` '${locale}': ${alias} as UILocaleResources,`);
|
||||
}
|
||||
|
||||
return {
|
||||
imports: importLines.join('\n'),
|
||||
mapEntries: mapEntries.join('\n'),
|
||||
};
|
||||
};
|
||||
|
||||
const buildElectronI18nMapContent = (metadata: I18nMetadata) => {
|
||||
const { imports, localeEntries } = generateLocaleNamespaceImports(metadata);
|
||||
|
||||
return `import defaultResources from '@/locales/default';
|
||||
|
||||
${imports}
|
||||
|
||||
export type LocaleNamespaceModule = { default: unknown };
|
||||
|
||||
const toModule = (resource: unknown): LocaleNamespaceModule => ({ default: resource });
|
||||
|
||||
export const defaultNamespaceModules: Record<string, LocaleNamespaceModule> = Object.fromEntries(
|
||||
Object.entries(defaultResources).map(([ns, resource]) => [ns, toModule(resource)]),
|
||||
);
|
||||
|
||||
export const getDefaultNamespaceModule = (ns: string): LocaleNamespaceModule => {
|
||||
const resource = defaultResources[ns as keyof typeof defaultResources] ?? defaultResources.common;
|
||||
return toModule(resource);
|
||||
};
|
||||
|
||||
export const staticLocaleNamespaceMap: Record<string, Record<string, LocaleNamespaceModule>> = {
|
||||
'${metadata.defaultLang}': defaultNamespaceModules,
|
||||
${localeEntries}
|
||||
};
|
||||
`;
|
||||
};
|
||||
|
||||
const buildElectronUiResourcesContent = (metadata: I18nMetadata) => {
|
||||
const { imports, mapEntries } = generateBusinessUiImports(metadata);
|
||||
|
||||
return `${imports}
|
||||
|
||||
export type UILocaleResources = Record<string, Record<string, string>>;
|
||||
|
||||
export const businessUiResources: Record<string, UILocaleResources> = {
|
||||
${mapEntries}
|
||||
};
|
||||
`;
|
||||
};
|
||||
|
||||
const applyEdits = (code: string, edits: CodeEdit[]): string => {
|
||||
if (edits.length === 0) return code;
|
||||
|
||||
const sorted = [...edits].sort((a, b) => b.start - a.start);
|
||||
let result = code;
|
||||
|
||||
for (const edit of sorted) {
|
||||
result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const ensureImportAfterLastImport = (code: string, importStatement: string): string => {
|
||||
if (code.includes(importStatement)) return code;
|
||||
|
||||
const moduleMatch = importStatement.match(/from '([^']+)'/);
|
||||
if (moduleMatch) {
|
||||
const modulePath = moduleMatch[1];
|
||||
const hasModuleImport = new RegExp(`from ['"]${modulePath}['"]`).test(code);
|
||||
if (hasModuleImport) return code;
|
||||
}
|
||||
|
||||
const ast = parse(Lang.TypeScript, code);
|
||||
const root = ast.root();
|
||||
const imports = root.findAll({ rule: { kind: 'import_statement' } });
|
||||
if (imports.length === 0) {
|
||||
return `${importStatement}\n\n${code}`;
|
||||
}
|
||||
|
||||
const lastImport = imports.at(-1)!;
|
||||
const insertPos = lastImport.range().end.index;
|
||||
|
||||
return code.slice(0, insertPos) + `\n${importStatement}` + code.slice(insertPos);
|
||||
};
|
||||
|
||||
const transformLoadNamespaceModule = (code: string) => {
|
||||
const importStatement =
|
||||
"import { defaultNamespaceModules, getDefaultNamespaceModule, staticLocaleNamespaceMap } from '@/utils/i18n/__electronI18nMap';";
|
||||
|
||||
let result = ensureImportAfterLastImport(code, importStatement);
|
||||
|
||||
const ast = parse(Lang.TypeScript, result);
|
||||
const root = ast.root();
|
||||
|
||||
const edits: CodeEdit[] = [];
|
||||
|
||||
const defaultLangReturns = root.findAll({
|
||||
rule: {
|
||||
pattern: 'if (lng === defaultLang) return import(`@/locales/default/${ns}`);',
|
||||
},
|
||||
});
|
||||
|
||||
for (const node of defaultLangReturns) {
|
||||
const range = node.range();
|
||||
edits.push({
|
||||
end: range.end.index,
|
||||
start: range.start.index,
|
||||
text: 'if (lng === defaultLang) return defaultNamespaceModules[ns] ?? getDefaultNamespaceModule(ns);',
|
||||
});
|
||||
}
|
||||
|
||||
const dynamicLocaleReturns = root.findAll({
|
||||
rule: {
|
||||
pattern: 'return import(`@/../locales/${normalizeLocale(lng)}/${ns}.json`);',
|
||||
},
|
||||
});
|
||||
|
||||
for (const node of dynamicLocaleReturns) {
|
||||
const range = node.range();
|
||||
edits.push({
|
||||
end: range.end.index,
|
||||
start: range.start.index,
|
||||
text: 'return staticLocaleNamespaceMap[normalizeLocale(lng)]?.[ns] ?? getDefaultNamespaceModule(ns);',
|
||||
});
|
||||
}
|
||||
|
||||
const defaultFallbackReturns = root.findAll({
|
||||
rule: {
|
||||
pattern: 'return import(`@/locales/default/${ns}`);',
|
||||
},
|
||||
});
|
||||
|
||||
for (const node of defaultFallbackReturns) {
|
||||
const range = node.range();
|
||||
edits.push({
|
||||
end: range.end.index,
|
||||
start: range.start.index,
|
||||
text: 'return getDefaultNamespaceModule(ns);',
|
||||
});
|
||||
}
|
||||
|
||||
result = applyEdits(result, edits);
|
||||
|
||||
// Fallback to robust function-level replacements if AST patterns did not match.
|
||||
result = result.replace(
|
||||
/export const loadI18nNamespaceModule = async[\S\s]*?};/m,
|
||||
`export const loadI18nNamespaceModule = async (params: LoadI18nNamespaceModuleParams) => {
|
||||
const { defaultLang, normalizeLocale, lng, ns } = params;
|
||||
|
||||
if (lng === defaultLang) return defaultNamespaceModules[ns] ?? getDefaultNamespaceModule(ns);
|
||||
|
||||
try {
|
||||
const normalizedLocale = normalizeLocale(lng);
|
||||
const localeResources = staticLocaleNamespaceMap[normalizedLocale];
|
||||
|
||||
if (localeResources?.[ns]) return localeResources[ns];
|
||||
|
||||
return defaultNamespaceModules[ns] ?? getDefaultNamespaceModule(ns);
|
||||
} catch {
|
||||
return getDefaultNamespaceModule(ns);
|
||||
}
|
||||
};`,
|
||||
);
|
||||
result = result.replace(
|
||||
/export const loadI18nNamespaceModuleWithFallback = async[\S\s]*?};/m,
|
||||
`export const loadI18nNamespaceModuleWithFallback = async (
|
||||
params: LoadI18nNamespaceModuleWithFallbackParams,
|
||||
) => {
|
||||
const { onFallback, ...rest } = params;
|
||||
|
||||
try {
|
||||
return await loadI18nNamespaceModule(rest);
|
||||
} catch (error) {
|
||||
onFallback?.({ error, lng: rest.lng, ns: rest.ns });
|
||||
return getDefaultNamespaceModule(rest.ns);
|
||||
}
|
||||
};`,
|
||||
);
|
||||
|
||||
result = result.replaceAll(/\n{3,}/g, '\n\n');
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const replaceFunctionBody = (code: string, functionName: string, newBody: string): string => {
|
||||
const ast = parse(Lang.TypeScript, code);
|
||||
const root = ast.root();
|
||||
|
||||
const target = root.find({
|
||||
rule: {
|
||||
kind: 'variable_declarator',
|
||||
pattern: `const ${functionName} = $EXPR`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!target) return code;
|
||||
|
||||
const declaratorText = target.text();
|
||||
const initMatch = declaratorText.match(/=\s*([\S\s]*)$/);
|
||||
if (!initMatch) return code;
|
||||
|
||||
const initText = initMatch[1];
|
||||
const initStart = declaratorText.indexOf(initText);
|
||||
if (initStart < 0) return code;
|
||||
|
||||
const fullRange = target.range();
|
||||
const initRange = {
|
||||
end: fullRange.start.index + initStart + initText.length,
|
||||
start: fullRange.start.index + initStart,
|
||||
};
|
||||
|
||||
const updated = code.slice(0, initRange.start) + newBody + code.slice(initRange.end);
|
||||
return updated;
|
||||
};
|
||||
|
||||
const transformUiLocaleResources = (code: string) => {
|
||||
const uiImportStatement = "import { en, zhCn } from '@lobehub/ui/es/i18n/resources/index';";
|
||||
const businessImportStatement =
|
||||
"import { businessUiResources } from '@/libs/__electronUiResources';";
|
||||
|
||||
let result = ensureImportAfterLastImport(code, uiImportStatement);
|
||||
result = ensureImportAfterLastImport(result, businessImportStatement);
|
||||
|
||||
result = replaceFunctionBody(
|
||||
result,
|
||||
'loadBusinessResources',
|
||||
`(locale: string): UILocaleResources | null => {
|
||||
return businessUiResources[locale] ?? null;
|
||||
}`,
|
||||
);
|
||||
|
||||
result = replaceFunctionBody(
|
||||
result,
|
||||
'loadLobeUIBuiltinResources',
|
||||
`(locale: string): UILocaleResources | null => {
|
||||
if (locale.startsWith('zh')) return zhCn as UILocaleResources;
|
||||
return en as UILocaleResources;
|
||||
}`,
|
||||
);
|
||||
|
||||
// Fallback to string replacements if AST patterns did not match.
|
||||
result = result.replace(
|
||||
/const loadBusinessResources = async[\S\s]*?};/m,
|
||||
`const loadBusinessResources = (locale: string): UILocaleResources | null => {
|
||||
return businessUiResources[locale] ?? null;
|
||||
};`,
|
||||
);
|
||||
result = result.replace(
|
||||
/const loadLobeUIBuiltinResources = async[\S\s]*?};/m,
|
||||
`const loadLobeUIBuiltinResources = (locale: string): UILocaleResources | null => {
|
||||
if (locale.startsWith('zh')) return zhCn as UILocaleResources;
|
||||
return en as UILocaleResources;
|
||||
};`,
|
||||
);
|
||||
|
||||
result = result.replaceAll(/\n{3,}/g, '\n\n');
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const convertI18nDynamicToStatic = async (TEMP_DIR: string) => {
|
||||
console.log(' Converting i18n dynamic imports to static maps...');
|
||||
|
||||
const metadata = await loadI18nMetadata(TEMP_DIR);
|
||||
|
||||
const electronI18nMapPath = path.join(TEMP_DIR, 'src/utils/i18n/__electronI18nMap.ts');
|
||||
const electronUiResourcesPath = path.join(TEMP_DIR, 'src/libs/__electronUiResources.ts');
|
||||
const loadNamespacePath = path.join(TEMP_DIR, 'src/utils/i18n/loadI18nNamespaceModule.ts');
|
||||
const uiLocalePath = path.join(TEMP_DIR, 'src/libs/getUILocaleAndResources.ts');
|
||||
|
||||
await fs.ensureFile(electronI18nMapPath);
|
||||
await fs.ensureFile(electronUiResourcesPath);
|
||||
|
||||
await writeFileEnsuring({
|
||||
assertAfter: (code) => !code.includes('import(`'),
|
||||
filePath: electronI18nMapPath,
|
||||
name: 'convertI18nDynamicToStatic.electronI18nMap',
|
||||
text: buildElectronI18nMapContent(metadata),
|
||||
});
|
||||
|
||||
await writeFileEnsuring({
|
||||
assertAfter: (code) => !code.includes('await import('),
|
||||
filePath: electronUiResourcesPath,
|
||||
name: 'convertI18nDynamicToStatic.electronUiResources',
|
||||
text: buildElectronUiResourcesContent(metadata),
|
||||
});
|
||||
|
||||
await updateFile({
|
||||
assertAfter: (code) =>
|
||||
code.includes('@/utils/i18n/__electronI18nMap') &&
|
||||
!code.includes('import(`@/locales/default/${ns}`)') &&
|
||||
!code.includes('import(`@/locales/default/${rest.ns}`)') &&
|
||||
!code.includes('import(`@/../locales/${normalizeLocale(lng)}/${ns}.json`)'),
|
||||
filePath: loadNamespacePath,
|
||||
name: 'convertI18nDynamicToStatic.loadNamespace',
|
||||
transformer: transformLoadNamespaceModule,
|
||||
});
|
||||
|
||||
await updateFile({
|
||||
assertAfter: (code) =>
|
||||
code.includes('@/libs/__electronUiResources') &&
|
||||
code.includes('@lobehub/ui/es/i18n/resources/index') &&
|
||||
!code.includes('await import(`@/../locales/${locale}/ui.json`)') &&
|
||||
!code.includes("await import('@lobehub/ui/es/i18n/resources/index')"),
|
||||
filePath: uiLocalePath,
|
||||
name: 'convertI18nDynamicToStatic.uiLocale',
|
||||
transformer: transformUiLocaleResources,
|
||||
});
|
||||
};
|
||||
|
||||
if (isDirectRun(import.meta.url)) {
|
||||
await runStandalone('convertI18nDynamicToStatic', convertI18nDynamicToStatic, []);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import path from 'node:path';
|
||||
import { modifyAppCode } from './appCode.mjs';
|
||||
import { cleanUpCode } from './cleanUp.mjs';
|
||||
import { convertDynamicToStatic } from './dynamicToStatic.mjs';
|
||||
import { convertI18nDynamicToStatic } from './i18nDynamicToStatic.mjs';
|
||||
import { convertNextDynamicToStatic } from './nextDynamicToStatic.mjs';
|
||||
import { modifyNextConfig } from './nextConfig.mjs';
|
||||
import { removeSuspenseFromConversation } from './removeSuspense.mjs';
|
||||
@@ -19,6 +20,7 @@ export const modifySourceForElectron = async (TEMP_DIR: string) => {
|
||||
await wrapChildrenWithClientOnly(TEMP_DIR);
|
||||
await convertDynamicToStatic(TEMP_DIR);
|
||||
await convertNextDynamicToStatic(TEMP_DIR);
|
||||
await convertI18nDynamicToStatic(TEMP_DIR);
|
||||
await convertSettingsContentToStatic(TEMP_DIR);
|
||||
await removeSuspenseFromConversation(TEMP_DIR);
|
||||
await modifyRoutes(TEMP_DIR);
|
||||
|
||||
Reference in New Issue
Block a user