diff --git a/.env.desktop b/.env.desktop index 2b66c5c793..01fc6d2b51 100644 --- a/.env.desktop +++ b/.env.desktop @@ -3,6 +3,5 @@ APP_URL=http://localhost:3015 FEATURE_FLAGS=-check_updates,+pin_list KEY_VAULTS_SECRET=oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE= DATABASE_URL=postgresql://postgres@localhost:5432/postgres -DEFAULT_AGENT_CONFIG="model=qwen2.5;provider=ollama;chatConfig.searchFCModel.provider=ollama;chatConfig.searchFCModel.model=qwen2.5" -SYSTEM_AGENT="default=ollama/qwen2.5" SEARCH_PROVIDERS=search1api +NEXT_PUBLIC_SERVICE_MODE='server' diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/desktop-pr-build.yml similarity index 50% rename from .github/workflows/release-desktop.yml rename to .github/workflows/desktop-pr-build.yml index b7989dd881..8d02edcb99 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/desktop-pr-build.yml @@ -1,9 +1,6 @@ -name: Release Desktop +name: Desktop PR Build on: -# uncomment when official desktop version released -# release: -# types: [published] # 发布 release 时触发构建 pull_request: types: [synchronize, labeled, unlabeled] # PR 更新或标签变化时触发 @@ -12,6 +9,9 @@ concurrency: group: ${{ github.ref }}-${{ github.workflow }} cancel-in-progress: true +# Add default permissions +permissions: read-all + env: PR_TAG_PREFIX: pr- # PR 构建版本的前缀标识 @@ -19,10 +19,7 @@ jobs: test: name: Code quality check # 添加 PR label 触发条件,只有添加了 Build Desktop 标签的 PR 才会触发构建 - if: | - (github.event_name == 'pull_request' && - contains(github.event.pull_request.labels.*.name, 'Build Desktop')) || - github.event_name != 'pull_request' + if: contains(github.event.pull_request.labels.*.name, 'Build Desktop') runs-on: ubuntu-latest # 只在 ubuntu 上运行一次检查 steps: - name: Checkout base @@ -38,7 +35,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v2 with: - version: 8 + version: 9 - name: Install deps run: pnpm install @@ -50,21 +47,14 @@ jobs: env: NODE_OPTIONS: --max-old-space-size=6144 - # - name: Test - # run: pnpm run test - version: name: Determine version # 与 test job 相同的触发条件 - if: | - (github.event_name == 'pull_request' && - contains(github.event.pull_request.labels.*.name, 'Build Desktop')) || - github.event_name != 'pull_request' + if: contains(github.event.pull_request.labels.*.name, 'Build Desktop') 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@v4 with: @@ -82,32 +72,13 @@ jobs: # 从 apps/desktop/package.json 读取基础版本号 base_version=$(node -p "require('./apps/desktop/package.json').version") - if [ "${{ github.event_name }}" == "pull_request" ]; then - # PR 构建:在基础版本号上添加 PR 信息 - branch_name="${{ github.head_ref }}" - # 清理分支名,移除非法字符 - sanitized_branch=$(echo "${branch_name}" | sed -E 's/[^a-zA-Z0-9_.-]+/-/g') - # 创建特殊的 PR 版本号:基础版本号-PR前缀-分支名-提交哈希 - version="${base_version}-${{ env.PR_TAG_PREFIX }}${sanitized_branch}-$(git rev-parse --short HEAD)" - echo "version=${version}" >> $GITHUB_OUTPUT - echo "is_pr_build=true" >> $GITHUB_OUTPUT - echo "📦 Release Version: ${version} (based on base version ${base_version})" + # PR 构建:在基础版本号上添加 PR 信息 + pr_number="${{ github.event.pull_request.number }}" + ci_build_number="${{ github.run_number }}" # CI 构建编号 + version="${base_version}-nightly.pr${pr_number}.${ci_build_number}" + echo "version=${version}" >> $GITHUB_OUTPUT + echo "📦 Release Version: ${version} (based on base version ${base_version})" - elif [ "${{ github.event_name }}" == "release" ]; then - # Release 事件直接使用 release tag 作为版本号,去掉可能的 v 前缀 - version="${{ github.event.release.tag_name }}" - version="${version#v}" - echo "version=${version}" >> $GITHUB_OUTPUT - echo "is_pr_build=false" >> $GITHUB_OUTPUT - echo "📦 Release Version: ${version}" - - else - # 其他情况(如手动触发)使用 apps/desktop/package.json 的版本号 - version="${base_version}" - echo "version=${version}" >> $GITHUB_OUTPUT - echo "is_pr_build=false" >> $GITHUB_OUTPUT - echo "📦 Release Version: ${version}" - fi env: NODE_OPTIONS: --max-old-space-size=6144 @@ -115,7 +86,6 @@ jobs: - name: Version Summary run: | echo "🚦 Release Version: ${{ steps.set_version.outputs.version }}" - echo "🔄 Is PR Build: ${{ steps.set_version.outputs.is_pr_build }}" build: needs: [version, test] @@ -137,43 +107,57 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v2 with: - version: 8 + version: 9 + # node-linker=hoisted 模式将可以确保 asar 压缩可用 - name: Install deps - run: pnpm install + 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 }} + run: npm run workflow:set-desktop-version ${{ needs.version.outputs.version }} nightly # macOS 构建处理 - name: Build artifact on macOS if: runner.os == 'macOS' run: npm run desktop:build env: - APP_URL: http://localhost:3010 + # 设置更新通道,PR构建为nightly,否则为stable + UPDATE_CHANNEL: 'nightly' + APP_URL: http://localhost:3015 DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres' # 默认添加一个加密 SECRET KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE=' - # 公证部分将来再加回 - # CSC_LINK: ./build/developer-id-app-certs.p12 - # CSC_KEY_PASSWORD: ${{ secrets.APPLE_APP_CERTS_PASSWORD }} - # APPLE_ID: ${{ secrets.APPLE_ID }} - # APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} + # macOS 签名和公证配置 + CSC_LINK: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} + CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_NIGHTLY_DESKTOP_PROJECT_ID }} + NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_NIGHTLY_DESKTOP_BASE_URL }} + + # 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 }} # 非 macOS 平台构建处理 - name: Build artifact on other platforms if: runner.os != 'macOS' run: npm run desktop:build env: - APP_URL: http://localhost:3010 + # 设置更新通道,PR构建为nightly,否则为stable + UPDATE_CHANNEL: 'nightly' + 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_NIGHTLY_DESKTOP_PROJECT_ID }} + NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_NIGHTLY_DESKTOP_BASE_URL }} - # 上传构建产物,移除了 zip 相关部分 + + # 上传构建产物 - name: Upload artifact uses: actions/upload-artifact@v4 with: @@ -186,18 +170,21 @@ jobs: apps/desktop/release/*.AppImage retention-days: 5 - - name: Log build info - run: | - echo "🔄 Is PR Build: ${{ needs.version.outputs.is_pr_build }}" - - # 将原本的 merge job 调整,作为所有构建产物的准备步骤 - prepare-artifacts: + publish-pr: needs: [build, version] - name: Prepare Artifacts + name: Publish PR Build runs-on: ubuntu-latest + # Grant write permissions for creating release and commenting on PR + permissions: + contents: write + pull-requests: write outputs: artifact_path: ${{ steps.set_path.outputs.path }} steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + # 下载所有平台的构建产物 - name: Download artifacts uses: actions/download-artifact@v4 @@ -210,66 +197,6 @@ jobs: - name: List artifacts run: ls -R release - # 设置构建产物路径,供后续 job 使用 - - name: Set artifact path - id: set_path - run: echo "path=release" >> $GITHUB_OUTPUT - - # 正式版发布 job - 只处理 release 触发的场景 - publish-release: - # 只在 release 事件触发且不是 PR 构建时执行 - if: | - github.event_name == 'release' && - needs.version.outputs.is_pr_build != 'true' - needs: [prepare-artifacts, version] - name: Publish Release - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - # 下载构建产物 - - name: Download artifacts - uses: actions/download-artifact@v4 - with: - path: ${{ needs.prepare-artifacts.outputs.artifact_path }} - pattern: release-* - merge-multiple: true - - # 将构建产物上传到现有 release - - name: Upload to Release - uses: softprops/action-gh-release@v1 - with: - tag_name: ${{ github.event.release.tag_name }} - files: | - ${{ needs.prepare-artifacts.outputs.artifact_path }}/latest* - ${{ needs.prepare-artifacts.outputs.artifact_path }}/*.dmg* - ${{ needs.prepare-artifacts.outputs.artifact_path }}/*.zip* - ${{ needs.prepare-artifacts.outputs.artifact_path }}/*.exe* - ${{ needs.prepare-artifacts.outputs.artifact_path }}/*.AppImage - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - # PR 构建的处理步骤 - publish-pr: - if: needs.version.outputs.is_pr_build == 'true' - needs: [prepare-artifacts, version] - name: Publish PR Build - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - # 下载构建产物 - - name: Download artifacts - uses: actions/download-artifact@v4 - with: - path: ${{ needs.prepare-artifacts.outputs.artifact_path }} - pattern: release-* - merge-multiple: true - # 生成PR发布描述 - name: Generate PR Release Body id: pr_release_body @@ -287,22 +214,22 @@ jobs: return body; - # 为构建产物创建一个临时发布 - name: Create Temporary Release for PR id: create_release uses: softprops/action-gh-release@v1 with: name: PR Build v${{ needs.version.outputs.version }} - tag_name: pr-build-${{ github.event.pull_request.number }}-${{ github.sha }} + tag_name: v${{ needs.version.outputs.version }} + # tag_name: pr-build-${{ github.event.pull_request.number }}-${{ github.sha }} body: ${{ steps.pr_release_body.outputs.result }} draft: false prerelease: true files: | - ${{ needs.prepare-artifacts.outputs.artifact_path }}/latest* - ${{ needs.prepare-artifacts.outputs.artifact_path }}/*.dmg* - ${{ needs.prepare-artifacts.outputs.artifact_path }}/*.zip* - ${{ needs.prepare-artifacts.outputs.artifact_path }}/*.exe* - ${{ needs.prepare-artifacts.outputs.artifact_path }}/*.AppImage + release/latest* + release/*.dmg* + release/*.zip* + release/*.exe* + release/*.AppImage env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -315,17 +242,12 @@ jobs: const releaseUrl = "${{ steps.create_release.outputs.url }}"; const prCommentGenerator = require('${{ github.workspace }}/.github/scripts/pr-comment.js'); - const body = await prCommentGenerator({ + const result = await prCommentGenerator({ github, context, releaseUrl, version: "${{ needs.version.outputs.version }}", - tag: "pr-build-${{ github.event.pull_request.number }}-${{ github.sha }}" + tag: "v${{ needs.version.outputs.version }}" }); - github.rest.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body: body - }); + console.log(`评论状态: ${result.updated ? '已更新' : '已创建'}, ID: ${result.id}`); diff --git a/.github/workflows/release-desktop-beta.yml b/.github/workflows/release-desktop-beta.yml new file mode 100644 index 0000000000..bc6402cebd --- /dev/null +++ b/.github/workflows/release-desktop-beta.yml @@ -0,0 +1,196 @@ +name: Release Desktop + +on: + release: + types: [published] # 发布 release 时触发构建 + +# 确保同一时间只运行一个相同的 workflow,取消正在进行的旧的运行 +concurrency: + group: ${{ github.ref }}-${{ github.workflow }} + cancel-in-progress: true + +# Add default permissions +permissions: read-all + +jobs: + test: + name: Code quality check + # 添加 PR label 触发条件,只有添加了 Build Desktop 标签的 PR 才会触发构建 + runs-on: ubuntu-latest # 只在 ubuntu 上运行一次检查 + steps: + - name: Checkout base + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Setup pnpm + uses: pnpm/action-setup@v2 + with: + version: 9 + + - name: Install deps + run: pnpm install + + - name: Lint + run: pnpm 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@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + # 主要逻辑:确定构建版本号 + - 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 "is_pr_build=false" >> $GITHUB_OUTPUT + echo "📦 Release Version: ${version}" + + # 输出版本信息总结,方便在 GitHub Actions 界面查看 + - name: Version Summary + run: | + echo "🚦 Release Version: ${{ steps.set_version.outputs.version }}" + echo "🔄 Is PR Build: ${{ steps.set_version.outputs.is_pr_build }}" + + build: + needs: [version, test] + name: Build Desktop App + runs-on: ${{ matrix.os }} + strategy: + matrix: + # 暂时先支持 macOS + os: [macos-latest] + # os: [macos-latest, windows-latest, ubuntu-latest] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Setup pnpm + uses: pnpm/action-setup@v2 + with: + version: 9 + + # node-linker=hoisted 模式将可以确保 asar 压缩可用 + - name: Install deps + 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 + + # macOS 构建处理 + - name: Build artifact on macOS + if: runner.os == 'macOS' + run: npm run desktop:build + env: + UPDATE_CHANNEL: 'stable' + APP_URL: http://localhost:3015 + DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres' + # 默认添加一个加密 SECRET + KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE=' + # macOS 签名和公证配置 + 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 }} + + # 非 macOS 平台构建处理 + - name: Build artifact on other platforms + if: runner.os != 'macOS' + run: npm run desktop:build + env: + UPDATE_CHANNEL: 'stable' + 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_BETA_DESKTOP_PROJECT_ID }} + NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_BETA_DESKTOP_BASE_URL }} + + # 上传构建产物,移除了 zip 相关部分 + - name: Upload artifact + uses: actions/upload-artifact@v4 + 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 + retention-days: 5 + + # 正式版发布 job + publish-release: + needs: [build, version] + name: Prepare Artifacts + runs-on: ubuntu-latest + # Grant write permission to contents for uploading release assets + permissions: + contents: write + outputs: + artifact_path: ${{ steps.set_path.outputs.path }} + steps: + # 下载所有平台的构建产物 + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + path: release + pattern: release-* + merge-multiple: true + + # 列出所有构建产物 + - name: List artifacts + run: ls -R release + + # 将构建产物上传到现有 release + - name: Upload to Release + uses: softprops/action-gh-release@v1 + with: + tag_name: ${{ github.event.release.tag_name }} + files: | + ${{ needs.prepare-artifacts.outputs.artifact_path }}/latest* + ${{ needs.prepare-artifacts.outputs.artifact_path }}/*.dmg* + ${{ needs.prepare-artifacts.outputs.artifact_path }}/*.zip* + ${{ needs.prepare-artifacts.outputs.artifact_path }}/*.exe* + ${{ needs.prepare-artifacts.outputs.artifact_path }}/*.AppImage + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/apps/desktop/.gitignore b/apps/desktop/.gitignore new file mode 100644 index 0000000000..fbef2d26a1 --- /dev/null +++ b/apps/desktop/.gitignore @@ -0,0 +1,8 @@ +node_modules +dist +out +.DS_Store +.eslintcache +*.log* +standalone +release diff --git a/apps/desktop/.i18nrc.js b/apps/desktop/.i18nrc.js new file mode 100644 index 0000000000..dfd94d6961 --- /dev/null +++ b/apps/desktop/.i18nrc.js @@ -0,0 +1,31 @@ +const { defineConfig } = require('@lobehub/i18n-cli'); + +module.exports = defineConfig({ + entry: 'resources/locales/zh-CN', + entryLocale: 'zh-CN', + output: 'resources/locales', + outputLocales: [ + 'ar', + 'bg-BG', + 'zh-TW', + 'en-US', + 'ru-RU', + 'ja-JP', + 'ko-KR', + 'fr-FR', + 'tr-TR', + 'es-ES', + 'pt-BR', + 'de-DE', + 'it-IT', + 'nl-NL', + 'pl-PL', + 'vi-VN', + 'fa-IR', + ], + temperature: 0, + modelName: 'gpt-4o-mini', + experimental: { + jsonMode: true, + }, +}); diff --git a/apps/desktop/.npmrc b/apps/desktop/.npmrc new file mode 100644 index 0000000000..fbb6fb5d5a --- /dev/null +++ b/apps/desktop/.npmrc @@ -0,0 +1,4 @@ +lockfile=false +shamefully-hoist=true +electron_mirror=https://npmmirror.com/mirrors/electron/ +electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/ diff --git a/apps/desktop/Development.md b/apps/desktop/Development.md new file mode 100644 index 0000000000..e6d144a69e --- /dev/null +++ b/apps/desktop/Development.md @@ -0,0 +1,47 @@ +## Menu 实现框架 + +``` +apps/desktop/src/main/ +├── core/ +│ ├── App.ts // 应用核心类 +│ ├── BrowserManager.ts // 浏览器窗口管理 +│ └── MenuManager.ts // 新增:菜单管理核心类,负责选择和协调平台实现 +├── menus/ // 新增:菜单实现目录 +│ ├── index.ts // 导出平台实现和接口 +│ ├── types.ts // 定义菜单平台接口 IMenuPlatform +│ └── impl/ // 平台特定实现目录 +│ ├── BaseMenuPlatform.ts // 基础平台类,注入App +│ ├── DarwinMenu.ts // macOS 充血模型实现 +│ ├── WindowsMenu.ts // Windows 充血模型实现 +│ └── LinuxMenu.ts // Linux 充血模型实现 +├── controllers/ +│ └── MenuCtr.ts // 菜单控制器,处理渲染进程调用 +``` + +## i18n + +src/main/ +├── core/ +│ ├── I18nManager.ts //i18n 管理器 +│ └── App.ts // 应用主类,集成 i18n +├── locales/ +│ ├── index.ts // 导出 i18n 相关功能 +│ ├── resources.ts // 资源加载逻辑 +│ └── default/ // 默认中文翻译源文件 +│ ├── index.ts // 导出所有翻译 +│ ├── menu.ts // 菜单翻译 +│ ├── dialog.ts // 对话框翻译 +│ └── common.ts // 通用翻译 + +主进程 i18n 国际化管理 +使用方式: + +1. 直接导入 i18nManager 实例: + import i18nManager from '@/locales'; + +2. 使用翻译函数: + import {t} from '@/locales'; + const translated = t ('key'); + +3. 添加新翻译: + 在 locales/default/ 目录下添加翻译源文件 diff --git a/apps/desktop/README.md b/apps/desktop/README.md new file mode 100644 index 0000000000..313c778cb9 --- /dev/null +++ b/apps/desktop/README.md @@ -0,0 +1,6 @@ +# LobeHub Desktop + +构建路径: + +- dist: 构建产物路径 +- release: 发布产物路径 diff --git a/apps/desktop/build/Icon-beta.icns b/apps/desktop/build/Icon-beta.icns new file mode 100644 index 0000000000..427785d10a Binary files /dev/null and b/apps/desktop/build/Icon-beta.icns differ diff --git a/apps/desktop/build/Icon-nightly.icns b/apps/desktop/build/Icon-nightly.icns new file mode 100644 index 0000000000..d67a726448 Binary files /dev/null and b/apps/desktop/build/Icon-nightly.icns differ diff --git a/apps/desktop/build/Icon.icns b/apps/desktop/build/Icon.icns new file mode 100644 index 0000000000..2e0066950b Binary files /dev/null and b/apps/desktop/build/Icon.icns differ diff --git a/apps/desktop/build/entitlements.mac.plist b/apps/desktop/build/entitlements.mac.plist new file mode 100644 index 0000000000..38c887b211 --- /dev/null +++ b/apps/desktop/build/entitlements.mac.plist @@ -0,0 +1,12 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.allow-dyld-environment-variables + + + diff --git a/apps/desktop/build/favicon.ico b/apps/desktop/build/favicon.ico new file mode 100644 index 0000000000..a9362f77bf Binary files /dev/null and b/apps/desktop/build/favicon.ico differ diff --git a/apps/desktop/build/icon-beta.png b/apps/desktop/build/icon-beta.png new file mode 100644 index 0000000000..728504a4e1 Binary files /dev/null and b/apps/desktop/build/icon-beta.png differ diff --git a/apps/desktop/build/icon-dev.png b/apps/desktop/build/icon-dev.png new file mode 100644 index 0000000000..2b896d6fc8 Binary files /dev/null and b/apps/desktop/build/icon-dev.png differ diff --git a/apps/desktop/build/icon-nightly.ico b/apps/desktop/build/icon-nightly.ico new file mode 100644 index 0000000000..f3781ae8cd Binary files /dev/null and b/apps/desktop/build/icon-nightly.ico differ diff --git a/apps/desktop/build/icon-nightly.png b/apps/desktop/build/icon-nightly.png new file mode 100644 index 0000000000..9598d40477 Binary files /dev/null and b/apps/desktop/build/icon-nightly.png differ diff --git a/apps/desktop/build/icon.ico b/apps/desktop/build/icon.ico new file mode 100644 index 0000000000..2b86beffbe Binary files /dev/null and b/apps/desktop/build/icon.ico differ diff --git a/apps/desktop/build/icon.png b/apps/desktop/build/icon.png new file mode 100644 index 0000000000..39929f7f83 Binary files /dev/null and b/apps/desktop/build/icon.png differ diff --git a/apps/desktop/dev-app-update.yml b/apps/desktop/dev-app-update.yml new file mode 100644 index 0000000000..3deac76f44 --- /dev/null +++ b/apps/desktop/dev-app-update.yml @@ -0,0 +1,6 @@ +provider: github +owner: lobehub +repo: lobe-chat +updaterCacheDirName: electron-app-updater +allowPrerelease: true +channel: nightly diff --git a/apps/desktop/electron-builder.js b/apps/desktop/electron-builder.js new file mode 100644 index 0000000000..7eba7f315a --- /dev/null +++ b/apps/desktop/electron-builder.js @@ -0,0 +1,92 @@ +const dotenv = require('dotenv'); + +dotenv.config(); + +const packageJSON = require('./package.json'); + +const channel = process.env.UPDATE_CHANNEL || 'stable'; + +console.log(`🚄 Build Version ${packageJSON.version}, Channel: ${channel}`); + +const isNightly = channel === 'nightly'; +/** + * @type {import('electron-builder').Configuration} + * @see https://www.electron.build/configuration + */ +const config = { + appId: isNightly ? 'com.lobehub.lobehub-desktop-nightly' : 'com.lobehub.lobehub-desktop', + appImage: { + artifactName: '${productName}-${version}.${ext}', + }, + asar: true, + detectUpdateChannel: true, + directories: { + buildResources: 'build', + output: 'release', + }, + dmg: { + artifactName: '${productName}-${version}-${arch}.${ext}', + }, + electronDownload: { + mirror: 'https://npmmirror.com/mirrors/electron/', + }, + files: [ + 'dist', + 'resources', + '!resources/locales', + '!dist/next/docs', + '!dist/next/packages', + '!dist/next/.next/server/app/sitemap', + '!dist/next/.next/static/media', + ], + generateUpdatesFilesForAllChannels: true, + linux: { + category: 'Utility', + maintainer: 'electronjs.org', + target: ['AppImage', 'snap', 'deb'], + }, + mac: { + compression: 'maximum', + entitlementsInherit: 'build/entitlements.mac.plist', + extendInfo: [ + { NSCameraUsageDescription: "Application requests access to the device's camera." }, + { NSMicrophoneUsageDescription: "Application requests access to the device's microphone." }, + { + NSDocumentsFolderUsageDescription: + "Application requests access to the user's Documents folder.", + }, + { + NSDownloadsFolderUsageDescription: + "Application requests access to the user's Downloads folder.", + }, + ], + gatekeeperAssess: false, + hardenedRuntime: true, + notarize: true, + target: [ + { arch: ['x64', 'arm64'], target: 'dmg' }, + { arch: ['x64', 'arm64'], target: 'zip' }, + ], + }, + npmRebuild: true, + nsis: { + artifactName: '${productName}-${version}-setup.${ext}', + createDesktopShortcut: 'always', + // allowToChangeInstallationDirectory: true, + // oneClick: false, + shortcutName: '${productName}', + uninstallDisplayName: '${productName}', + }, + publish: [ + { + owner: 'lobehub', + provider: 'github', + repo: 'lobe-chat', + }, + ], + win: { + executableName: 'LobeHub', + }, +}; + +module.exports = config; diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts new file mode 100644 index 0000000000..38f6280e1e --- /dev/null +++ b/apps/desktop/electron.vite.config.ts @@ -0,0 +1,40 @@ +import dotenv from 'dotenv'; +import { defineConfig, externalizeDepsPlugin } from 'electron-vite'; +import { resolve } from 'node:path'; + +dotenv.config(); + +const updateChannel = process.env.UPDATE_CHANNEL || 'stable'; +console.log(`[electron-vite.config.ts] Detected UPDATE_CHANNEL: ${updateChannel}`); // 添加日志确认 + +export default defineConfig({ + main: { + build: { + outDir: 'dist/main', + }, + // 这里是关键:在构建时进行文本替换 + 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), + }, + plugins: [externalizeDepsPlugin({})], + resolve: { + alias: { + '@': resolve(__dirname, 'src/main'), + '~common': resolve(__dirname, 'src/common'), + }, + }, + }, + preload: { + build: { + outDir: 'dist/preload', + }, + plugins: [externalizeDepsPlugin({})], + resolve: { + alias: { + '~common': resolve(__dirname, 'src/common'), + }, + }, + }, +}); diff --git a/apps/desktop/package.json b/apps/desktop/package.json new file mode 100644 index 0000000000..e36913992c --- /dev/null +++ b/apps/desktop/package.json @@ -0,0 +1,72 @@ +{ + "name": "lobehub-desktop-dev", + "version": "0.0.10", + "description": "LobeHub Desktop Application", + "homepage": "https://lobehub.com", + "repository": { + "type": "git", + "url": "https://github.com/lobehub/lobe-chat.git" + }, + "author": "LobeHub", + "main": "./dist/main/index.js", + "scripts": { + "build": "npm run typecheck && electron-vite build", + "build-local": "npm run build && electron-builder --dir --config electron-builder.js --c.mac.notarize=false -c.mac.identity=null --c.asar=false", + "build:linux": "npm run build && electron-builder --linux --config electron-builder.js", + "build:mac": "npm run build && electron-builder --mac --config electron-builder.js", + "build:win": "npm run build && electron-builder --win --config electron-builder.js", + "electron:dev": "electron-vite dev", + "electron:run-unpack": "electron .", + "format": "prettier --write ", + "i18n": "bun run scripts/i18nWorkflow/index.ts && lobe-i18n", + "postinstall": "electron-builder install-app-deps", + "install-isolated": "pnpm install", + "lint": "eslint --cache ", + "pg-server": "bun run scripts/pglite-server.ts", + "start": "electron-vite preview", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "electron-updater": "^6.6.2", + "get-port-please": "^3.1.2", + "pdfjs-dist": "4.8.69" + }, + "devDependencies": { + "@electron-toolkit/eslint-config-prettier": "^3.0.0", + "@electron-toolkit/eslint-config-ts": "^3.0.0", + "@electron-toolkit/preload": "^3.0.1", + "@electron-toolkit/tsconfig": "^1.0.1", + "@electron-toolkit/utils": "^4.0.0", + "@lobechat/electron-client-ipc": "workspace:*", + "@lobechat/electron-server-ipc": "workspace:*", + "@lobechat/file-loaders": "workspace:*", + "@lobehub/i18n-cli": "^1.20.3", + "@types/lodash": "^4.17.0", + "@types/resolve": "^1.20.6", + "@types/semver": "^7.7.0", + "@types/set-cookie-parser": "^2.4.10", + "consola": "^3.1.0", + "cookie": "^1.0.2", + "electron": "^35.2.0", + "electron-builder": "^26.0.12", + "electron-is": "^3.0.0", + "electron-log": "^5.3.3", + "electron-store": "^8.2.0", + "electron-vite": "^3.0.0", + "execa": "^9.5.2", + "just-diff": "^6.0.2", + "lodash": "^4.17.21", + "pglite-server": "^0.1.4", + "resolve": "^1.22.8", + "semver": "^7.5.4", + "set-cookie-parser": "^2.7.1", + "tsx": "^4.19.3", + "typescript": "^5.7.3", + "vite": "^6.2.5" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "electron" + ] + } +} diff --git a/apps/desktop/pnpm-workspace.yaml b/apps/desktop/pnpm-workspace.yaml new file mode 100644 index 0000000000..f9232e0468 --- /dev/null +++ b/apps/desktop/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +packages: + - '../../packages/electron-server-ipc' + - '../../packages/electron-client-ipc' + - '../../packages/file-loaders' + - '.' diff --git a/apps/desktop/resources/error.html b/apps/desktop/resources/error.html new file mode 100644 index 0000000000..bcfb743185 --- /dev/null +++ b/apps/desktop/resources/error.html @@ -0,0 +1,136 @@ + + + + + + LobeHub - 连接错误 + + + +
+
⚠️
+

Connection Error

+

+ Unable to connect to the application, please check your network connection or confirm if the + development server is running. +

+ + +
+ + + + diff --git a/apps/desktop/resources/locales/ar/common.json b/apps/desktop/resources/locales/ar/common.json new file mode 100644 index 0000000000..381b08455d --- /dev/null +++ b/apps/desktop/resources/locales/ar/common.json @@ -0,0 +1,32 @@ +{ + "actions": { + "add": "إضافة", + "back": "عودة", + "cancel": "إلغاء", + "close": "إغلاق", + "confirm": "تأكيد", + "delete": "حذف", + "edit": "تعديل", + "more": "المزيد", + "next": "التالي", + "ok": "حسناً", + "previous": "السابق", + "refresh": "تحديث", + "remove": "إزالة", + "retry": "إعادة المحاولة", + "save": "حفظ", + "search": "بحث", + "submit": "إرسال" + }, + "app": { + "description": "منصة تعاون مساعدك الذكي", + "name": "LobeHub" + }, + "status": { + "error": "خطأ", + "info": "معلومات", + "loading": "جارٍ التحميل", + "success": "نجاح", + "warning": "تحذير" + } +} diff --git a/apps/desktop/resources/locales/ar/dialog.json b/apps/desktop/resources/locales/ar/dialog.json new file mode 100644 index 0000000000..23d7b55f20 --- /dev/null +++ b/apps/desktop/resources/locales/ar/dialog.json @@ -0,0 +1,31 @@ +{ + "about": { + "button": "تأكيد", + "detail": "تطبيق دردشة يعتمد على نموذج لغة كبير", + "message": "{{appName}} {{appVersion}}", + "title": "حول" + }, + "confirm": { + "cancel": "إلغاء", + "no": "لا", + "title": "تأكيد", + "yes": "نعم" + }, + "error": { + "button": "تأكيد", + "detail": "حدث خطأ أثناء العملية، يرجى المحاولة لاحقًا", + "message": "حدث خطأ", + "title": "خطأ" + }, + "update": { + "downloadAndInstall": "تنزيل وتثبيت", + "downloadComplete": "اكتمل التنزيل", + "downloadCompleteMessage": "تم تنزيل حزمة التحديث، هل ترغب في التثبيت الآن؟", + "installLater": "تثبيت لاحقًا", + "installNow": "تثبيت الآن", + "later": "تذكير لاحقًا", + "newVersion": "تم اكتشاف إصدار جديد", + "newVersionAvailable": "تم اكتشاف إصدار جديد: {{version}}", + "skipThisVersion": "تخطي هذا الإصدار" + } +} diff --git a/apps/desktop/resources/locales/ar/menu.json b/apps/desktop/resources/locales/ar/menu.json new file mode 100644 index 0000000000..5468302809 --- /dev/null +++ b/apps/desktop/resources/locales/ar/menu.json @@ -0,0 +1,70 @@ +{ + "common": { + "checkUpdates": "التحقق من التحديثات..." + }, + "dev": { + "devPanel": "لوحة المطور", + "devTools": "أدوات المطور", + "forceReload": "إعادة تحميل قسري", + "openStore": "فتح ملف التخزين", + "refreshMenu": "تحديث القائمة", + "reload": "إعادة تحميل", + "title": "تطوير" + }, + "edit": { + "copy": "نسخ", + "cut": "قص", + "paste": "لصق", + "redo": "إعادة", + "selectAll": "تحديد الكل", + "speech": "صوت", + "startSpeaking": "بدء القراءة", + "stopSpeaking": "إيقاف القراءة", + "title": "تحرير", + "undo": "تراجع" + }, + "file": { + "preferences": "التفضيلات", + "quit": "خروج", + "title": "ملف" + }, + "help": { + "about": "حول", + "githubRepo": "مستودع GitHub", + "reportIssue": "الإبلاغ عن مشكلة", + "title": "مساعدة", + "visitWebsite": "زيارة الموقع الرسمي" + }, + "macOS": { + "about": "حول {{appName}}", + "devTools": "أدوات مطور LobeHub", + "hide": "إخفاء {{appName}}", + "hideOthers": "إخفاء الآخرين", + "preferences": "إعدادات مفضلة...", + "services": "خدمات", + "unhide": "إظهار الكل" + }, + "tray": { + "open": "فتح {{appName}}", + "quit": "خروج", + "show": "عرض {{appName}}" + }, + "view": { + "forceReload": "إعادة تحميل قسري", + "reload": "إعادة تحميل", + "resetZoom": "إعادة تعيين التكبير", + "title": "عرض", + "toggleFullscreen": "تبديل وضع ملء الشاشة", + "zoomIn": "تكبير", + "zoomOut": "تصغير" + }, + "window": { + "bringAllToFront": "إحضار جميع النوافذ إلى الأمام", + "close": "إغلاق", + "front": "إحضار جميع النوافذ إلى الأمام", + "minimize": "تصغير", + "title": "نافذة", + "toggleFullscreen": "تبديل وضع ملء الشاشة", + "zoom": "تكبير" + } +} diff --git a/apps/desktop/resources/locales/bg-BG/common.json b/apps/desktop/resources/locales/bg-BG/common.json new file mode 100644 index 0000000000..928d158f41 --- /dev/null +++ b/apps/desktop/resources/locales/bg-BG/common.json @@ -0,0 +1,32 @@ +{ + "actions": { + "add": "Добави", + "back": "Назад", + "cancel": "Отмени", + "close": "Затвори", + "confirm": "Потвърди", + "delete": "Изтрий", + "edit": "Редактирай", + "more": "Повече", + "next": "Следващ", + "ok": "Добре", + "previous": "Предишен", + "refresh": "Освежи", + "remove": "Премахни", + "retry": "Опитай отново", + "save": "Запази", + "search": "Търси", + "submit": "Изпрати" + }, + "app": { + "description": "Твоята платформа за сътрудничество с AI асистент", + "name": "LobeHub" + }, + "status": { + "error": "Грешка", + "info": "Информация", + "loading": "Зареждане", + "success": "Успех", + "warning": "Предупреждение" + } +} diff --git a/apps/desktop/resources/locales/bg-BG/dialog.json b/apps/desktop/resources/locales/bg-BG/dialog.json new file mode 100644 index 0000000000..4713a27faa --- /dev/null +++ b/apps/desktop/resources/locales/bg-BG/dialog.json @@ -0,0 +1,31 @@ +{ + "about": { + "button": "Потвърди", + "detail": "Приложение за чат, базирано на голям езиков модел", + "message": "{{appName}} {{appVersion}}", + "title": "За нас" + }, + "confirm": { + "cancel": "Отказ", + "no": "Не", + "title": "Потвърждение", + "yes": "Да" + }, + "error": { + "button": "Потвърди", + "detail": "Възникна грешка по време на операцията, моля опитайте отново по-късно", + "message": "Възникна грешка", + "title": "Грешка" + }, + "update": { + "downloadAndInstall": "Изтегли и инсталирай", + "downloadComplete": "Изтеглянето е завършено", + "downloadCompleteMessage": "Актуализационният пакет е изтеглен, желаете ли да го инсталирате веднага?", + "installLater": "Инсталирай по-късно", + "installNow": "Инсталирай сега", + "later": "Напомни по-късно", + "newVersion": "Открита нова версия", + "newVersionAvailable": "Открита нова версия: {{version}}", + "skipThisVersion": "Пропусни тази версия" + } +} diff --git a/apps/desktop/resources/locales/bg-BG/menu.json b/apps/desktop/resources/locales/bg-BG/menu.json new file mode 100644 index 0000000000..5b1b0cffae --- /dev/null +++ b/apps/desktop/resources/locales/bg-BG/menu.json @@ -0,0 +1,70 @@ +{ + "common": { + "checkUpdates": "Проверка за актуализации..." + }, + "dev": { + "devPanel": "Панел на разработчика", + "devTools": "Инструменти за разработчици", + "forceReload": "Принудително презареждане", + "openStore": "Отворете файла за съхранение", + "refreshMenu": "Освежаване на менюто", + "reload": "Презареждане", + "title": "Разработка" + }, + "edit": { + "copy": "Копиране", + "cut": "Изрязване", + "paste": "Поставяне", + "redo": "Повторно", + "selectAll": "Избери всичко", + "speech": "Глас", + "startSpeaking": "Започни четене", + "stopSpeaking": "Спри четенето", + "title": "Редактиране", + "undo": "Отмяна" + }, + "file": { + "preferences": "Предпочитания", + "quit": "Изход", + "title": "Файл" + }, + "help": { + "about": "За", + "githubRepo": "GitHub хранилище", + "reportIssue": "Докладвай проблем", + "title": "Помощ", + "visitWebsite": "Посети уебсайта" + }, + "macOS": { + "about": "За {{appName}}", + "devTools": "Инструменти за разработчици на LobeHub", + "hide": "Скрий {{appName}}", + "hideOthers": "Скрий другите", + "preferences": "Настройки...", + "services": "Услуги", + "unhide": "Покажи всичко" + }, + "tray": { + "open": "Отвори {{appName}}", + "quit": "Изход", + "show": "Покажи {{appName}}" + }, + "view": { + "forceReload": "Принудително презареждане", + "reload": "Презареждане", + "resetZoom": "Нулиране на мащаба", + "title": "Изглед", + "toggleFullscreen": "Превключи на цял екран", + "zoomIn": "Увеличи", + "zoomOut": "Намали" + }, + "window": { + "bringAllToFront": "Премести всички прозорци напред", + "close": "Затвори", + "front": "Премести всички прозорци напред", + "minimize": "Минимизирай", + "title": "Прозорец", + "toggleFullscreen": "Превключи на цял екран", + "zoom": "Мащаб" + } +} diff --git a/apps/desktop/resources/locales/de-DE/common.json b/apps/desktop/resources/locales/de-DE/common.json new file mode 100644 index 0000000000..d59358ed72 --- /dev/null +++ b/apps/desktop/resources/locales/de-DE/common.json @@ -0,0 +1,32 @@ +{ + "actions": { + "add": "Hinzufügen", + "back": "Zurück", + "cancel": "Abbrechen", + "close": "Schließen", + "confirm": "Bestätigen", + "delete": "Löschen", + "edit": "Bearbeiten", + "more": "Mehr", + "next": "Weiter", + "ok": "OK", + "previous": "Zurück", + "refresh": "Aktualisieren", + "remove": "Entfernen", + "retry": "Erneut versuchen", + "save": "Speichern", + "search": "Suchen", + "submit": "Einreichen" + }, + "app": { + "description": "Ihre KI-Assistenten-Kollaborationsplattform", + "name": "LobeHub" + }, + "status": { + "error": "Fehler", + "info": "Information", + "loading": "Lädt", + "success": "Erfolg", + "warning": "Warnung" + } +} diff --git a/apps/desktop/resources/locales/de-DE/dialog.json b/apps/desktop/resources/locales/de-DE/dialog.json new file mode 100644 index 0000000000..1690bf7718 --- /dev/null +++ b/apps/desktop/resources/locales/de-DE/dialog.json @@ -0,0 +1,31 @@ +{ + "about": { + "button": "Bestätigen", + "detail": "Eine Chat-Anwendung, die auf einem großen Sprachmodell basiert", + "message": "{{appName}} {{appVersion}}", + "title": "Über" + }, + "confirm": { + "cancel": "Abbrechen", + "no": "Nein", + "title": "Bestätigung", + "yes": "Ja" + }, + "error": { + "button": "Bestätigen", + "detail": "Während der Operation ist ein Fehler aufgetreten, bitte versuchen Sie es später erneut", + "message": "Ein Fehler ist aufgetreten", + "title": "Fehler" + }, + "update": { + "downloadAndInstall": "Herunterladen und installieren", + "downloadComplete": "Download abgeschlossen", + "downloadCompleteMessage": "Das Update-Paket wurde heruntergeladen, möchten Sie es jetzt installieren?", + "installLater": "Später installieren", + "installNow": "Jetzt installieren", + "later": "Später erinnern", + "newVersion": "Neue Version gefunden", + "newVersionAvailable": "Neue Version verfügbar: {{version}}", + "skipThisVersion": "Diese Version überspringen" + } +} diff --git a/apps/desktop/resources/locales/de-DE/menu.json b/apps/desktop/resources/locales/de-DE/menu.json new file mode 100644 index 0000000000..ba915c733b --- /dev/null +++ b/apps/desktop/resources/locales/de-DE/menu.json @@ -0,0 +1,70 @@ +{ + "common": { + "checkUpdates": "Überprüfen Sie auf Updates..." + }, + "dev": { + "devPanel": "Entwicklerpanel", + "devTools": "Entwicklerwerkzeuge", + "forceReload": "Erzwinge Neuladen", + "openStore": "Speicherdatei öffnen", + "refreshMenu": "Menü aktualisieren", + "reload": "Neuladen", + "title": "Entwicklung" + }, + "edit": { + "copy": "Kopieren", + "cut": "Ausschneiden", + "paste": "Einfügen", + "redo": "Wiederherstellen", + "selectAll": "Alles auswählen", + "speech": "Sprache", + "startSpeaking": "Beginne zu sprechen", + "stopSpeaking": "Stoppe das Sprechen", + "title": "Bearbeiten", + "undo": "Rückgängig" + }, + "file": { + "preferences": "Einstellungen", + "quit": "Beenden", + "title": "Datei" + }, + "help": { + "about": "Über", + "githubRepo": "GitHub-Repository", + "reportIssue": "Problem melden", + "title": "Hilfe", + "visitWebsite": "Besuche die Website" + }, + "macOS": { + "about": "Über {{appName}}", + "devTools": "LobeHub Entwicklerwerkzeuge", + "hide": "{{appName}} ausblenden", + "hideOthers": "Andere ausblenden", + "preferences": "Einstellungen...", + "services": "Dienste", + "unhide": "Alle anzeigen" + }, + "tray": { + "open": "{{appName}} öffnen", + "quit": "Beenden", + "show": "{{appName}} anzeigen" + }, + "view": { + "forceReload": "Erzwinge Neuladen", + "reload": "Neuladen", + "resetZoom": "Zoom zurücksetzen", + "title": "Ansicht", + "toggleFullscreen": "Vollbild umschalten", + "zoomIn": "Vergrößern", + "zoomOut": "Verkleinern" + }, + "window": { + "bringAllToFront": "Alle Fenster in den Vordergrund bringen", + "close": "Schließen", + "front": "Alle Fenster in den Vordergrund bringen", + "minimize": "Minimieren", + "title": "Fenster", + "toggleFullscreen": "Vollbild umschalten", + "zoom": "Zoom" + } +} diff --git a/apps/desktop/resources/locales/en-US/common.json b/apps/desktop/resources/locales/en-US/common.json new file mode 100644 index 0000000000..631f337686 --- /dev/null +++ b/apps/desktop/resources/locales/en-US/common.json @@ -0,0 +1,32 @@ +{ + "actions": { + "add": "Add", + "back": "Back", + "cancel": "Cancel", + "close": "Close", + "confirm": "Confirm", + "delete": "Delete", + "edit": "Edit", + "more": "More", + "next": "Next", + "ok": "OK", + "previous": "Previous", + "refresh": "Refresh", + "remove": "Remove", + "retry": "Retry", + "save": "Save", + "search": "Search", + "submit": "Submit" + }, + "app": { + "description": "Your AI Assistant Collaboration Platform", + "name": "LobeHub" + }, + "status": { + "error": "Error", + "info": "Information", + "loading": "Loading", + "success": "Success", + "warning": "Warning" + } +} diff --git a/apps/desktop/resources/locales/en-US/dialog.json b/apps/desktop/resources/locales/en-US/dialog.json new file mode 100644 index 0000000000..3a30242932 --- /dev/null +++ b/apps/desktop/resources/locales/en-US/dialog.json @@ -0,0 +1,31 @@ +{ + "about": { + "button": "OK", + "detail": "A chat application based on a large language model", + "message": "{{appName}} {{appVersion}}", + "title": "About" + }, + "confirm": { + "cancel": "Cancel", + "no": "No", + "title": "Confirm", + "yes": "Yes" + }, + "error": { + "button": "OK", + "detail": "An error occurred during the operation, please try again later", + "message": "An error occurred", + "title": "Error" + }, + "update": { + "downloadAndInstall": "Download and Install", + "downloadComplete": "Download Complete", + "downloadCompleteMessage": "The update package has been downloaded, would you like to install it now?", + "installLater": "Install Later", + "installNow": "Install Now", + "later": "Remind Me Later", + "newVersion": "New Version Found", + "newVersionAvailable": "New version available: {{version}}", + "skipThisVersion": "Skip This Version" + } +} diff --git a/apps/desktop/resources/locales/en-US/menu.json b/apps/desktop/resources/locales/en-US/menu.json new file mode 100644 index 0000000000..53aaffc17b --- /dev/null +++ b/apps/desktop/resources/locales/en-US/menu.json @@ -0,0 +1,70 @@ +{ + "common": { + "checkUpdates": "Checking for updates..." + }, + "dev": { + "devPanel": "Developer Panel", + "devTools": "Developer Tools", + "forceReload": "Force Reload", + "openStore": "Open Storage File", + "refreshMenu": "Refresh menu", + "reload": "Reload", + "title": "Development" + }, + "edit": { + "copy": "Copy", + "cut": "Cut", + "paste": "Paste", + "redo": "Redo", + "selectAll": "Select All", + "speech": "Speech", + "startSpeaking": "Start Speaking", + "stopSpeaking": "Stop Speaking", + "title": "Edit", + "undo": "Undo" + }, + "file": { + "preferences": "Preferences", + "quit": "Quit", + "title": "File" + }, + "help": { + "about": "About", + "githubRepo": "GitHub Repository", + "reportIssue": "Report Issue", + "title": "Help", + "visitWebsite": "Visit Website" + }, + "macOS": { + "about": "About {{appName}}", + "devTools": "LobeHub Developer Tools", + "hide": "Hide {{appName}}", + "hideOthers": "Hide Others", + "preferences": "Preferences...", + "services": "Services", + "unhide": "Show All" + }, + "tray": { + "open": "Open {{appName}}", + "quit": "Quit", + "show": "Show {{appName}}" + }, + "view": { + "forceReload": "Force Reload", + "reload": "Reload", + "resetZoom": "Reset Zoom", + "title": "View", + "toggleFullscreen": "Toggle Fullscreen", + "zoomIn": "Zoom In", + "zoomOut": "Zoom Out" + }, + "window": { + "bringAllToFront": "Bring All Windows to Front", + "close": "Close", + "front": "Bring All Windows to Front", + "minimize": "Minimize", + "title": "Window", + "toggleFullscreen": "Toggle Fullscreen", + "zoom": "Zoom" + } +} diff --git a/apps/desktop/resources/locales/es-ES/common.json b/apps/desktop/resources/locales/es-ES/common.json new file mode 100644 index 0000000000..490d4e40e5 --- /dev/null +++ b/apps/desktop/resources/locales/es-ES/common.json @@ -0,0 +1,32 @@ +{ + "actions": { + "add": "Agregar", + "back": "Volver", + "cancel": "Cancelar", + "close": "Cerrar", + "confirm": "Confirmar", + "delete": "Eliminar", + "edit": "Editar", + "more": "Más", + "next": "Siguiente", + "ok": "Aceptar", + "previous": "Anterior", + "refresh": "Actualizar", + "remove": "Eliminar", + "retry": "Reintentar", + "save": "Guardar", + "search": "Buscar", + "submit": "Enviar" + }, + "app": { + "description": "Tu plataforma de colaboración con el asistente de IA", + "name": "LobeHub" + }, + "status": { + "error": "Error", + "info": "Información", + "loading": "Cargando", + "success": "Éxito", + "warning": "Advertencia" + } +} diff --git a/apps/desktop/resources/locales/es-ES/dialog.json b/apps/desktop/resources/locales/es-ES/dialog.json new file mode 100644 index 0000000000..1ab6c96549 --- /dev/null +++ b/apps/desktop/resources/locales/es-ES/dialog.json @@ -0,0 +1,31 @@ +{ + "about": { + "button": "Aceptar", + "detail": "Una aplicación de chat basada en un modelo de lenguaje grande", + "message": "{{appName}} {{appVersion}}", + "title": "Acerca de" + }, + "confirm": { + "cancel": "Cancelar", + "no": "No", + "title": "Confirmar", + "yes": "Sí" + }, + "error": { + "button": "Aceptar", + "detail": "Se produjo un error durante la operación, por favor intente de nuevo más tarde", + "message": "Se produjo un error", + "title": "Error" + }, + "update": { + "downloadAndInstall": "Descargar e instalar", + "downloadComplete": "Descarga completada", + "downloadCompleteMessage": "El paquete de actualización se ha descargado, ¿desea instalarlo ahora?", + "installLater": "Instalar más tarde", + "installNow": "Instalar ahora", + "later": "Recordar más tarde", + "newVersion": "Nueva versión disponible", + "newVersionAvailable": "Nueva versión encontrada: {{version}}", + "skipThisVersion": "Saltar esta versión" + } +} diff --git a/apps/desktop/resources/locales/es-ES/menu.json b/apps/desktop/resources/locales/es-ES/menu.json new file mode 100644 index 0000000000..9c56487421 --- /dev/null +++ b/apps/desktop/resources/locales/es-ES/menu.json @@ -0,0 +1,70 @@ +{ + "common": { + "checkUpdates": "Comprobando actualizaciones..." + }, + "dev": { + "devPanel": "Panel de desarrollador", + "devTools": "Herramientas de desarrollador", + "forceReload": "Recargar forzosamente", + "openStore": "Abrir archivo de almacenamiento", + "refreshMenu": "Actualizar menú", + "reload": "Recargar", + "title": "Desarrollo" + }, + "edit": { + "copy": "Copiar", + "cut": "Cortar", + "paste": "Pegar", + "redo": "Rehacer", + "selectAll": "Seleccionar todo", + "speech": "Voz", + "startSpeaking": "Comenzar a leer en voz alta", + "stopSpeaking": "Detener lectura en voz alta", + "title": "Editar", + "undo": "Deshacer" + }, + "file": { + "preferences": "Preferencias", + "quit": "Salir", + "title": "Archivo" + }, + "help": { + "about": "Acerca de", + "githubRepo": "Repositorio de GitHub", + "reportIssue": "Reportar un problema", + "title": "Ayuda", + "visitWebsite": "Visitar el sitio web" + }, + "macOS": { + "about": "Acerca de {{appName}}", + "devTools": "Herramientas de desarrollador de LobeHub", + "hide": "Ocultar {{appName}}", + "hideOthers": "Ocultar otros", + "preferences": "Configuración...", + "services": "Servicios", + "unhide": "Mostrar todo" + }, + "tray": { + "open": "Abrir {{appName}}", + "quit": "Salir", + "show": "Mostrar {{appName}}" + }, + "view": { + "forceReload": "Recargar forzosamente", + "reload": "Recargar", + "resetZoom": "Restablecer zoom", + "title": "Vista", + "toggleFullscreen": "Alternar pantalla completa", + "zoomIn": "Acercar", + "zoomOut": "Alejar" + }, + "window": { + "bringAllToFront": "Traer todas las ventanas al frente", + "close": "Cerrar", + "front": "Traer todas las ventanas al frente", + "minimize": "Minimizar", + "title": "Ventana", + "toggleFullscreen": "Alternar pantalla completa", + "zoom": "Zoom" + } +} diff --git a/apps/desktop/resources/locales/fa-IR/common.json b/apps/desktop/resources/locales/fa-IR/common.json new file mode 100644 index 0000000000..34b0b6a7e1 --- /dev/null +++ b/apps/desktop/resources/locales/fa-IR/common.json @@ -0,0 +1,32 @@ +{ + "actions": { + "add": "افزودن", + "back": "بازگشت", + "cancel": "لغو", + "close": "بستن", + "confirm": "تأیید", + "delete": "حذف", + "edit": "ویرایش", + "more": "بیشتر", + "next": "مرحله بعد", + "ok": "تأیید", + "previous": "مرحله قبل", + "refresh": "به‌روزرسانی", + "remove": "حذف", + "retry": "تلاش مجدد", + "save": "ذخیره", + "search": "جستجو", + "submit": "ارسال" + }, + "app": { + "description": "پلتفرم همکاری دستیار هوش مصنوعی شما", + "name": "LobeHub" + }, + "status": { + "error": "خطا", + "info": "اطلاعات", + "loading": "در حال بارگذاری", + "success": "موفق", + "warning": "هشدار" + } +} diff --git a/apps/desktop/resources/locales/fa-IR/dialog.json b/apps/desktop/resources/locales/fa-IR/dialog.json new file mode 100644 index 0000000000..5c902de48b --- /dev/null +++ b/apps/desktop/resources/locales/fa-IR/dialog.json @@ -0,0 +1,31 @@ +{ + "about": { + "button": "تأیید", + "detail": "یک برنامه چت مبتنی بر مدل‌های زبانی بزرگ", + "message": "{{appName}} {{appVersion}}", + "title": "درباره" + }, + "confirm": { + "cancel": "لغو", + "no": "خیر", + "title": "تأیید", + "yes": "بله" + }, + "error": { + "button": "تأیید", + "detail": "در حین انجام عملیات خطایی رخ داده است، لطفاً بعداً دوباره تلاش کنید", + "message": "خطا رخ داده است", + "title": "خطا" + }, + "update": { + "downloadAndInstall": "دانلود و نصب", + "downloadComplete": "دانلود کامل شد", + "downloadCompleteMessage": "بسته به‌روزرسانی دانلود شده است، آیا می‌خواهید بلافاصله نصب کنید؟", + "installLater": "نصب بعداً", + "installNow": "نصب اکنون", + "later": "یادآوری بعداً", + "newVersion": "نسخه جدیدی پیدا شد", + "newVersionAvailable": "نسخه جدید پیدا شد: {{version}}", + "skipThisVersion": "این نسخه را نادیده بگیرید" + } +} diff --git a/apps/desktop/resources/locales/fa-IR/menu.json b/apps/desktop/resources/locales/fa-IR/menu.json new file mode 100644 index 0000000000..94f469a0b4 --- /dev/null +++ b/apps/desktop/resources/locales/fa-IR/menu.json @@ -0,0 +1,70 @@ +{ + "common": { + "checkUpdates": "بررسی به‌روزرسانی..." + }, + "dev": { + "devPanel": "پنل توسعه‌دهنده", + "devTools": "ابزارهای توسعه‌دهنده", + "forceReload": "بارگذاری اجباری", + "openStore": "باز کردن فایل‌های ذخیره شده", + "refreshMenu": "به‌روزرسانی منو", + "reload": "بارگذاری مجدد", + "title": "توسعه" + }, + "edit": { + "copy": "کپی", + "cut": "برش", + "paste": "چسباندن", + "redo": "انجام مجدد", + "selectAll": "انتخاب همه", + "speech": "گفتار", + "startSpeaking": "شروع به خواندن", + "stopSpeaking": "متوقف کردن خواندن", + "title": "ویرایش", + "undo": "بازگشت" + }, + "file": { + "preferences": "تنظیمات", + "quit": "خروج", + "title": "فایل" + }, + "help": { + "about": "درباره", + "githubRepo": "مخزن GitHub", + "reportIssue": "گزارش مشکل", + "title": "کمک", + "visitWebsite": "بازدید از وب‌سایت" + }, + "macOS": { + "about": "درباره {{appName}}", + "devTools": "ابزارهای توسعه‌دهنده LobeHub", + "hide": "پنهان کردن {{appName}}", + "hideOthers": "پنهان کردن دیگران", + "preferences": "تنظیمات...", + "services": "خدمات", + "unhide": "نمایش همه" + }, + "tray": { + "open": "باز کردن {{appName}}", + "quit": "خروج", + "show": "نمایش {{appName}}" + }, + "view": { + "forceReload": "بارگذاری اجباری", + "reload": "بارگذاری مجدد", + "resetZoom": "تنظیم زوم به حالت اولیه", + "title": "نمایش", + "toggleFullscreen": "تغییر به حالت تمام صفحه", + "zoomIn": "بزرگ‌نمایی", + "zoomOut": "کوچک‌نمایی" + }, + "window": { + "bringAllToFront": "همه پنجره‌ها را به جلو بیاورید", + "close": "بستن", + "front": "همه پنجره‌ها را به جلو بیاورید", + "minimize": "کوچک کردن", + "title": "پنجره", + "toggleFullscreen": "تغییر به حالت تمام صفحه", + "zoom": "زوم" + } +} diff --git a/apps/desktop/resources/locales/fr-FR/common.json b/apps/desktop/resources/locales/fr-FR/common.json new file mode 100644 index 0000000000..aff184ddd0 --- /dev/null +++ b/apps/desktop/resources/locales/fr-FR/common.json @@ -0,0 +1,32 @@ +{ + "actions": { + "add": "Ajouter", + "back": "Retour", + "cancel": "Annuler", + "close": "Fermer", + "confirm": "Confirmer", + "delete": "Supprimer", + "edit": "Éditer", + "more": "Plus", + "next": "Suivant", + "ok": "D'accord", + "previous": "Précédent", + "refresh": "Rafraîchir", + "remove": "Retirer", + "retry": "Réessayer", + "save": "Enregistrer", + "search": "Rechercher", + "submit": "Soumettre" + }, + "app": { + "description": "Votre plateforme de collaboration avec l'assistant IA", + "name": "LobeHub" + }, + "status": { + "error": "Erreur", + "info": "Information", + "loading": "Chargement", + "success": "Succès", + "warning": "Avertissement" + } +} diff --git a/apps/desktop/resources/locales/fr-FR/dialog.json b/apps/desktop/resources/locales/fr-FR/dialog.json new file mode 100644 index 0000000000..972a980a14 --- /dev/null +++ b/apps/desktop/resources/locales/fr-FR/dialog.json @@ -0,0 +1,31 @@ +{ + "about": { + "button": "D'accord", + "detail": "Une application de chat basée sur un grand modèle de langage", + "message": "{{appName}} {{appVersion}}", + "title": "À propos" + }, + "confirm": { + "cancel": "Annuler", + "no": "Non", + "title": "Confirmer", + "yes": "Oui" + }, + "error": { + "button": "D'accord", + "detail": "Une erreur s'est produite lors de l'opération, veuillez réessayer plus tard", + "message": "Une erreur s'est produite", + "title": "Erreur" + }, + "update": { + "downloadAndInstall": "Télécharger et installer", + "downloadComplete": "Téléchargement terminé", + "downloadCompleteMessage": "Le paquet de mise à jour a été téléchargé, souhaitez-vous l'installer maintenant ?", + "installLater": "Installer plus tard", + "installNow": "Installer maintenant", + "later": "Rappeler plus tard", + "newVersion": "Nouvelle version détectée", + "newVersionAvailable": "Nouvelle version disponible : {{version}}", + "skipThisVersion": "Ignorer cette version" + } +} diff --git a/apps/desktop/resources/locales/fr-FR/menu.json b/apps/desktop/resources/locales/fr-FR/menu.json new file mode 100644 index 0000000000..1bf3ff34f5 --- /dev/null +++ b/apps/desktop/resources/locales/fr-FR/menu.json @@ -0,0 +1,70 @@ +{ + "common": { + "checkUpdates": "Vérifier les mises à jour..." + }, + "dev": { + "devPanel": "Panneau de développement", + "devTools": "Outils de développement", + "forceReload": "Recharger de force", + "openStore": "Ouvrir le fichier de stockage", + "refreshMenu": "Rafraîchir le menu", + "reload": "Recharger", + "title": "Développement" + }, + "edit": { + "copy": "Copier", + "cut": "Couper", + "paste": "Coller", + "redo": "Rétablir", + "selectAll": "Tout sélectionner", + "speech": "Voix", + "startSpeaking": "Commencer à lire", + "stopSpeaking": "Arrêter de lire", + "title": "Édition", + "undo": "Annuler" + }, + "file": { + "preferences": "Préférences", + "quit": "Quitter", + "title": "Fichier" + }, + "help": { + "about": "À propos", + "githubRepo": "Dépôt GitHub", + "reportIssue": "Signaler un problème", + "title": "Aide", + "visitWebsite": "Visiter le site officiel" + }, + "macOS": { + "about": "À propos de {{appName}}", + "devTools": "Outils de développement LobeHub", + "hide": "Masquer {{appName}}", + "hideOthers": "Masquer les autres", + "preferences": "Préférences...", + "services": "Services", + "unhide": "Tout afficher" + }, + "tray": { + "open": "Ouvrir {{appName}}", + "quit": "Quitter", + "show": "Afficher {{appName}}" + }, + "view": { + "forceReload": "Recharger de force", + "reload": "Recharger", + "resetZoom": "Réinitialiser le zoom", + "title": "Affichage", + "toggleFullscreen": "Basculer en plein écran", + "zoomIn": "Zoomer", + "zoomOut": "Dézoomer" + }, + "window": { + "bringAllToFront": "Mettre toutes les fenêtres au premier plan", + "close": "Fermer", + "front": "Mettre toutes les fenêtres au premier plan", + "minimize": "Réduire", + "title": "Fenêtre", + "toggleFullscreen": "Basculer en plein écran", + "zoom": "Zoom" + } +} diff --git a/apps/desktop/resources/locales/it-IT/common.json b/apps/desktop/resources/locales/it-IT/common.json new file mode 100644 index 0000000000..749770cc8d --- /dev/null +++ b/apps/desktop/resources/locales/it-IT/common.json @@ -0,0 +1,32 @@ +{ + "actions": { + "add": "Aggiungi", + "back": "Indietro", + "cancel": "Annulla", + "close": "Chiudi", + "confirm": "Conferma", + "delete": "Elimina", + "edit": "Modifica", + "more": "Di più", + "next": "Avanti", + "ok": "OK", + "previous": "Indietro", + "refresh": "Aggiorna", + "remove": "Rimuovi", + "retry": "Riprova", + "save": "Salva", + "search": "Cerca", + "submit": "Invia" + }, + "app": { + "description": "La tua piattaforma di collaborazione con assistente AI", + "name": "LobeHub" + }, + "status": { + "error": "Errore", + "info": "Informazioni", + "loading": "Caricamento in corso", + "success": "Successo", + "warning": "Avviso" + } +} diff --git a/apps/desktop/resources/locales/it-IT/dialog.json b/apps/desktop/resources/locales/it-IT/dialog.json new file mode 100644 index 0000000000..95dbe05d41 --- /dev/null +++ b/apps/desktop/resources/locales/it-IT/dialog.json @@ -0,0 +1,31 @@ +{ + "about": { + "button": "Conferma", + "detail": "Un'app di chat basata su un grande modello linguistico", + "message": "{{appName}} {{appVersion}}", + "title": "Informazioni" + }, + "confirm": { + "cancel": "Annulla", + "no": "No", + "title": "Conferma", + "yes": "Sì" + }, + "error": { + "button": "Conferma", + "detail": "Si è verificato un errore durante l'operazione, riprovare più tardi", + "message": "Si è verificato un errore", + "title": "Errore" + }, + "update": { + "downloadAndInstall": "Scarica e installa", + "downloadComplete": "Download completato", + "downloadCompleteMessage": "Il pacchetto di aggiornamento è stato scaricato, vuoi installarlo subito?", + "installLater": "Installa più tardi", + "installNow": "Installa ora", + "later": "Promemoria più tardi", + "newVersion": "Nuova versione disponibile", + "newVersionAvailable": "Nuova versione trovata: {{version}}", + "skipThisVersion": "Salta questa versione" + } +} diff --git a/apps/desktop/resources/locales/it-IT/menu.json b/apps/desktop/resources/locales/it-IT/menu.json new file mode 100644 index 0000000000..0953cfd9ee --- /dev/null +++ b/apps/desktop/resources/locales/it-IT/menu.json @@ -0,0 +1,70 @@ +{ + "common": { + "checkUpdates": "Controlla aggiornamenti..." + }, + "dev": { + "devPanel": "Pannello sviluppatore", + "devTools": "Strumenti per sviluppatori", + "forceReload": "Ricarica forzata", + "openStore": "Apri il file di archiviazione", + "refreshMenu": "Aggiorna menu", + "reload": "Ricarica", + "title": "Sviluppo" + }, + "edit": { + "copy": "Copia", + "cut": "Taglia", + "paste": "Incolla", + "redo": "Ripeti", + "selectAll": "Seleziona tutto", + "speech": "Voce", + "startSpeaking": "Inizia a leggere", + "stopSpeaking": "Ferma la lettura", + "title": "Modifica", + "undo": "Annulla" + }, + "file": { + "preferences": "Preferenze", + "quit": "Esci", + "title": "File" + }, + "help": { + "about": "Informazioni", + "githubRepo": "Repository GitHub", + "reportIssue": "Segnala un problema", + "title": "Aiuto", + "visitWebsite": "Visita il sito ufficiale" + }, + "macOS": { + "about": "Informazioni su {{appName}}", + "devTools": "Strumenti per sviluppatori LobeHub", + "hide": "Nascondi {{appName}}", + "hideOthers": "Nascondi altri", + "preferences": "Impostazioni...", + "services": "Servizi", + "unhide": "Mostra tutto" + }, + "tray": { + "open": "Apri {{appName}}", + "quit": "Esci", + "show": "Mostra {{appName}}" + }, + "view": { + "forceReload": "Ricarica forzata", + "reload": "Ricarica", + "resetZoom": "Reimposta zoom", + "title": "Visualizza", + "toggleFullscreen": "Attiva/disattiva schermo intero", + "zoomIn": "Ingrandisci", + "zoomOut": "Riduci" + }, + "window": { + "bringAllToFront": "Porta tutte le finestre in primo piano", + "close": "Chiudi", + "front": "Porta tutte le finestre in primo piano", + "minimize": "Minimizza", + "title": "Finestra", + "toggleFullscreen": "Attiva/disattiva schermo intero", + "zoom": "Zoom" + } +} diff --git a/apps/desktop/resources/locales/ja-JP/common.json b/apps/desktop/resources/locales/ja-JP/common.json new file mode 100644 index 0000000000..26f3071cf5 --- /dev/null +++ b/apps/desktop/resources/locales/ja-JP/common.json @@ -0,0 +1,32 @@ +{ + "actions": { + "add": "追加", + "back": "戻る", + "cancel": "キャンセル", + "close": "閉じる", + "confirm": "確認", + "delete": "削除", + "edit": "編集", + "more": "もっと見る", + "next": "次へ", + "ok": "OK", + "previous": "前へ", + "refresh": "更新", + "remove": "削除", + "retry": "再試行", + "save": "保存", + "search": "検索", + "submit": "送信" + }, + "app": { + "description": "あなたのAIアシスタント協力プラットフォーム", + "name": "LobeHub" + }, + "status": { + "error": "エラー", + "info": "情報", + "loading": "読み込み中", + "success": "成功", + "warning": "警告" + } +} diff --git a/apps/desktop/resources/locales/ja-JP/dialog.json b/apps/desktop/resources/locales/ja-JP/dialog.json new file mode 100644 index 0000000000..46e9942579 --- /dev/null +++ b/apps/desktop/resources/locales/ja-JP/dialog.json @@ -0,0 +1,31 @@ +{ + "about": { + "button": "確定", + "detail": "大規模言語モデルに基づくチャットアプリ", + "message": "{{appName}} {{appVersion}}", + "title": "について" + }, + "confirm": { + "cancel": "キャンセル", + "no": "いいえ", + "title": "確認", + "yes": "はい" + }, + "error": { + "button": "確定", + "detail": "操作中にエラーが発生しました。後で再試行してください。", + "message": "エラーが発生しました", + "title": "エラー" + }, + "update": { + "downloadAndInstall": "ダウンロードしてインストール", + "downloadComplete": "ダウンロード完了", + "downloadCompleteMessage": "更新パッケージのダウンロードが完了しました。今すぐインストールしますか?", + "installLater": "後でインストール", + "installNow": "今すぐインストール", + "later": "後でリマインド", + "newVersion": "新しいバージョンが見つかりました", + "newVersionAvailable": "新しいバージョンが見つかりました: {{version}}", + "skipThisVersion": "このバージョンをスキップ" + } +} diff --git a/apps/desktop/resources/locales/ja-JP/menu.json b/apps/desktop/resources/locales/ja-JP/menu.json new file mode 100644 index 0000000000..718630a28a --- /dev/null +++ b/apps/desktop/resources/locales/ja-JP/menu.json @@ -0,0 +1,70 @@ +{ + "common": { + "checkUpdates": "更新を確認しています..." + }, + "dev": { + "devPanel": "開発者パネル", + "devTools": "開発者ツール", + "forceReload": "強制再読み込み", + "openStore": "ストレージファイルを開く", + "refreshMenu": "メニューを更新", + "reload": "再読み込み", + "title": "開発" + }, + "edit": { + "copy": "コピー", + "cut": "切り取り", + "paste": "貼り付け", + "redo": "やり直し", + "selectAll": "すべて選択", + "speech": "音声", + "startSpeaking": "読み上げ開始", + "stopSpeaking": "読み上げ停止", + "title": "編集", + "undo": "元に戻す" + }, + "file": { + "preferences": "設定", + "quit": "終了", + "title": "ファイル" + }, + "help": { + "about": "について", + "githubRepo": "GitHub リポジトリ", + "reportIssue": "問題を報告", + "title": "ヘルプ", + "visitWebsite": "公式ウェブサイトを訪問" + }, + "macOS": { + "about": "{{appName}} について", + "devTools": "LobeHub 開発者ツール", + "hide": "{{appName}} を隠す", + "hideOthers": "他を隠す", + "preferences": "環境設定...", + "services": "サービス", + "unhide": "すべて表示" + }, + "tray": { + "open": "{{appName}} を開く", + "quit": "終了", + "show": "{{appName}} を表示" + }, + "view": { + "forceReload": "強制再読み込み", + "reload": "再読み込み", + "resetZoom": "ズームをリセット", + "title": "ビュー", + "toggleFullscreen": "フルスクリーン切替", + "zoomIn": "ズームイン", + "zoomOut": "ズームアウト" + }, + "window": { + "bringAllToFront": "すべてのウィンドウを前面に", + "close": "閉じる", + "front": "すべてのウィンドウを前面に", + "minimize": "最小化", + "title": "ウィンドウ", + "toggleFullscreen": "フルスクリーン切替", + "zoom": "ズーム" + } +} diff --git a/apps/desktop/resources/locales/ko-KR/common.json b/apps/desktop/resources/locales/ko-KR/common.json new file mode 100644 index 0000000000..af16ea9866 --- /dev/null +++ b/apps/desktop/resources/locales/ko-KR/common.json @@ -0,0 +1,32 @@ +{ + "actions": { + "add": "추가", + "back": "뒤로", + "cancel": "취소", + "close": "닫기", + "confirm": "확인", + "delete": "삭제", + "edit": "편집", + "more": "더보기", + "next": "다음", + "ok": "확인", + "previous": "이전", + "refresh": "새로 고침", + "remove": "제거", + "retry": "다시 시도", + "save": "저장", + "search": "검색", + "submit": "제출" + }, + "app": { + "description": "당신의 AI 비서 협업 플랫폼", + "name": "LobeHub" + }, + "status": { + "error": "오류", + "info": "정보", + "loading": "로딩 중", + "success": "성공", + "warning": "경고" + } +} diff --git a/apps/desktop/resources/locales/ko-KR/dialog.json b/apps/desktop/resources/locales/ko-KR/dialog.json new file mode 100644 index 0000000000..4a4fff9f76 --- /dev/null +++ b/apps/desktop/resources/locales/ko-KR/dialog.json @@ -0,0 +1,31 @@ +{ + "about": { + "button": "확인", + "detail": "대형 언어 모델 기반의 채팅 애플리케이션", + "message": "{{appName}} {{appVersion}}", + "title": "정보" + }, + "confirm": { + "cancel": "취소", + "no": "아니요", + "title": "확인", + "yes": "예" + }, + "error": { + "button": "확인", + "detail": "작업 중 오류가 발생했습니다. 나중에 다시 시도해 주세요.", + "message": "오류 발생", + "title": "오류" + }, + "update": { + "downloadAndInstall": "다운로드 및 설치", + "downloadComplete": "다운로드 완료", + "downloadCompleteMessage": "업데이트 패키지가 다운로드 완료되었습니다. 지금 설치하시겠습니까?", + "installLater": "나중에 설치", + "installNow": "지금 설치", + "later": "나중에 알림", + "newVersion": "새 버전 발견", + "newVersionAvailable": "새 버전 발견: {{version}}", + "skipThisVersion": "이 버전 건너뛰기" + } +} diff --git a/apps/desktop/resources/locales/ko-KR/menu.json b/apps/desktop/resources/locales/ko-KR/menu.json new file mode 100644 index 0000000000..65ec256c66 --- /dev/null +++ b/apps/desktop/resources/locales/ko-KR/menu.json @@ -0,0 +1,70 @@ +{ + "common": { + "checkUpdates": "업데이트 확인 중..." + }, + "dev": { + "devPanel": "개발자 패널", + "devTools": "개발자 도구", + "forceReload": "강제 새로 고침", + "openStore": "저장 파일 열기", + "refreshMenu": "메뉴 새로 고침", + "reload": "새로 고침", + "title": "개발" + }, + "edit": { + "copy": "복사", + "cut": "잘라내기", + "paste": "붙여넣기", + "redo": "다시 실행", + "selectAll": "모두 선택", + "speech": "음성", + "startSpeaking": "읽기 시작", + "stopSpeaking": "읽기 중지", + "title": "편집", + "undo": "실행 취소" + }, + "file": { + "preferences": "환경 설정", + "quit": "종료", + "title": "파일" + }, + "help": { + "about": "정보", + "githubRepo": "GitHub 저장소", + "reportIssue": "문제 보고", + "title": "도움말", + "visitWebsite": "웹사이트 방문" + }, + "macOS": { + "about": "{{appName}} 정보", + "devTools": "LobeHub 개발자 도구", + "hide": "{{appName}} 숨기기", + "hideOthers": "다른 것 숨기기", + "preferences": "환경 설정...", + "services": "서비스", + "unhide": "모두 표시" + }, + "tray": { + "open": "{{appName}} 열기", + "quit": "종료", + "show": "{{appName}} 표시" + }, + "view": { + "forceReload": "강제 새로 고침", + "reload": "새로 고침", + "resetZoom": "줌 초기화", + "title": "보기", + "toggleFullscreen": "전체 화면 전환", + "zoomIn": "확대", + "zoomOut": "축소" + }, + "window": { + "bringAllToFront": "모든 창 앞으로 가져오기", + "close": "닫기", + "front": "모든 창 앞으로 가져오기", + "minimize": "최소화", + "title": "창", + "toggleFullscreen": "전체 화면 전환", + "zoom": "줌" + } +} diff --git a/apps/desktop/resources/locales/nl-NL/common.json b/apps/desktop/resources/locales/nl-NL/common.json new file mode 100644 index 0000000000..6a2f0aa630 --- /dev/null +++ b/apps/desktop/resources/locales/nl-NL/common.json @@ -0,0 +1,32 @@ +{ + "actions": { + "add": "Toevoegen", + "back": "Terug", + "cancel": "Annuleren", + "close": "Sluiten", + "confirm": "Bevestigen", + "delete": "Verwijderen", + "edit": "Bewerken", + "more": "Meer", + "next": "Volgende stap", + "ok": "OK", + "previous": "Vorige stap", + "refresh": "Vernieuwen", + "remove": "Verwijderen", + "retry": "Opnieuw proberen", + "save": "Opslaan", + "search": "Zoeken", + "submit": "Indienen" + }, + "app": { + "description": "Jouw AI-assistent samenwerkingsplatform", + "name": "LobeHub" + }, + "status": { + "error": "Fout", + "info": "Informatie", + "loading": "Laden", + "success": "Succes", + "warning": "Waarschuwing" + } +} diff --git a/apps/desktop/resources/locales/nl-NL/dialog.json b/apps/desktop/resources/locales/nl-NL/dialog.json new file mode 100644 index 0000000000..aeb6042ded --- /dev/null +++ b/apps/desktop/resources/locales/nl-NL/dialog.json @@ -0,0 +1,31 @@ +{ + "about": { + "button": "Bevestigen", + "detail": "Een chatapplicatie gebaseerd op een groot taalmodel", + "message": "{{appName}} {{appVersion}}", + "title": "Over" + }, + "confirm": { + "cancel": "Annuleren", + "no": "Nee", + "title": "Bevestigen", + "yes": "Ja" + }, + "error": { + "button": "Bevestigen", + "detail": "Er is een fout opgetreden tijdens de operatie, probeer het later opnieuw", + "message": "Er is een fout opgetreden", + "title": "Fout" + }, + "update": { + "downloadAndInstall": "Downloaden en installeren", + "downloadComplete": "Download voltooid", + "downloadCompleteMessage": "Het updatepakket is gedownload, wilt u het nu installeren?", + "installLater": "Later installeren", + "installNow": "Nu installeren", + "later": "Later herinneren", + "newVersion": "Nieuwe versie gevonden", + "newVersionAvailable": "Nieuwe versie beschikbaar: {{version}}", + "skipThisVersion": "Deze versie overslaan" + } +} diff --git a/apps/desktop/resources/locales/nl-NL/menu.json b/apps/desktop/resources/locales/nl-NL/menu.json new file mode 100644 index 0000000000..82629c0585 --- /dev/null +++ b/apps/desktop/resources/locales/nl-NL/menu.json @@ -0,0 +1,70 @@ +{ + "common": { + "checkUpdates": "Updates controleren..." + }, + "dev": { + "devPanel": "Ontwikkelaarspaneel", + "devTools": "Ontwikkelaarstools", + "forceReload": "Forceer herladen", + "openStore": "Open opslagbestand", + "refreshMenu": "Menu verversen", + "reload": "Herladen", + "title": "Ontwikkeling" + }, + "edit": { + "copy": "Kopiëren", + "cut": "Knippen", + "paste": "Plakken", + "redo": "Opnieuw doen", + "selectAll": "Alles selecteren", + "speech": "Spraak", + "startSpeaking": "Begin met voorlezen", + "stopSpeaking": "Stop met voorlezen", + "title": "Bewerken", + "undo": "Ongedaan maken" + }, + "file": { + "preferences": "Voorkeuren", + "quit": "Afsluiten", + "title": "Bestand" + }, + "help": { + "about": "Over", + "githubRepo": "GitHub-repo", + "reportIssue": "Probleem melden", + "title": "Hulp", + "visitWebsite": "Bezoek de website" + }, + "macOS": { + "about": "Over {{appName}}", + "devTools": "LobeHub Ontwikkelaarstools", + "hide": "Verberg {{appName}}", + "hideOthers": "Verberg anderen", + "preferences": "Voorkeuren...", + "services": "Diensten", + "unhide": "Toon alles" + }, + "tray": { + "open": "Open {{appName}}", + "quit": "Afsluiten", + "show": "Toon {{appName}}" + }, + "view": { + "forceReload": "Forceer herladen", + "reload": "Herladen", + "resetZoom": "Zoom resetten", + "title": "Weergave", + "toggleFullscreen": "Schakel volledig scherm in/uit", + "zoomIn": "Inzoomen", + "zoomOut": "Uitzoomen" + }, + "window": { + "bringAllToFront": "Breng alle vensters naar voren", + "close": "Sluiten", + "front": "Breng alle vensters naar voren", + "minimize": "Minimaliseren", + "title": "Venster", + "toggleFullscreen": "Schakel volledig scherm in/uit", + "zoom": "Inzoomen" + } +} diff --git a/apps/desktop/resources/locales/pl-PL/common.json b/apps/desktop/resources/locales/pl-PL/common.json new file mode 100644 index 0000000000..e455eebe79 --- /dev/null +++ b/apps/desktop/resources/locales/pl-PL/common.json @@ -0,0 +1,32 @@ +{ + "actions": { + "add": "Dodaj", + "back": "Wstecz", + "cancel": "Anuluj", + "close": "Zamknij", + "confirm": "Potwierdź", + "delete": "Usuń", + "edit": "Edytuj", + "more": "Więcej", + "next": "Dalej", + "ok": "OK", + "previous": "Cofnij", + "refresh": "Odśwież", + "remove": "Usuń", + "retry": "Spróbuj ponownie", + "save": "Zapisz", + "search": "Szukaj", + "submit": "Wyślij" + }, + "app": { + "description": "Twoja platforma współpracy z asystentem AI", + "name": "LobeHub" + }, + "status": { + "error": "Błąd", + "info": "Informacja", + "loading": "Ładowanie", + "success": "Sukces", + "warning": "Ostrzeżenie" + } +} diff --git a/apps/desktop/resources/locales/pl-PL/dialog.json b/apps/desktop/resources/locales/pl-PL/dialog.json new file mode 100644 index 0000000000..e8b3241249 --- /dev/null +++ b/apps/desktop/resources/locales/pl-PL/dialog.json @@ -0,0 +1,31 @@ +{ + "about": { + "button": "OK", + "detail": "Aplikacja czatu oparta na dużym modelu językowym", + "message": "{{appName}} {{appVersion}}", + "title": "O aplikacji" + }, + "confirm": { + "cancel": "Anuluj", + "no": "Nie", + "title": "Potwierdzenie", + "yes": "Tak" + }, + "error": { + "button": "OK", + "detail": "Wystąpił błąd podczas operacji, spróbuj ponownie później", + "message": "Wystąpił błąd", + "title": "Błąd" + }, + "update": { + "downloadAndInstall": "Pobierz i zainstaluj", + "downloadComplete": "Pobieranie zakończone", + "downloadCompleteMessage": "Pakiet aktualizacji został pobrany, czy chcesz go teraz zainstalować?", + "installLater": "Zainstaluj później", + "installNow": "Zainstaluj teraz", + "later": "Przypomnij później", + "newVersion": "Nowa wersja dostępna", + "newVersionAvailable": "Znaleziono nową wersję: {{version}}", + "skipThisVersion": "Pomiń tę wersję" + } +} diff --git a/apps/desktop/resources/locales/pl-PL/menu.json b/apps/desktop/resources/locales/pl-PL/menu.json new file mode 100644 index 0000000000..975e63d672 --- /dev/null +++ b/apps/desktop/resources/locales/pl-PL/menu.json @@ -0,0 +1,70 @@ +{ + "common": { + "checkUpdates": "Sprawdzanie aktualizacji..." + }, + "dev": { + "devPanel": "Panel dewelopera", + "devTools": "Narzędzia dewelopera", + "forceReload": "Wymuś ponowne załadowanie", + "openStore": "Otwórz plik magazynu", + "refreshMenu": "Odśwież menu", + "reload": "Przeładuj", + "title": "Rozwój" + }, + "edit": { + "copy": "Kopiuj", + "cut": "Wytnij", + "paste": "Wklej", + "redo": "Ponów", + "selectAll": "Zaznacz wszystko", + "speech": "Mowa", + "startSpeaking": "Rozpocznij czytanie", + "stopSpeaking": "Zatrzymaj czytanie", + "title": "Edycja", + "undo": "Cofnij" + }, + "file": { + "preferences": "Preferencje", + "quit": "Zakończ", + "title": "Plik" + }, + "help": { + "about": "O", + "githubRepo": "Repozytorium GitHub", + "reportIssue": "Zgłoś problem", + "title": "Pomoc", + "visitWebsite": "Odwiedź stronę internetową" + }, + "macOS": { + "about": "O {{appName}}", + "devTools": "Narzędzia dewelopera LobeHub", + "hide": "Ukryj {{appName}}", + "hideOthers": "Ukryj inne", + "preferences": "Ustawienia...", + "services": "Usługi", + "unhide": "Pokaż wszystko" + }, + "tray": { + "open": "Otwórz {{appName}}", + "quit": "Zakończ", + "show": "Pokaż {{appName}}" + }, + "view": { + "forceReload": "Wymuś ponowne załadowanie", + "reload": "Przeładuj", + "resetZoom": "Zresetuj powiększenie", + "title": "Widok", + "toggleFullscreen": "Przełącz tryb pełnoekranowy", + "zoomIn": "Powiększ", + "zoomOut": "Pomniejsz" + }, + "window": { + "bringAllToFront": "Przenieś wszystkie okna na wierzch", + "close": "Zamknij", + "front": "Przenieś wszystkie okna na wierzch", + "minimize": "Zminimalizuj", + "title": "Okno", + "toggleFullscreen": "Przełącz tryb pełnoekranowy", + "zoom": "Powiększenie" + } +} diff --git a/apps/desktop/resources/locales/pt-BR/common.json b/apps/desktop/resources/locales/pt-BR/common.json new file mode 100644 index 0000000000..7851975f2c --- /dev/null +++ b/apps/desktop/resources/locales/pt-BR/common.json @@ -0,0 +1,32 @@ +{ + "actions": { + "add": "Adicionar", + "back": "Voltar", + "cancel": "Cancelar", + "close": "Fechar", + "confirm": "Confirmar", + "delete": "Excluir", + "edit": "Editar", + "more": "Mais", + "next": "Próximo", + "ok": "OK", + "previous": "Anterior", + "refresh": "Atualizar", + "remove": "Remover", + "retry": "Tentar novamente", + "save": "Salvar", + "search": "Pesquisar", + "submit": "Enviar" + }, + "app": { + "description": "Sua plataforma de colaboração com assistente de IA", + "name": "LobeHub" + }, + "status": { + "error": "Erro", + "info": "Informação", + "loading": "Carregando", + "success": "Sucesso", + "warning": "Aviso" + } +} diff --git a/apps/desktop/resources/locales/pt-BR/dialog.json b/apps/desktop/resources/locales/pt-BR/dialog.json new file mode 100644 index 0000000000..392c641a71 --- /dev/null +++ b/apps/desktop/resources/locales/pt-BR/dialog.json @@ -0,0 +1,31 @@ +{ + "about": { + "button": "Confirmar", + "detail": "Um aplicativo de chat baseado em um grande modelo de linguagem", + "message": "{{appName}} {{appVersion}}", + "title": "Sobre" + }, + "confirm": { + "cancel": "Cancelar", + "no": "Não", + "title": "Confirmar", + "yes": "Sim" + }, + "error": { + "button": "Confirmar", + "detail": "Ocorreu um erro durante a operação, por favor tente novamente mais tarde", + "message": "Ocorreu um erro", + "title": "Erro" + }, + "update": { + "downloadAndInstall": "Baixar e instalar", + "downloadComplete": "Download completo", + "downloadCompleteMessage": "O pacote de atualização foi baixado com sucesso, deseja instalá-lo agora?", + "installLater": "Instalar depois", + "installNow": "Instalar agora", + "later": "Lembrar mais tarde", + "newVersion": "Nova versão disponível", + "newVersionAvailable": "Nova versão encontrada: {{version}}", + "skipThisVersion": "Ignorar esta versão" + } +} diff --git a/apps/desktop/resources/locales/pt-BR/menu.json b/apps/desktop/resources/locales/pt-BR/menu.json new file mode 100644 index 0000000000..216448d9ba --- /dev/null +++ b/apps/desktop/resources/locales/pt-BR/menu.json @@ -0,0 +1,70 @@ +{ + "common": { + "checkUpdates": "Verificando atualizações..." + }, + "dev": { + "devPanel": "Painel do Desenvolvedor", + "devTools": "Ferramentas do Desenvolvedor", + "forceReload": "Recarregar Forçadamente", + "openStore": "Abrir arquivo de armazenamento", + "refreshMenu": "Atualizar menu", + "reload": "Recarregar", + "title": "Desenvolvimento" + }, + "edit": { + "copy": "Copiar", + "cut": "Cortar", + "paste": "Colar", + "redo": "Refazer", + "selectAll": "Selecionar Tudo", + "speech": "Fala", + "startSpeaking": "Começar a Ler", + "stopSpeaking": "Parar de Ler", + "title": "Edição", + "undo": "Desfazer" + }, + "file": { + "preferences": "Preferências", + "quit": "Sair", + "title": "Arquivo" + }, + "help": { + "about": "Sobre", + "githubRepo": "Repositório do GitHub", + "reportIssue": "Reportar Problema", + "title": "Ajuda", + "visitWebsite": "Visitar o Site" + }, + "macOS": { + "about": "Sobre {{appName}}", + "devTools": "Ferramentas do Desenvolvedor LobeHub", + "hide": "Ocultar {{appName}}", + "hideOthers": "Ocultar Outros", + "preferences": "Configurações...", + "services": "Serviços", + "unhide": "Mostrar Todos" + }, + "tray": { + "open": "Abrir {{appName}}", + "quit": "Sair", + "show": "Mostrar {{appName}}" + }, + "view": { + "forceReload": "Recarregar Forçadamente", + "reload": "Recarregar", + "resetZoom": "Redefinir Zoom", + "title": "Visualização", + "toggleFullscreen": "Alternar Tela Cheia", + "zoomIn": "Aumentar", + "zoomOut": "Diminuir" + }, + "window": { + "bringAllToFront": "Trazer Todas as Janelas para Frente", + "close": "Fechar", + "front": "Trazer Todas as Janelas para Frente", + "minimize": "Minimizar", + "title": "Janela", + "toggleFullscreen": "Alternar Tela Cheia", + "zoom": "Zoom" + } +} diff --git a/apps/desktop/resources/locales/ru-RU/common.json b/apps/desktop/resources/locales/ru-RU/common.json new file mode 100644 index 0000000000..80a19c7759 --- /dev/null +++ b/apps/desktop/resources/locales/ru-RU/common.json @@ -0,0 +1,32 @@ +{ + "actions": { + "add": "Добавить", + "back": "Назад", + "cancel": "Отмена", + "close": "Закрыть", + "confirm": "Подтвердить", + "delete": "Удалить", + "edit": "Редактировать", + "more": "Больше", + "next": "Далее", + "ok": "ОК", + "previous": "Назад", + "refresh": "Обновить", + "remove": "Удалить", + "retry": "Повторить", + "save": "Сохранить", + "search": "Поиск", + "submit": "Отправить" + }, + "app": { + "description": "Ваша платформа для совместной работы с ИИ", + "name": "LobeHub" + }, + "status": { + "error": "Ошибка", + "info": "Информация", + "loading": "Загрузка", + "success": "Успех", + "warning": "Предупреждение" + } +} diff --git a/apps/desktop/resources/locales/ru-RU/dialog.json b/apps/desktop/resources/locales/ru-RU/dialog.json new file mode 100644 index 0000000000..2a1d67714b --- /dev/null +++ b/apps/desktop/resources/locales/ru-RU/dialog.json @@ -0,0 +1,31 @@ +{ + "about": { + "button": "Подтвердить", + "detail": "Приложение для чата на основе большой языковой модели", + "message": "{{appName}} {{appVersion}}", + "title": "О приложении" + }, + "confirm": { + "cancel": "Отмена", + "no": "Нет", + "title": "Подтверждение", + "yes": "Да" + }, + "error": { + "button": "Подтвердить", + "detail": "Произошла ошибка во время операции, пожалуйста, попробуйте позже", + "message": "Произошла ошибка", + "title": "Ошибка" + }, + "update": { + "downloadAndInstall": "Скачать и установить", + "downloadComplete": "Скачивание завершено", + "downloadCompleteMessage": "Обновление загружено, хотите установить сейчас?", + "installLater": "Установить позже", + "installNow": "Установить сейчас", + "later": "Напомнить позже", + "newVersion": "Обнаружена новая версия", + "newVersionAvailable": "Обнаружена новая версия: {{version}}", + "skipThisVersion": "Пропустить эту версию" + } +} diff --git a/apps/desktop/resources/locales/ru-RU/menu.json b/apps/desktop/resources/locales/ru-RU/menu.json new file mode 100644 index 0000000000..78a89c76bc --- /dev/null +++ b/apps/desktop/resources/locales/ru-RU/menu.json @@ -0,0 +1,70 @@ +{ + "common": { + "checkUpdates": "Проверка обновлений..." + }, + "dev": { + "devPanel": "Панель разработчика", + "devTools": "Инструменты разработчика", + "forceReload": "Принудительная перезагрузка", + "openStore": "Открыть файл хранилища", + "refreshMenu": "Обновить меню", + "reload": "Перезагрузить", + "title": "Разработка" + }, + "edit": { + "copy": "Копировать", + "cut": "Вырезать", + "paste": "Вставить", + "redo": "Повторить", + "selectAll": "Выбрать все", + "speech": "Речь", + "startSpeaking": "Начать чтение", + "stopSpeaking": "Остановить чтение", + "title": "Редактирование", + "undo": "Отменить" + }, + "file": { + "preferences": "Настройки", + "quit": "Выйти", + "title": "Файл" + }, + "help": { + "about": "О программе", + "githubRepo": "Репозиторий GitHub", + "reportIssue": "Сообщить о проблеме", + "title": "Помощь", + "visitWebsite": "Посетить сайт" + }, + "macOS": { + "about": "О {{appName}}", + "devTools": "Инструменты разработчика LobeHub", + "hide": "Скрыть {{appName}}", + "hideOthers": "Скрыть другие", + "preferences": "Настройки...", + "services": "Сервисы", + "unhide": "Показать все" + }, + "tray": { + "open": "Открыть {{appName}}", + "quit": "Выйти", + "show": "Показать {{appName}}" + }, + "view": { + "forceReload": "Принудительная перезагрузка", + "reload": "Перезагрузить", + "resetZoom": "Сбросить масштаб", + "title": "Вид", + "toggleFullscreen": "Переключить полноэкранный режим", + "zoomIn": "Увеличить", + "zoomOut": "Уменьшить" + }, + "window": { + "bringAllToFront": "Вывести все окна на передний план", + "close": "Закрыть", + "front": "Вывести все окна на передний план", + "minimize": "Свернуть", + "title": "Окно", + "toggleFullscreen": "Переключить полноэкранный режим", + "zoom": "Масштаб" + } +} diff --git a/apps/desktop/resources/locales/tr-TR/common.json b/apps/desktop/resources/locales/tr-TR/common.json new file mode 100644 index 0000000000..f8efc742bc --- /dev/null +++ b/apps/desktop/resources/locales/tr-TR/common.json @@ -0,0 +1,32 @@ +{ + "actions": { + "add": "Ekle", + "back": "Geri", + "cancel": "İptal", + "close": "Kapat", + "confirm": "Onayla", + "delete": "Sil", + "edit": "Düzenle", + "more": "Daha Fazla", + "next": "Sonraki", + "ok": "Tamam", + "previous": "Önceki", + "refresh": "Yenile", + "remove": "Kaldır", + "retry": "Yeniden Dene", + "save": "Kaydet", + "search": "Ara", + "submit": "Gönder" + }, + "app": { + "description": "AI asistanınız için işbirliği platformu", + "name": "LobeHub" + }, + "status": { + "error": "Hata", + "info": "Bilgi", + "loading": "Yükleniyor", + "success": "Başarılı", + "warning": "Uyarı" + } +} diff --git a/apps/desktop/resources/locales/tr-TR/dialog.json b/apps/desktop/resources/locales/tr-TR/dialog.json new file mode 100644 index 0000000000..edb8926ce2 --- /dev/null +++ b/apps/desktop/resources/locales/tr-TR/dialog.json @@ -0,0 +1,31 @@ +{ + "about": { + "button": "Tamam", + "detail": "Büyük dil modeli tabanlı bir sohbet uygulaması", + "message": "{{appName}} {{appVersion}}", + "title": "Hakkında" + }, + "confirm": { + "cancel": "İptal", + "no": "Hayır", + "title": "Onay", + "yes": "Evet" + }, + "error": { + "button": "Tamam", + "detail": "İşlem sırasında bir hata oluştu, lütfen daha sonra tekrar deneyin", + "message": "Hata oluştu", + "title": "Hata" + }, + "update": { + "downloadAndInstall": "İndir ve Yükle", + "downloadComplete": "İndirme tamamlandı", + "downloadCompleteMessage": "Güncelleme paketi indirildi, hemen yüklemek ister misiniz?", + "installLater": "Sonra yükle", + "installNow": "Şimdi yükle", + "later": "Sonra hatırlat", + "newVersion": "Yeni sürüm bulundu", + "newVersionAvailable": "Yeni sürüm bulundu: {{version}}", + "skipThisVersion": "Bu sürümü atla" + } +} diff --git a/apps/desktop/resources/locales/tr-TR/menu.json b/apps/desktop/resources/locales/tr-TR/menu.json new file mode 100644 index 0000000000..3360b38ce2 --- /dev/null +++ b/apps/desktop/resources/locales/tr-TR/menu.json @@ -0,0 +1,70 @@ +{ + "common": { + "checkUpdates": "Güncellemeleri kontrol et..." + }, + "dev": { + "devPanel": "Geliştirici Paneli", + "devTools": "Geliştirici Araçları", + "forceReload": "Zorla Yenile", + "openStore": "Depolama dosyasını aç", + "refreshMenu": "Menüyü yenile", + "reload": "Yenile", + "title": "Geliştir" + }, + "edit": { + "copy": "Kopyala", + "cut": "Kes", + "paste": "Yapıştır", + "redo": "Yinele", + "selectAll": "Tümünü Seç", + "speech": "Ses", + "startSpeaking": "Okumaya Başla", + "stopSpeaking": "Okumayı Durdur", + "title": "Düzenle", + "undo": "Geri Al" + }, + "file": { + "preferences": "Tercihler", + "quit": "Çık", + "title": "Dosya" + }, + "help": { + "about": "Hakkında", + "githubRepo": "GitHub Deposu", + "reportIssue": "Sorun Bildir", + "title": "Yardım", + "visitWebsite": "Resmi Web Sitesini Ziyaret Et" + }, + "macOS": { + "about": "{{appName}} Hakkında", + "devTools": "LobeHub Geliştirici Araçları", + "hide": "{{appName}}'i Gizle", + "hideOthers": "Diğerlerini Gizle", + "preferences": "Tercihler...", + "services": "Hizmetler", + "unhide": "Hepsini Göster" + }, + "tray": { + "open": "{{appName}}'i Aç", + "quit": "Çık", + "show": "{{appName}}'i Göster" + }, + "view": { + "forceReload": "Zorla Yenile", + "reload": "Yenile", + "resetZoom": "Yakınlaştırmayı Sıfırla", + "title": "Görünüm", + "toggleFullscreen": "Tam Ekrana Geç", + "zoomIn": "Büyüt", + "zoomOut": "Küçült" + }, + "window": { + "bringAllToFront": "Tüm Pencereleri Öne Getir", + "close": "Kapat", + "front": "Tüm Pencereleri Öne Getir", + "minimize": "Küçült", + "title": "Pencere", + "toggleFullscreen": "Tam Ekrana Geç", + "zoom": "Yakınlaştır" + } +} diff --git a/apps/desktop/resources/locales/vi-VN/common.json b/apps/desktop/resources/locales/vi-VN/common.json new file mode 100644 index 0000000000..4434ec2309 --- /dev/null +++ b/apps/desktop/resources/locales/vi-VN/common.json @@ -0,0 +1,32 @@ +{ + "actions": { + "add": "Thêm", + "back": "Quay lại", + "cancel": "Hủy", + "close": "Đóng", + "confirm": "Xác nhận", + "delete": "Xóa", + "edit": "Chỉnh sửa", + "more": "Thêm nữa", + "next": "Tiếp theo", + "ok": "Đồng ý", + "previous": "Quay lại", + "refresh": "Tải lại", + "remove": "Gỡ bỏ", + "retry": "Thử lại", + "save": "Lưu", + "search": "Tìm kiếm", + "submit": "Gửi" + }, + "app": { + "description": "Nền tảng hợp tác trợ lý AI của bạn", + "name": "LobeHub" + }, + "status": { + "error": "Lỗi", + "info": "Thông tin", + "loading": "Đang tải", + "success": "Thành công", + "warning": "Cảnh báo" + } +} diff --git a/apps/desktop/resources/locales/vi-VN/dialog.json b/apps/desktop/resources/locales/vi-VN/dialog.json new file mode 100644 index 0000000000..5f028f98ed --- /dev/null +++ b/apps/desktop/resources/locales/vi-VN/dialog.json @@ -0,0 +1,31 @@ +{ + "about": { + "button": "Xác nhận", + "detail": "Một ứng dụng trò chuyện dựa trên mô hình ngôn ngữ lớn", + "message": "{{appName}} {{appVersion}}", + "title": "Về" + }, + "confirm": { + "cancel": "Hủy", + "no": "Không", + "title": "Xác nhận", + "yes": "Có" + }, + "error": { + "button": "Xác nhận", + "detail": "Đã xảy ra lỗi trong quá trình thực hiện, vui lòng thử lại sau", + "message": "Đã xảy ra lỗi", + "title": "Lỗi" + }, + "update": { + "downloadAndInstall": "Tải xuống và cài đặt", + "downloadComplete": "Tải xuống hoàn tất", + "downloadCompleteMessage": "Gói cập nhật đã tải xuống hoàn tất, có muốn cài đặt ngay không?", + "installLater": "Cài đặt sau", + "installNow": "Cài đặt ngay", + "later": "Nhắc nhở sau", + "newVersion": "Phát hiện phiên bản mới", + "newVersionAvailable": "Phát hiện phiên bản mới: {{version}}", + "skipThisVersion": "Bỏ qua phiên bản này" + } +} diff --git a/apps/desktop/resources/locales/vi-VN/menu.json b/apps/desktop/resources/locales/vi-VN/menu.json new file mode 100644 index 0000000000..384b4879d4 --- /dev/null +++ b/apps/desktop/resources/locales/vi-VN/menu.json @@ -0,0 +1,70 @@ +{ + "common": { + "checkUpdates": "Kiểm tra cập nhật..." + }, + "dev": { + "devPanel": "Bảng điều khiển nhà phát triển", + "devTools": "Công cụ phát triển", + "forceReload": "Tải lại cưỡng bức", + "openStore": "Mở tệp lưu trữ", + "refreshMenu": "Làm mới menu", + "reload": "Tải lại", + "title": "Phát triển" + }, + "edit": { + "copy": "Sao chép", + "cut": "Cắt", + "paste": "Dán", + "redo": "Làm lại", + "selectAll": "Chọn tất cả", + "speech": "Giọng nói", + "startSpeaking": "Bắt đầu đọc", + "stopSpeaking": "Dừng đọc", + "title": "Chỉnh sửa", + "undo": "Hoàn tác" + }, + "file": { + "preferences": "Tùy chọn", + "quit": "Thoát", + "title": "Tập tin" + }, + "help": { + "about": "Về", + "githubRepo": "Kho lưu trữ GitHub", + "reportIssue": "Báo cáo sự cố", + "title": "Trợ giúp", + "visitWebsite": "Truy cập trang web" + }, + "macOS": { + "about": "Về {{appName}}", + "devTools": "Công cụ phát triển LobeHub", + "hide": "Ẩn {{appName}}", + "hideOthers": "Ẩn khác", + "preferences": "Cài đặt ưu tiên...", + "services": "Dịch vụ", + "unhide": "Hiện tất cả" + }, + "tray": { + "open": "Mở {{appName}}", + "quit": "Thoát", + "show": "Hiện {{appName}}" + }, + "view": { + "forceReload": "Tải lại cưỡng bức", + "reload": "Tải lại", + "resetZoom": "Đặt lại thu phóng", + "title": "Xem", + "toggleFullscreen": "Chuyển đổi toàn màn hình", + "zoomIn": "Phóng to", + "zoomOut": "Thu nhỏ" + }, + "window": { + "bringAllToFront": "Đưa tất cả cửa sổ lên trước", + "close": "Đóng", + "front": "Đưa tất cả cửa sổ lên trước", + "minimize": "Thu nhỏ", + "title": "Cửa sổ", + "toggleFullscreen": "Chuyển đổi toàn màn hình", + "zoom": "Thu phóng" + } +} diff --git a/apps/desktop/resources/locales/zh-CN/common.json b/apps/desktop/resources/locales/zh-CN/common.json new file mode 100644 index 0000000000..ba164ca0d4 --- /dev/null +++ b/apps/desktop/resources/locales/zh-CN/common.json @@ -0,0 +1,32 @@ +{ + "actions": { + "add": "添加", + "back": "返回", + "cancel": "取消", + "close": "关闭", + "confirm": "确认", + "delete": "删除", + "edit": "编辑", + "more": "更多", + "next": "下一步", + "ok": "确定", + "previous": "上一步", + "refresh": "刷新", + "remove": "移除", + "retry": "重试", + "save": "保存", + "search": "搜索", + "submit": "提交" + }, + "app": { + "description": "你的 AI 助手协作平台", + "name": "LobeHub" + }, + "status": { + "error": "错误", + "info": "信息", + "loading": "加载中", + "success": "成功", + "warning": "警告" + } +} diff --git a/apps/desktop/resources/locales/zh-CN/dialog.json b/apps/desktop/resources/locales/zh-CN/dialog.json new file mode 100644 index 0000000000..629b91b433 --- /dev/null +++ b/apps/desktop/resources/locales/zh-CN/dialog.json @@ -0,0 +1,31 @@ +{ + "about": { + "button": "确定", + "detail": "一个基于大语言模型的聊天应用", + "message": "{{appName}} {{appVersion}}", + "title": "关于" + }, + "confirm": { + "cancel": "取消", + "no": "否", + "title": "确认", + "yes": "是" + }, + "error": { + "button": "确定", + "detail": "操作过程中发生错误,请稍后重试", + "message": "发生错误", + "title": "错误" + }, + "update": { + "downloadAndInstall": "下载并安装", + "downloadComplete": "下载完成", + "downloadCompleteMessage": "更新包已下载完成,是否立即安装?", + "installLater": "稍后安装", + "installNow": "立即安装", + "later": "稍后提醒", + "newVersion": "发现新版本", + "newVersionAvailable": "发现新版本: {{version}}", + "skipThisVersion": "跳过此版本" + } +} diff --git a/apps/desktop/resources/locales/zh-CN/menu.json b/apps/desktop/resources/locales/zh-CN/menu.json new file mode 100644 index 0000000000..6b4660552e --- /dev/null +++ b/apps/desktop/resources/locales/zh-CN/menu.json @@ -0,0 +1,70 @@ +{ + "common": { + "checkUpdates": "检查更新..." + }, + "dev": { + "devPanel": "开发者面板", + "devTools": "开发者工具", + "forceReload": "强制重新加载", + "openStore": "打开存储文件", + "refreshMenu": "刷新菜单", + "reload": "重新加载", + "title": "开发" + }, + "edit": { + "copy": "复制", + "cut": "剪切", + "paste": "粘贴", + "redo": "重做", + "selectAll": "全选", + "speech": "语音", + "startSpeaking": "开始朗读", + "stopSpeaking": "停止朗读", + "title": "编辑", + "undo": "撤销" + }, + "file": { + "preferences": "首选项", + "quit": "退出", + "title": "文件" + }, + "help": { + "about": "关于", + "githubRepo": "GitHub 仓库", + "reportIssue": "报告问题", + "title": "帮助", + "visitWebsite": "访问官网" + }, + "macOS": { + "about": "关于 {{appName}}", + "devTools": "LobeHub 开发者工具", + "hide": "隐藏 {{appName}}", + "hideOthers": "隐藏其他", + "preferences": "偏好设置...", + "services": "服务", + "unhide": "全部显示" + }, + "tray": { + "open": "打开 {{appName}}", + "quit": "退出", + "show": "显示 {{appName}}" + }, + "view": { + "forceReload": "强制重新加载", + "reload": "重新加载", + "resetZoom": "重置缩放", + "title": "视图", + "toggleFullscreen": "切换全屏", + "zoomIn": "放大", + "zoomOut": "缩小" + }, + "window": { + "bringAllToFront": "前置所有窗口", + "close": "关闭", + "front": "前置所有窗口", + "minimize": "最小化", + "title": "窗口", + "toggleFullscreen": "切换全屏", + "zoom": "缩放" + } +} diff --git a/apps/desktop/resources/locales/zh-TW/common.json b/apps/desktop/resources/locales/zh-TW/common.json new file mode 100644 index 0000000000..05ec4b3418 --- /dev/null +++ b/apps/desktop/resources/locales/zh-TW/common.json @@ -0,0 +1,32 @@ +{ + "actions": { + "add": "新增", + "back": "返回", + "cancel": "取消", + "close": "關閉", + "confirm": "確認", + "delete": "刪除", + "edit": "編輯", + "more": "更多", + "next": "下一步", + "ok": "確定", + "previous": "上一步", + "refresh": "刷新", + "remove": "移除", + "retry": "重試", + "save": "儲存", + "search": "搜尋", + "submit": "提交" + }, + "app": { + "description": "你的 AI 助手協作平台", + "name": "LobeHub" + }, + "status": { + "error": "錯誤", + "info": "資訊", + "loading": "載入中", + "success": "成功", + "warning": "警告" + } +} diff --git a/apps/desktop/resources/locales/zh-TW/dialog.json b/apps/desktop/resources/locales/zh-TW/dialog.json new file mode 100644 index 0000000000..796be99e15 --- /dev/null +++ b/apps/desktop/resources/locales/zh-TW/dialog.json @@ -0,0 +1,31 @@ +{ + "about": { + "button": "確定", + "detail": "一個基於大語言模型的聊天應用", + "message": "{{appName}} {{appVersion}}", + "title": "關於" + }, + "confirm": { + "cancel": "取消", + "no": "否", + "title": "確認", + "yes": "是" + }, + "error": { + "button": "確定", + "detail": "操作過程中發生錯誤,請稍後重試", + "message": "發生錯誤", + "title": "錯誤" + }, + "update": { + "downloadAndInstall": "下載並安裝", + "downloadComplete": "下載完成", + "downloadCompleteMessage": "更新包已下載完成,是否立即安裝?", + "installLater": "稍後安裝", + "installNow": "立即安裝", + "later": "稍後提醒", + "newVersion": "發現新版本", + "newVersionAvailable": "發現新版本: {{version}}", + "skipThisVersion": "跳過此版本" + } +} diff --git a/apps/desktop/resources/locales/zh-TW/menu.json b/apps/desktop/resources/locales/zh-TW/menu.json new file mode 100644 index 0000000000..c10b9954da --- /dev/null +++ b/apps/desktop/resources/locales/zh-TW/menu.json @@ -0,0 +1,70 @@ +{ + "common": { + "checkUpdates": "檢查更新..." + }, + "dev": { + "devPanel": "開發者面板", + "devTools": "開發者工具", + "forceReload": "強制重新載入", + "openStore": "打開儲存檔案", + "refreshMenu": "刷新選單", + "reload": "重新載入", + "title": "開發" + }, + "edit": { + "copy": "複製", + "cut": "剪下", + "paste": "貼上", + "redo": "重做", + "selectAll": "全選", + "speech": "語音", + "startSpeaking": "開始朗讀", + "stopSpeaking": "停止朗讀", + "title": "編輯", + "undo": "撤銷" + }, + "file": { + "preferences": "偏好設定", + "quit": "退出", + "title": "檔案" + }, + "help": { + "about": "關於", + "githubRepo": "GitHub 倉庫", + "reportIssue": "報告問題", + "title": "幫助", + "visitWebsite": "訪問網站" + }, + "macOS": { + "about": "關於 {{appName}}", + "devTools": "LobeHub 開發者工具", + "hide": "隱藏 {{appName}}", + "hideOthers": "隱藏其他", + "preferences": "偏好設定...", + "services": "服務", + "unhide": "全部顯示" + }, + "tray": { + "open": "打開 {{appName}}", + "quit": "退出", + "show": "顯示 {{appName}}" + }, + "view": { + "forceReload": "強制重新載入", + "reload": "重新載入", + "resetZoom": "重置縮放", + "title": "視圖", + "toggleFullscreen": "切換全螢幕", + "zoomIn": "放大", + "zoomOut": "縮小" + }, + "window": { + "bringAllToFront": "前置所有視窗", + "close": "關閉", + "front": "前置所有視窗", + "minimize": "最小化", + "title": "視窗", + "toggleFullscreen": "切換全螢幕", + "zoom": "縮放" + } +} diff --git a/apps/desktop/resources/splash.html b/apps/desktop/resources/splash.html new file mode 100644 index 0000000000..cb29472c32 --- /dev/null +++ b/apps/desktop/resources/splash.html @@ -0,0 +1,88 @@ + + + + + + LobeHub + + + +
+ + LobeHub + + +
+ + diff --git a/apps/desktop/scripts/i18nWorkflow/const.ts b/apps/desktop/scripts/i18nWorkflow/const.ts new file mode 100644 index 0000000000..02591d05ad --- /dev/null +++ b/apps/desktop/scripts/i18nWorkflow/const.ts @@ -0,0 +1,18 @@ +import { readdirSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import i18nConfig from '../../.i18nrc'; + +export const root = resolve(__dirname, '../..'); +export const localesDir = resolve(root, i18nConfig.output); +export const localeDir = (locale: string) => resolve(localesDir, locale); +export const localeDirJsonList = (locale: string) => + readdirSync(localeDir(locale)).filter((name) => name.includes('.json')); +export const srcLocalesDir = resolve(root, './src/main/locales'); +export const entryLocaleJsonFilepath = (file: string) => + resolve(localesDir, i18nConfig.entryLocale, file); +export const outputLocaleJsonFilepath = (locale: string, file: string) => + resolve(localesDir, locale, file); +export const srcDefaultLocales = resolve(root, srcLocalesDir, 'default'); + +export { default as i18nConfig } from '../../.i18nrc'; diff --git a/apps/desktop/scripts/i18nWorkflow/genDefaultLocale.ts b/apps/desktop/scripts/i18nWorkflow/genDefaultLocale.ts new file mode 100644 index 0000000000..2fecdf1613 --- /dev/null +++ b/apps/desktop/scripts/i18nWorkflow/genDefaultLocale.ts @@ -0,0 +1,35 @@ +import { consola } from 'consola'; +import { colors } from 'consola/utils'; +import { existsSync, mkdirSync } from 'node:fs'; +import { dirname } from 'node:path'; + +import { entryLocaleJsonFilepath, i18nConfig, localeDir, srcDefaultLocales } from './const'; +import { tagWhite, writeJSON } from './utils'; + +export const genDefaultLocale = () => { + consola.info(`默认语言为 ${i18nConfig.entryLocale}...`); + + // 确保入口语言目录存在 + const entryLocaleDir = localeDir(i18nConfig.entryLocale); + if (!existsSync(entryLocaleDir)) { + mkdirSync(entryLocaleDir, { recursive: true }); + consola.info(`创建目录:${entryLocaleDir}`); + } + + const resources = require(srcDefaultLocales); + const data = Object.entries(resources.default); + consola.start(`生成默认语言 JSON 文件,发现 ${data.length} 个命名空间...`); + + for (const [ns, value] of data) { + const filepath = entryLocaleJsonFilepath(`${ns}.json`); + + // 确保目录存在 + const dir = dirname(filepath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + writeJSON(filepath, value); + consola.success(tagWhite(ns), colors.gray(filepath)); + } +}; diff --git a/apps/desktop/scripts/i18nWorkflow/genDiff.ts b/apps/desktop/scripts/i18nWorkflow/genDiff.ts new file mode 100644 index 0000000000..460ad8e687 --- /dev/null +++ b/apps/desktop/scripts/i18nWorkflow/genDiff.ts @@ -0,0 +1,57 @@ +import { consola } from 'consola'; +import { colors } from 'consola/utils'; +import { diff } from 'just-diff'; +import { unset } from 'lodash'; +import { existsSync } from 'node:fs'; + +import { + entryLocaleJsonFilepath, + i18nConfig, + outputLocaleJsonFilepath, + srcDefaultLocales, +} from './const'; +import { readJSON, tagWhite, writeJSON } from './utils'; + +export const genDiff = () => { + consola.start(`对比开发与生产环境中的本地化文件...`); + + const resources = require(srcDefaultLocales); + const data = Object.entries(resources.default); + + for (const [ns, devJSON] of data) { + const filepath = entryLocaleJsonFilepath(`${ns}.json`); + if (!existsSync(filepath)) { + consola.info(`文件不存在,跳过:${filepath}`); + continue; + } + + const prodJSON = readJSON(filepath); + + const diffResult = diff(prodJSON, devJSON as any); + const remove = diffResult.filter((item) => item.op === 'remove'); + if (remove.length === 0) { + consola.success(tagWhite(ns), colors.gray(filepath)); + continue; + } + + const clearLocals = []; + + for (const locale of [i18nConfig.entryLocale, ...i18nConfig.outputLocales]) { + const localeFilepath = outputLocaleJsonFilepath(locale, `${ns}.json`); + if (!existsSync(localeFilepath)) continue; + const localeJSON = readJSON(localeFilepath); + + for (const item of remove) { + unset(localeJSON, item.path); + } + + writeJSON(localeFilepath, localeJSON); + clearLocals.push(locale); + } + + if (clearLocals.length > 0) { + consola.info('清理了以下语言的过期项目:', clearLocals.join(', ')); + } + consola.success(tagWhite(ns), colors.gray(filepath)); + } +}; diff --git a/apps/desktop/scripts/i18nWorkflow/index.ts b/apps/desktop/scripts/i18nWorkflow/index.ts new file mode 100644 index 0000000000..5215bb00c7 --- /dev/null +++ b/apps/desktop/scripts/i18nWorkflow/index.ts @@ -0,0 +1,35 @@ +import { existsSync, mkdirSync } from 'node:fs'; + +import { i18nConfig, localeDir } from './const'; +import { genDefaultLocale } from './genDefaultLocale'; +import { genDiff } from './genDiff'; +import { split } from './utils'; + +// 确保所有语言目录存在 +const ensureLocalesDirs = () => { + [i18nConfig.entryLocale, ...i18nConfig.outputLocales].forEach((locale) => { + const dir = localeDir(locale); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + }); +}; + +// 运行工作流 +const run = async () => { + // 确保目录存在 + ensureLocalesDirs(); + + // 差异分析 + split('差异分析'); + genDiff(); + + // 生成默认语言文件 + split('生成默认语言文件'); + genDefaultLocale(); + + // 生成国际化文件 + split('生成国际化文件'); +}; + +run(); diff --git a/apps/desktop/scripts/i18nWorkflow/utils.ts b/apps/desktop/scripts/i18nWorkflow/utils.ts new file mode 100644 index 0000000000..bf5571817d --- /dev/null +++ b/apps/desktop/scripts/i18nWorkflow/utils.ts @@ -0,0 +1,54 @@ +import { consola } from 'consola'; +import { colors } from 'consola/utils'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import i18nConfig from '../../.i18nrc'; + +export const readJSON = (filePath: string) => { + const data = readFileSync(filePath, 'utf8'); + return JSON.parse(data); +}; + +export const writeJSON = (filePath: string, data: any) => { + const jsonStr = JSON.stringify(data, null, 2); + writeFileSync(filePath, jsonStr, 'utf8'); +}; + +export const genResourcesContent = (locales: string[]) => { + let index = ''; + let indexObj = ''; + + for (const locale of locales) { + index += `import ${locale} from "./${locale}";\n`; + indexObj += ` "${locale.replace('_', '-')}": ${locale},\n`; + } + + return `${index} +const resources = { +${indexObj}} as const; +export default resources; +export const defaultResources = ${i18nConfig.entryLocale}; +export type Resources = typeof resources; +export type DefaultResources = typeof defaultResources; +export type Namespaces = keyof DefaultResources; +export type Locales = keyof Resources; +`; +}; + +export const genNamespaceList = (files: string[], locale: string) => { + return files.map((file) => ({ + name: file.replace('.json', ''), + path: resolve(i18nConfig.output, locale, file), + })); +}; + +export const tagBlue = (text: string) => colors.bgBlueBright(colors.black(` ${text} `)); +export const tagYellow = (text: string) => colors.bgYellowBright(colors.black(` ${text} `)); +export const tagGreen = (text: string) => colors.bgGreenBright(colors.black(` ${text} `)); +export const tagWhite = (text: string) => colors.bgWhiteBright(colors.black(` ${text} `)); + +export const split = (name: string) => { + consola.log(''); + consola.log(colors.gray(`========================== ${name} ==============================`)); +}; diff --git a/apps/desktop/scripts/pglite-server.ts b/apps/desktop/scripts/pglite-server.ts new file mode 100644 index 0000000000..c3735b8938 --- /dev/null +++ b/apps/desktop/scripts/pglite-server.ts @@ -0,0 +1,14 @@ +import { PGlite } from "@electric-sql/pglite"; +import { createServer } from "pglite-server"; + +// 创建或连接到您现有的 PGlite 数据库 +const db = new PGlite("/Users/arvinxx/Library/Application Support/lobehub-desktop/lobehub-local-db"); +await db.waitReady; + +// 创建服务器并监听端口 +const PORT = 6543; +const pgServer = createServer(db); + +pgServer.listen(PORT, () => { + console.log(`PGlite 服务器已启动,监听端口 ${PORT}`); +}); diff --git a/apps/desktop/src/common/routes.ts b/apps/desktop/src/common/routes.ts new file mode 100644 index 0000000000..cb2d96353f --- /dev/null +++ b/apps/desktop/src/common/routes.ts @@ -0,0 +1,78 @@ +/** + * 路由拦截类型,描述拦截路由和目标窗口的映射关系 + */ +export interface RouteInterceptConfig { + /** + * 是否始终在新窗口中打开,即使目标窗口已经存在 + */ + alwaysOpenNew?: boolean; + + /** + * 描述 + */ + description: string; + + /** + * 是否启用拦截 + */ + enabled: boolean; + + /** + * 路由模式前缀,例如 '/settings' + */ + pathPrefix: string; + + /** + * 目标窗口标识符 + */ + targetWindow: string; +} + +/** + * 拦截路由配置列表 + * 定义了所有需要特殊处理的路由 + */ +export const interceptRoutes: RouteInterceptConfig[] = [ + { + description: '设置页面', + enabled: true, + pathPrefix: '/settings', + targetWindow: 'settings', + }, + { + description: '开发者工具', + enabled: true, + pathPrefix: '/desktop/devtools', + targetWindow: 'devtools', + }, + // 未来可能的其他路由 + // { + // description: '帮助中心', + // enabled: true, + // pathPrefix: '/help', + // targetWindow: 'help', + // }, +]; + +/** + * 通过路径查找匹配的路由拦截配置 + * @param path 需要检查的路径 + * @returns 匹配的拦截配置,如果没有匹配则返回 undefined + */ +export const findMatchingRoute = (path: string): RouteInterceptConfig | undefined => { + return interceptRoutes.find((route) => route.enabled && path.startsWith(route.pathPrefix)); +}; + +/** + * 从完整路径中提取子路径 + * @param fullPath 完整路径,如 '/settings/agent' + * @param pathPrefix 路径前缀,如 '/settings' + * @returns 子路径,如 'agent' + */ +export const extractSubPath = (fullPath: string, pathPrefix: string): string | undefined => { + if (fullPath.length <= pathPrefix.length) return undefined; + + // 去除前导斜杠 + const subPath = fullPath.slice(Math.max(0, pathPrefix.length + 1)); + return subPath || undefined; +}; diff --git a/apps/desktop/src/main/appBrowsers.ts b/apps/desktop/src/main/appBrowsers.ts new file mode 100644 index 0000000000..39c4be73f4 --- /dev/null +++ b/apps/desktop/src/main/appBrowsers.ts @@ -0,0 +1,47 @@ +import type { BrowserWindowOpts } from './core/Browser'; + +export const BrowsersIdentifiers = { + chat: 'chat', + devtools: 'devtools', + settings: 'settings', +}; + +export const appBrowsers = { + chat: { + autoHideMenuBar: true, + height: 800, + identifier: 'chat', + keepAlive: true, + minWidth: 400, + path: '/chat', + showOnInit: true, + titleBarStyle: 'hidden', + vibrancy: 'under-window', + width: 1200, + }, + devtools: { + autoHideMenuBar: true, + fullscreenable: false, + height: 600, + identifier: 'devtools', + maximizable: false, + minWidth: 400, + path: '/desktop/devtools', + titleBarStyle: 'hiddenInset', + vibrancy: 'under-window', + width: 1000, + }, + settings: { + autoHideMenuBar: true, + height: 800, + identifier: 'settings', + keepAlive: true, + minWidth: 600, + path: '/settings', + titleBarStyle: 'hidden', + vibrancy: 'under-window', + width: 1000, + }, +} satisfies Record; + +export type AppBrowsersIdentifiers = keyof typeof appBrowsers; diff --git a/apps/desktop/src/main/const/dir.ts b/apps/desktop/src/main/const/dir.ts new file mode 100644 index 0000000000..00f3fb05d3 --- /dev/null +++ b/apps/desktop/src/main/const/dir.ts @@ -0,0 +1,29 @@ +import { app } from 'electron'; +import { join } from 'node:path'; + +export const mainDir = join(__dirname); + +export const preloadDir = join(mainDir, '../preload'); + +export const resourcesDir = join(mainDir, '../../resources'); + +export const buildDir = join(mainDir, '../../build'); + +const appPath = app.getAppPath(); + +export const nextStandaloneDir = join(appPath, 'dist', 'next'); + +export const userDataDir = app.getPath('userData'); + +export const appStorageDir = join(userDataDir, 'lobehub-storage'); + +// ------ Application storage directory ---- // + +// db schema hash +export const DB_SCHEMA_HASH_FILENAME = 'lobehub-local-db-schema-hash'; +// pglite database dir +export const LOCAL_DATABASE_DIR = 'lobehub-local-db'; +// 本地存储文件(模拟 S3) +export const FILE_STORAGE_DIR = 'file-storage'; +// Plugin 安装目录 +export const INSTALL_PLUGINS_DIR = 'plugins'; diff --git a/apps/desktop/src/main/const/env.ts b/apps/desktop/src/main/const/env.ts new file mode 100644 index 0000000000..e5b200659a --- /dev/null +++ b/apps/desktop/src/main/const/env.ts @@ -0,0 +1,3 @@ +export const isDev = process.env.NODE_ENV === 'development'; + +export const OFFICIAL_CLOUD_SERVER = process.env.OFFICIAL_CLOUD_SERVER || 'https://lobechat.com'; diff --git a/apps/desktop/src/main/const/store.ts b/apps/desktop/src/main/const/store.ts new file mode 100644 index 0000000000..27103c63d0 --- /dev/null +++ b/apps/desktop/src/main/const/store.ts @@ -0,0 +1,22 @@ +/** + * 应用设置存储相关常量 + */ +import { appStorageDir } from '@/const/dir'; +import { DEFAULT_SHORTCUTS_CONFIG } from '@/shortcuts'; +import { ElectronMainStore } from '@/types/store'; + +/** + * 存储名称 + */ +export const STORE_NAME = 'lobehub-settings'; + +/** + * 存储默认值 + */ +export const STORE_DEFAULTS: ElectronMainStore = { + dataSyncConfig: { storageMode: 'local' }, + encryptedTokens: {}, + locale: 'auto', + shortcuts: DEFAULT_SHORTCUTS_CONFIG, + storagePath: appStorageDir, +}; diff --git a/apps/desktop/src/main/controllers/AuthCtr.ts b/apps/desktop/src/main/controllers/AuthCtr.ts new file mode 100644 index 0000000000..56e55ad879 --- /dev/null +++ b/apps/desktop/src/main/controllers/AuthCtr.ts @@ -0,0 +1,390 @@ +import { DataSyncConfig } from '@lobechat/electron-client-ipc'; +import { BrowserWindow, app, shell } from 'electron'; +import crypto from 'node:crypto'; +import querystring from 'node:querystring'; +import { URL } from 'node:url'; + +import { name } from '@/../../package.json'; +import { createLogger } from '@/utils/logger'; + +import RemoteServerConfigCtr from './RemoteServerConfigCtr'; +import { ControllerModule, ipcClientEvent } from './index'; + +// Create logger +const logger = createLogger('controllers:AuthCtr'); + +const protocolPrefix = `com.lobehub.${name}`; +/** + * Authentication Controller + * Used to implement the OAuth authorization flow + */ +export default class AuthCtr extends ControllerModule { + /** + * 远程服务器配置控制器 + */ + private get remoteServerConfigCtr() { + return this.app.getController(RemoteServerConfigCtr); + } + + /** + * 当前的 PKCE 参数 + */ + private codeVerifier: string | null = null; + private authRequestState: string | null = null; + + beforeAppReady = () => { + this.registerProtocolHandler(); + }; + + /** + * Request OAuth authorization + */ + @ipcClientEvent('requestAuthorization') + async requestAuthorization(config: DataSyncConfig) { + const remoteUrl = await this.remoteServerConfigCtr.getRemoteServerUrl(config); + + logger.info( + `Requesting OAuth authorization, storageMode:${config.storageMode} server URL: ${remoteUrl}`, + ); + try { + // Generate PKCE parameters + logger.debug('Generating PKCE parameters'); + const codeVerifier = this.generateCodeVerifier(); + const codeChallenge = await this.generateCodeChallenge(codeVerifier); + this.codeVerifier = codeVerifier; + + // Generate state parameter to prevent CSRF attacks + this.authRequestState = crypto.randomBytes(16).toString('hex'); + logger.debug(`Generated state parameter: ${this.authRequestState}`); + + // Construct authorization URL + const authUrl = new URL('/oidc/auth', remoteUrl); + + // Add query parameters + authUrl.search = querystring.stringify({ + client_id: 'lobehub-desktop', + code_challenge: codeChallenge, + code_challenge_method: 'S256', + prompt: 'consent', + redirect_uri: `${protocolPrefix}://auth/callback`, + response_type: 'code', + scope: 'profile email offline_access', + state: this.authRequestState, + }); + + logger.info(`Constructed authorization URL: ${authUrl.toString()}`); + + // Open authorization URL in the default browser + await shell.openExternal(authUrl.toString()); + logger.debug('Opening authorization URL in default browser'); + + return { success: true }; + } catch (error) { + logger.error('Authorization request failed:', error); + return { error: error.message, success: false }; + } + } + + /** + * Handle authorization callback + * This method is called when the browser redirects to our custom protocol + */ + async handleAuthCallback(callbackUrl: string) { + logger.info(`Handling authorization callback: ${callbackUrl}`); + try { + const url = new URL(callbackUrl); + const params = new URLSearchParams(url.search); + + // Get authorization code + const code = params.get('code'); + const state = params.get('state'); + logger.debug(`Got parameters from callback URL: code=${code}, state=${state}`); + + // Validate state parameter to prevent CSRF attacks + if (state !== this.authRequestState) { + logger.error( + `Invalid state parameter: expected ${this.authRequestState}, received ${state}`, + ); + throw new Error('Invalid state parameter'); + } + logger.debug('State parameter validation passed'); + + if (!code) { + logger.error('No authorization code received'); + throw new Error('No authorization code received'); + } + + // Get configuration information + const config = await this.remoteServerConfigCtr.getRemoteServerConfig(); + logger.debug(`Getting remote server configuration: url=${config.remoteServerUrl}`); + + if (!config.remoteServerUrl) { + logger.error('Server URL not configured'); + throw new Error('No server URL configured'); + } + + // Get the previously saved code_verifier + const codeVerifier = this.codeVerifier; + if (!codeVerifier) { + logger.error('Code verifier not found'); + throw new Error('No code verifier found'); + } + logger.debug('Found code verifier'); + + // Exchange authorization code for token + logger.debug('Starting to exchange authorization code for token'); + const result = await this.exchangeCodeForToken(code, codeVerifier); + + if (result.success) { + logger.info('Authorization successful'); + // Notify render process of successful authorization + this.broadcastAuthorizationSuccessful(); + } else { + logger.warn(`Authorization failed: ${result.error || 'Unknown error'}`); + // Notify render process of failed authorization + this.broadcastAuthorizationFailed(result.error || 'Unknown error'); + } + + return result; + } catch (error) { + logger.error('Handling authorization callback failed:', error); + + // Notify render process of failed authorization + this.broadcastAuthorizationFailed(error.message); + + return { error: error.message, success: false }; + } finally { + // Clear authorization request state + logger.debug('Clearing authorization request state'); + this.authRequestState = null; + this.codeVerifier = null; + } + } + + /** + * Refresh access token + */ + @ipcClientEvent('refreshAccessToken') + async refreshAccessToken() { + logger.info('Starting to refresh access token'); + try { + // Call the centralized refresh logic in RemoteServerConfigCtr + const result = await this.remoteServerConfigCtr.refreshAccessToken(); + + if (result.success) { + logger.info('Token refresh successful via AuthCtr call.'); + // Notify render process that token has been refreshed + this.broadcastTokenRefreshed(); + return { success: true }; + } else { + // Throw an error to be caught by the catch block below + // This maintains the existing behavior of clearing tokens on failure + logger.error(`Token refresh failed via AuthCtr call: ${result.error}`); + throw new Error(result.error || 'Token refresh failed'); + } + } catch (error) { + // Keep the existing logic to clear tokens and require re-auth on failure + logger.error('Token refresh operation failed via AuthCtr, initiating cleanup:', error); + + // Refresh failed, clear tokens and disable remote server + logger.warn('Refresh failed, clearing tokens and disabling remote server'); + await this.remoteServerConfigCtr.clearTokens(); + await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false }); + + // Notify render process that re-authorization is required + this.broadcastAuthorizationRequired(); + + return { error: error.message, success: false }; + } + } + + /** + * Register custom protocol handler + */ + private registerProtocolHandler() { + logger.info(`Registering custom protocol handler ${protocolPrefix}://`); + app.setAsDefaultProtocolClient(protocolPrefix); + + // Register custom protocol handler + if (process.platform === 'darwin') { + // Handle open-url event on macOS + logger.debug('Registering open-url event handler for macOS'); + app.on('open-url', (event, url) => { + event.preventDefault(); + logger.info(`Received open-url event: ${url}`); + this.handleAuthCallback(url); + }); + } else { + // Handle protocol callback via second-instance event on Windows and Linux + logger.debug('Registering second-instance event handler for Windows/Linux'); + app.on('second-instance', (event, commandLine) => { + // Find the URL from command line arguments + const url = commandLine.find((arg) => arg.startsWith(`${protocolPrefix}://`)); + if (url) { + logger.info(`Found URL from second-instance command line arguments: ${url}`); + this.handleAuthCallback(url); + } else { + logger.warn('Protocol URL not found in second-instance command line arguments'); + } + }); + } + + logger.info(`Registered ${protocolPrefix}:// custom protocol handler`); + } + + /** + * Exchange authorization code for token + */ + private async exchangeCodeForToken(code: string, codeVerifier: string) { + const remoteUrl = await this.remoteServerConfigCtr.getRemoteServerUrl(); + logger.info('Starting to exchange authorization code for token'); + try { + const tokenUrl = new URL('/oidc/token', remoteUrl); + logger.debug(`Constructed token exchange URL: ${tokenUrl.toString()}`); + + // Construct request body + const body = querystring.stringify({ + client_id: 'lobehub-desktop', + code, + code_verifier: codeVerifier, + grant_type: 'authorization_code', + redirect_uri: `${protocolPrefix}://auth/callback`, + }); + + logger.debug('Sending token exchange request'); + // Send request to get token + const response = await fetch(tokenUrl.toString(), { + body, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + method: 'POST', + }); + + if (!response.ok) { + // Try parsing the error response + const errorData = await response.json().catch(() => ({})); + const errorMessage = `Failed to get token: ${response.status} ${response.statusText} ${errorData.error_description || errorData.error || ''}`; + logger.error(errorMessage); + throw new Error(errorMessage); + } + + // Parse response + const data = await response.json(); + logger.debug('Successfully received token exchange response'); + // console.log(data); // Keep original log for debugging, or remove/change to logger.debug as needed + + // Ensure response contains necessary fields + if (!data.access_token || !data.refresh_token) { + logger.error('Invalid token response: missing access_token or refresh_token'); + throw new Error('Invalid token response: missing required fields'); + } + + // Save tokens + logger.debug('Starting to save exchanged tokens'); + await this.remoteServerConfigCtr.saveTokens(data.access_token, data.refresh_token); + logger.info('Successfully saved exchanged tokens'); + + // Set server to active state + logger.debug(`Setting remote server to active state: ${remoteUrl}`); + await this.remoteServerConfigCtr.setRemoteServerConfig({ active: true }); + + return { success: true }; + } catch (error) { + logger.error('Exchanging authorization code failed:', error); + return { error: error.message, success: false }; + } + } + + /** + * Broadcast token refreshed event + */ + private broadcastTokenRefreshed() { + logger.debug('Broadcasting tokenRefreshed event to all windows'); + const allWindows = BrowserWindow.getAllWindows(); + + for (const win of allWindows) { + if (!win.isDestroyed()) { + win.webContents.send('tokenRefreshed'); + } + } + } + + /** + * Broadcast authorization successful event + */ + private broadcastAuthorizationSuccessful() { + logger.debug('Broadcasting authorizationSuccessful event to all windows'); + const allWindows = BrowserWindow.getAllWindows(); + + for (const win of allWindows) { + if (!win.isDestroyed()) { + win.webContents.send('authorizationSuccessful'); + } + } + } + + /** + * Broadcast authorization failed event + */ + private broadcastAuthorizationFailed(error: string) { + logger.debug(`Broadcasting authorizationFailed event to all windows, error: ${error}`); + const allWindows = BrowserWindow.getAllWindows(); + + for (const win of allWindows) { + if (!win.isDestroyed()) { + win.webContents.send('authorizationFailed', { error }); + } + } + } + + /** + * Broadcast authorization required event + */ + private broadcastAuthorizationRequired() { + logger.debug('Broadcasting authorizationRequired event to all windows'); + const allWindows = BrowserWindow.getAllWindows(); + + for (const win of allWindows) { + if (!win.isDestroyed()) { + win.webContents.send('authorizationRequired'); + } + } + } + + /** + * Generate PKCE codeVerifier + */ + private generateCodeVerifier(): string { + logger.debug('Generating PKCE code verifier'); + // Generate a random string of at least 43 characters + const verifier = crypto + .randomBytes(32) + .toString('base64') + .replaceAll('+', '-') + .replaceAll('/', '_') + .replace(/=+$/, ''); + logger.debug('Generated code verifier (partial): ' + verifier.slice(0, 10) + '...'); // Avoid logging full sensitive info + return verifier; + } + + /** + * Generate codeChallenge from codeVerifier (S256 method) + */ + private async generateCodeChallenge(codeVerifier: string): Promise { + logger.debug('Generating PKCE code challenge (S256)'); + // Hash codeVerifier using SHA-256 + const encoder = new TextEncoder(); + const data = encoder.encode(codeVerifier); + const digest = await crypto.subtle.digest('SHA-256', data); + + // Convert hash result to base64url encoding + const challenge = Buffer.from(digest) + .toString('base64') + .replaceAll('+', '-') + .replaceAll('/', '_') + .replace(/=+$/, ''); + logger.debug('Generated code challenge (partial): ' + challenge.slice(0, 10) + '...'); // Avoid logging full sensitive info + return challenge; + } +} diff --git a/apps/desktop/src/main/controllers/BrowserWindowsCtr.ts b/apps/desktop/src/main/controllers/BrowserWindowsCtr.ts new file mode 100644 index 0000000000..27297d78e2 --- /dev/null +++ b/apps/desktop/src/main/controllers/BrowserWindowsCtr.ts @@ -0,0 +1,95 @@ +import { InterceptRouteParams } from '@lobechat/electron-client-ipc'; +import { extractSubPath, findMatchingRoute } from '~common/routes'; + +import { AppBrowsersIdentifiers, BrowsersIdentifiers } from '@/appBrowsers'; + +import { ControllerModule, ipcClientEvent, shortcut } from './index'; + +export default class BrowserWindowsCtr extends ControllerModule { + @shortcut('toggleMainWindow') + async toggleMainWindow() { + const mainWindow = this.app.browserManager.getMainWindow(); + mainWindow.toggleVisible(); + } + + @ipcClientEvent('openSettingsWindow') + async openSettingsWindow(tab?: string) { + console.log('[BrowserWindowsCtr] Received request to open settings window', tab); + + try { + await this.app.browserManager.showSettingsWindowWithTab(tab); + + return { success: true }; + } catch (error) { + console.error('[BrowserWindowsCtr] Failed to open settings window:', error); + return { error: error.message, success: false }; + } + } + + /** + * Handle route interception requests + * Responsible for handling route interception requests from the renderer process + */ + @ipcClientEvent('interceptRoute') + async interceptRoute(params: InterceptRouteParams) { + const { path, source } = params; + console.log( + `[BrowserWindowsCtr] Received route interception request: ${path}, source: ${source}`, + ); + + // Find matching route configuration + const matchedRoute = findMatchingRoute(path); + + // If no matching route found, return not intercepted + if (!matchedRoute) { + console.log(`[BrowserWindowsCtr] No matching route configuration found: ${path}`); + return { intercepted: false, path, source }; + } + + console.log( + `[BrowserWindowsCtr] Intercepted route: ${path}, target window: ${matchedRoute.targetWindow}`, + ); + + try { + if (matchedRoute.targetWindow === BrowsersIdentifiers.settings) { + const subPath = extractSubPath(path, matchedRoute.pathPrefix); + + await this.app.browserManager.showSettingsWindowWithTab(subPath); + + return { + intercepted: true, + path, + source, + subPath, + targetWindow: matchedRoute.targetWindow, + }; + } else { + await this.openTargetWindow(matchedRoute.targetWindow as AppBrowsersIdentifiers); + + return { + intercepted: true, + path, + source, + targetWindow: matchedRoute.targetWindow, + }; + } + } catch (error) { + console.error('[BrowserWindowsCtr] Error while processing route interception:', error); + return { + error: error.message, + intercepted: false, + path, + source, + }; + } + } + + /** + * Open target window and navigate to specified sub-path + */ + private async openTargetWindow(targetWindow: AppBrowsersIdentifiers) { + // Ensure the window can always be created or reopened + const browser = this.app.browserManager.retrieveByIdentifier(targetWindow); + browser.show(); + } +} diff --git a/apps/desktop/src/main/controllers/DevtoolsCtr.ts b/apps/desktop/src/main/controllers/DevtoolsCtr.ts new file mode 100644 index 0000000000..add75b04d9 --- /dev/null +++ b/apps/desktop/src/main/controllers/DevtoolsCtr.ts @@ -0,0 +1,9 @@ +import { ControllerModule, ipcClientEvent } from './index'; + +export default class DevtoolsCtr extends ControllerModule { + @ipcClientEvent('openDevtools') + async openDevtools() { + const devtoolsBrowser = this.app.browserManager.retrieveByIdentifier('devtools'); + devtoolsBrowser.show(); + } +} diff --git a/apps/desktop/src/main/controllers/LocalFileCtr.ts b/apps/desktop/src/main/controllers/LocalFileCtr.ts new file mode 100644 index 0000000000..8939ed230d --- /dev/null +++ b/apps/desktop/src/main/controllers/LocalFileCtr.ts @@ -0,0 +1,380 @@ +import { + ListLocalFileParams, + LocalMoveFilesResultItem, + LocalReadFileParams, + LocalReadFileResult, + LocalReadFilesParams, + LocalSearchFilesParams, + MoveLocalFilesParams, + OpenLocalFileParams, + OpenLocalFolderParams, + RenameLocalFileResult, +} from '@lobechat/electron-client-ipc'; +import { SYSTEM_FILES_TO_IGNORE, loadFile } from '@lobechat/file-loaders'; +import { shell } from 'electron'; +import * as fs from 'node:fs'; +import { rename as renamePromise } from 'node:fs/promises'; +import * as path from 'node:path'; +import { promisify } from 'node:util'; + +import FileSearchService from '@/services/fileSearchSrv'; +import { FileResult, SearchOptions } from '@/types/fileSearch'; +import { makeSureDirExist } from '@/utils/file-system'; + +import { ControllerModule, ipcClientEvent } from './index'; + +const statPromise = promisify(fs.stat); +const readdirPromise = promisify(fs.readdir); +const renamePromiseFs = promisify(fs.rename); +const accessPromise = promisify(fs.access); + +export default class LocalFileCtr extends ControllerModule { + private get searchService() { + return this.app.getService(FileSearchService); + } + + /** + * Handle IPC event for local file search + */ + @ipcClientEvent('searchLocalFiles') + async handleLocalFilesSearch(params: LocalSearchFilesParams): Promise { + const options: Omit = { + limit: 30, + }; + + return this.searchService.search(params.keywords, options); + } + + @ipcClientEvent('openLocalFile') + async handleOpenLocalFile({ path: filePath }: OpenLocalFileParams): Promise<{ + error?: string; + success: boolean; + }> { + try { + await shell.openPath(filePath); + return { success: true }; + } catch (error) { + console.error(`Failed to open file ${filePath}:`, error); + return { error: (error as Error).message, success: false }; + } + } + + @ipcClientEvent('openLocalFolder') + async handleOpenLocalFolder({ path: targetPath, isDirectory }: OpenLocalFolderParams): Promise<{ + error?: string; + success: boolean; + }> { + try { + const folderPath = isDirectory ? targetPath : path.dirname(targetPath); + await shell.openPath(folderPath); + return { success: true }; + } catch (error) { + console.error(`Failed to open folder for path ${targetPath}:`, error); + return { error: (error as Error).message, success: false }; + } + } + + @ipcClientEvent('readLocalFiles') + async readFiles({ paths }: LocalReadFilesParams): Promise { + const results: LocalReadFileResult[] = []; + + for (const filePath of paths) { + // 初始化结果对象 + const result = await this.readFile({ path: filePath }); + + results.push(result); + } + + return results; + } + + @ipcClientEvent('readLocalFile') + async readFile({ path: filePath, loc }: LocalReadFileParams): Promise { + try { + const effectiveLoc = loc ?? [0, 200]; + + const fileDocument = await loadFile(filePath); + + const [startLine, endLine] = effectiveLoc; + const lines = fileDocument.content.split('\n'); + const totalLineCount = lines.length; + const totalCharCount = fileDocument.content.length; + + // Adjust slice indices to be 0-based and inclusive/exclusive + const selectedLines = lines.slice(startLine, endLine); + const content = selectedLines.join('\n'); + const charCount = content.length; + const lineCount = selectedLines.length; + + const result: LocalReadFileResult = { + // Char count for the selected range + charCount, + // Content for the selected range + content, + createdTime: fileDocument.createdTime, + fileType: fileDocument.fileType, + filename: fileDocument.filename, + lineCount, + loc: effectiveLoc, + // Line count for the selected range + modifiedTime: fileDocument.modifiedTime, + + // Total char count of the file + totalCharCount, + // Total line count of the file + totalLineCount, + }; + + try { + const stats = await statPromise(filePath); + if (stats.isDirectory()) { + result.content = 'This is a directory and cannot be read as plain text.'; + result.charCount = 0; + result.lineCount = 0; + // Keep total counts for directory as 0 as well, or decide if they should reflect metadata size + result.totalCharCount = 0; + result.totalLineCount = 0; + } + } catch (statError) { + console.error(`Stat failed for ${filePath} after loadFile:`, statError); + } + + return result; + } catch (error) { + console.error(`Error processing file ${filePath}:`, error); + const errorMessage = (error as Error).message; + return { + charCount: 0, + content: `Error accessing or processing file: ${errorMessage}`, + createdTime: new Date(), + fileType: path.extname(filePath).toLowerCase().replace('.', '') || 'unknown', + filename: path.basename(filePath), + lineCount: 0, + loc: [0, 0], + modifiedTime: new Date(), + totalCharCount: 0, // Add total counts to error result + totalLineCount: 0, + }; + } + } + + @ipcClientEvent('listLocalFiles') + async listLocalFiles({ path: dirPath }: ListLocalFileParams): Promise { + const results: FileResult[] = []; + try { + const entries = await readdirPromise(dirPath); + + for (const entry of entries) { + // Skip specific system files based on the ignore list + if (SYSTEM_FILES_TO_IGNORE.includes(entry)) { + continue; + } + + const fullPath = path.join(dirPath, entry); + try { + const stats = await statPromise(fullPath); + const isDirectory = stats.isDirectory(); + results.push({ + createdTime: stats.birthtime, + isDirectory, + lastAccessTime: stats.atime, + modifiedTime: stats.mtime, + name: entry, + path: fullPath, + size: stats.size, + type: isDirectory ? 'directory' : path.extname(entry).toLowerCase().replace('.', ''), + }); + } catch (statError) { + // Silently ignore files we can't stat (e.g. permissions) + console.error(`Failed to stat ${fullPath}:`, statError); + } + } + + // Sort entries: folders first, then by name + results.sort((a, b) => { + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1; // Directories first + } + // Add null/undefined checks for robustness if needed, though names should exist + return (a.name || '').localeCompare(b.name || ''); // Then sort by name + }); + + return results; + } catch (error) { + console.error(`Failed to list directory ${dirPath}:`, error); + // Rethrow or return an empty array/error object depending on desired behavior + // For now, returning empty array on error listing directory itself + return []; + } + } + + @ipcClientEvent('moveLocalFiles') + async handleMoveFiles({ items }: MoveLocalFilesParams): Promise { + const results: LocalMoveFilesResultItem[] = []; + + if (!items || items.length === 0) { + console.warn('moveLocalFiles called with empty items array.'); + return []; + } + + // 逐个处理移动请求 + for (const item of items) { + const { oldPath: sourcePath, newPath } = item; + const resultItem: LocalMoveFilesResultItem = { + newPath: undefined, + sourcePath, + success: false, + }; + + // 基本验证 + if (!sourcePath || !newPath) { + resultItem.error = 'Both oldPath and newPath are required for each item.'; + results.push(resultItem); + continue; + } + + try { + // 检查源是否存在 + try { + await accessPromise(sourcePath, fs.constants.F_OK); + } catch (accessError: any) { + if (accessError.code === 'ENOENT') { + throw new Error(`Source path not found: ${sourcePath}`); + } else { + throw new Error( + `Permission denied accessing source path: ${sourcePath}. ${accessError.message}`, + ); + } + } + + // 检查目标路径是否与源路径相同 + if (path.normalize(sourcePath) === path.normalize(newPath)) { + console.log(`Skipping move: source and target path are identical: ${sourcePath}`); + resultItem.success = true; + resultItem.newPath = newPath; // 即使未移动,也报告目标路径 + results.push(resultItem); + continue; + } + + // LBYL: 确保目标目录存在 + const targetDir = path.dirname(newPath); + makeSureDirExist(targetDir); + + // 执行移动 (rename) + await renamePromiseFs(sourcePath, newPath); + resultItem.success = true; + resultItem.newPath = newPath; + console.log(`Successfully moved ${sourcePath} to ${newPath}`); + } catch (error) { + console.error(`Error moving ${sourcePath} to ${newPath}:`, error); + // 使用与 handleMoveFile 类似的错误处理逻辑 + let errorMessage = (error as Error).message; + if ((error as any).code === 'ENOENT') + errorMessage = `Source path not found: ${sourcePath}.`; + else if ((error as any).code === 'EPERM' || (error as any).code === 'EACCES') + errorMessage = `Permission denied to move the item at ${sourcePath}. Check file/folder permissions.`; + else if ((error as any).code === 'EBUSY') + errorMessage = `The file or directory at ${sourcePath} or ${newPath} is busy or locked by another process.`; + else if ((error as any).code === 'EXDEV') + errorMessage = `Cannot move across different file systems or drives. Source: ${sourcePath}, Target: ${newPath}.`; + else if ((error as any).code === 'EISDIR') + errorMessage = `Cannot overwrite a directory with a file, or vice versa. Source: ${sourcePath}, Target: ${newPath}.`; + else if ((error as any).code === 'ENOTEMPTY') + errorMessage = `The target directory ${newPath} is not empty (relevant on some systems if target exists and is a directory).`; + else if ((error as any).code === 'EEXIST') + errorMessage = `An item already exists at the target path: ${newPath}.`; + // 保留来自访问检查或目录检查的更具体错误 + else if ( + !errorMessage.startsWith('Source path not found') && + !errorMessage.startsWith('Permission denied accessing source path') && + !errorMessage.includes('Target directory') + ) { + // Keep the original error message if none of the specific codes match + } + resultItem.error = errorMessage; + } + results.push(resultItem); + } + + return results; + } + + @ipcClientEvent('renameLocalFile') + async handleRenameFile({ + path: currentPath, + newName, + }: { + newName: string; + path: string; + }): Promise { + // Basic validation (can also be done in frontend action) + if (!currentPath || !newName) { + return { error: 'Both path and newName are required.', newPath: '', success: false }; + } + // Prevent path traversal or using invalid characters/names + if ( + newName.includes('/') || + newName.includes('\\') || + newName === '.' || + newName === '..' || + /["*/:<>?\\|]/.test(newName) // Check for typical invalid filename characters + ) { + return { + error: + 'Invalid new name. It cannot contain path separators (/, \\), be "." or "..", or include characters like < > : " / \\ | ? *.', + newPath: '', + success: false, + }; + } + + let newPath: string; + try { + const dir = path.dirname(currentPath); + newPath = path.join(dir, newName); + + // Check if paths are identical after calculation + if (path.normalize(currentPath) === path.normalize(newPath)) { + console.log( + `Skipping rename: oldPath and calculated newPath are identical: ${currentPath}`, + ); + // Consider success as no change is needed, but maybe inform the user? + // Return success for now. + return { newPath, success: true }; + } + } catch (error) { + console.error(`Error calculating new path for rename ${currentPath} to ${newName}:`, error); + return { + error: `Internal error calculating the new path: ${(error as Error).message}`, + newPath: '', + success: false, + }; + } + + // Perform the rename operation using fs.promises.rename directly + try { + await renamePromise(currentPath, newPath); + console.log(`Successfully renamed ${currentPath} to ${newPath}`); + // Optionally return the newPath if frontend needs it + // return { success: true, newPath: newPath }; + return { newPath, success: true }; + } catch (error) { + console.error(`Error renaming ${currentPath} to ${newPath}:`, error); + let errorMessage = (error as Error).message; + // Provide more specific error messages based on common codes + if ((error as any).code === 'ENOENT') { + errorMessage = `File or directory not found at the original path: ${currentPath}.`; + } else if ((error as any).code === 'EPERM' || (error as any).code === 'EACCES') { + errorMessage = `Permission denied to rename the item at ${currentPath}. Check file/folder permissions.`; + } else if ((error as any).code === 'EBUSY') { + errorMessage = `The file or directory at ${currentPath} or ${newPath} is busy or locked by another process.`; + } else if ((error as any).code === 'EISDIR' || (error as any).code === 'ENOTDIR') { + errorMessage = `Cannot rename - conflict between file and directory. Source: ${currentPath}, Target: ${newPath}.`; + } else if ((error as any).code === 'EEXIST') { + // Target already exists + errorMessage = `Cannot rename: an item with the name '${newName}' already exists at this location.`; + } + // Add more specific checks as needed + return { error: errorMessage, newPath: '', success: false }; + } + } +} diff --git a/apps/desktop/src/main/controllers/MenuCtr.ts b/apps/desktop/src/main/controllers/MenuCtr.ts new file mode 100644 index 0000000000..5a82f094f9 --- /dev/null +++ b/apps/desktop/src/main/controllers/MenuCtr.ts @@ -0,0 +1,29 @@ +import { ControllerModule, ipcClientEvent } from './index'; + +export default class MenuController extends ControllerModule { + /** + * 刷新菜单 + */ + @ipcClientEvent('refreshAppMenu') + refreshAppMenu() { + // 注意:可能需要根据具体情况决定是否允许渲染进程刷新所有菜单 + return this.app.menuManager.refreshMenus(); + } + + /** + * 显示上下文菜单 + */ + @ipcClientEvent('showContextMenu') + showContextMenu(type: string, data?: any) { + return this.app.menuManager.showContextMenu(type, data); + } + + /** + * 设置开发菜单可见性 + */ + @ipcClientEvent('setDevMenuVisibility') + setDevMenuVisibility(visible: boolean) { + // 调用 MenuManager 的方法来重建应用菜单 + return this.app.menuManager.rebuildAppMenu({ showDevItems: visible }); + } +} diff --git a/apps/desktop/src/main/controllers/RemoteServerConfigCtr.ts b/apps/desktop/src/main/controllers/RemoteServerConfigCtr.ts new file mode 100644 index 0000000000..8c155ca4e6 --- /dev/null +++ b/apps/desktop/src/main/controllers/RemoteServerConfigCtr.ts @@ -0,0 +1,335 @@ +import { DataSyncConfig } from '@lobechat/electron-client-ipc'; +import { safeStorage } from 'electron'; +import querystring from 'node:querystring'; +import { URL } from 'node:url'; + +import { OFFICIAL_CLOUD_SERVER } from '@/const/env'; +import { createLogger } from '@/utils/logger'; + +import { ControllerModule, ipcClientEvent } from './index'; + +// Create logger +const logger = createLogger('controllers:RemoteServerConfigCtr'); + +/** + * Remote Server Configuration Controller + * Used to manage custom remote LobeChat server configuration + */ +export default class RemoteServerConfigCtr extends ControllerModule { + /** + * Key used to store encrypted tokens in electron-store. + */ + private readonly encryptedTokensKey = 'encryptedTokens'; + + /** + * Get remote server configuration + */ + @ipcClientEvent('getRemoteServerConfig') + async getRemoteServerConfig() { + logger.debug('Getting remote server configuration'); + const { storeManager } = this.app; + + const config: DataSyncConfig = storeManager.get('dataSyncConfig'); + + logger.debug( + `Remote server config: active=${config.active}, storageMode=${config.storageMode}, url=${config.remoteServerUrl}`, + ); + + return config; + } + + /** + * Set remote server configuration + */ + @ipcClientEvent('setRemoteServerConfig') + async setRemoteServerConfig(config: Partial) { + logger.info( + `Setting remote server storageMode: active=${config.active}, storageMode=${config.storageMode}, url=${config.remoteServerUrl}`, + ); + const { storeManager } = this.app; + const prev: DataSyncConfig = storeManager.get('dataSyncConfig'); + + // Save configuration + storeManager.set('dataSyncConfig', { ...prev, ...config }); + + return true; + } + + /** + * Clear remote server configuration + */ + @ipcClientEvent('clearRemoteServerConfig') + async clearRemoteServerConfig() { + logger.info('Clearing remote server configuration'); + const { storeManager } = this.app; + + // Clear instance configuration + storeManager.set('dataSyncConfig', { storageMode: 'local' }); + + // Clear tokens (if any) + await this.clearTokens(); + + return true; + } + + /** + * Encrypted tokens + * Stored in memory for quick access, loaded from persistent storage on init. + */ + private encryptedAccessToken?: string; + private encryptedRefreshToken?: string; + + /** + * Promise representing the ongoing token refresh operation. + * Used to prevent concurrent refreshes and allow callers to wait. + */ + private refreshPromise: Promise<{ error?: string; success: boolean }> | null = null; + + /** + * Encrypt and store tokens + * @param accessToken Access token + * @param refreshToken Refresh token + */ + async saveTokens(accessToken: string, refreshToken: string) { + logger.info('Saving encrypted tokens'); + + // If platform doesn't support secure storage, store raw tokens + if (!safeStorage.isEncryptionAvailable()) { + logger.warn('Safe storage not available, storing tokens unencrypted'); + this.encryptedAccessToken = accessToken; + this.encryptedRefreshToken = refreshToken; + // Persist unencrypted tokens (consider security implications) + this.app.storeManager.set(this.encryptedTokensKey, { + accessToken: this.encryptedAccessToken, + refreshToken: this.encryptedRefreshToken, + }); + return; + } + + // Encrypt tokens + logger.debug('Encrypting tokens using safe storage'); + this.encryptedAccessToken = Buffer.from(safeStorage.encryptString(accessToken)).toString( + 'base64', + ); + + this.encryptedRefreshToken = Buffer.from(safeStorage.encryptString(refreshToken)).toString( + 'base64', + ); + + // Persist encrypted tokens + logger.debug(`Persisting encrypted tokens to store key: ${this.encryptedTokensKey}`); + this.app.storeManager.set(this.encryptedTokensKey, { + accessToken: this.encryptedAccessToken, + refreshToken: this.encryptedRefreshToken, + }); + } + + /** + * Get decrypted access token + */ + async getAccessToken(): Promise { + // Try loading from memory first + if (!this.encryptedAccessToken) { + logger.debug('Access token not in memory, trying to load from store...'); + this.loadTokensFromStore(); // Attempt to load from persistent storage + } + + if (!this.encryptedAccessToken) { + logger.debug('No access token found in memory or store.'); + return null; + } + + // If platform doesn't support secure storage, return stored token + if (!safeStorage.isEncryptionAvailable()) { + logger.debug( + 'Safe storage not available, returning potentially unencrypted token from memory/store', + ); + return this.encryptedAccessToken; + } + + try { + // Decrypt token + logger.debug('Decrypting access token'); + const encryptedData = Buffer.from(this.encryptedAccessToken, 'base64'); + return safeStorage.decryptString(encryptedData); + } catch (error) { + logger.error('Failed to decrypt access token:', error); + return null; + } + } + + /** + * Get decrypted refresh token + */ + async getRefreshToken(): Promise { + // Try loading from memory first + if (!this.encryptedRefreshToken) { + logger.debug('Refresh token not in memory, trying to load from store...'); + this.loadTokensFromStore(); // Attempt to load from persistent storage + } + + if (!this.encryptedRefreshToken) { + logger.debug('No refresh token found in memory or store.'); + return null; + } + + // If platform doesn't support secure storage, return stored token + if (!safeStorage.isEncryptionAvailable()) { + logger.debug( + 'Safe storage not available, returning potentially unencrypted token from memory/store', + ); + return this.encryptedRefreshToken; + } + + try { + // Decrypt token + logger.debug('Decrypting refresh token'); + const encryptedData = Buffer.from(this.encryptedRefreshToken, 'base64'); + return safeStorage.decryptString(encryptedData); + } catch (error) { + logger.error('Failed to decrypt refresh token:', error); + return null; + } + } + + /** + * Clear tokens + */ + async clearTokens() { + logger.info('Clearing access and refresh tokens'); + this.encryptedAccessToken = undefined; + this.encryptedRefreshToken = undefined; + // Also clear from persistent storage + logger.debug(`Deleting tokens from store key: ${this.encryptedTokensKey}`); + this.app.storeManager.delete(this.encryptedTokensKey); + } + + /** + * 刷新访问令牌 + * 使用存储的刷新令牌获取新的访问令牌 + * Handles concurrent requests by returning the existing refresh promise if one is in progress. + */ + @ipcClientEvent('refreshAccessToken') + async refreshAccessToken(): Promise<{ error?: string; success: boolean }> { + // If a refresh is already in progress, return the existing promise + if (this.refreshPromise) { + logger.debug('Token refresh already in progress, returning existing promise.'); + return this.refreshPromise; + } + + // Start a new refresh operation + logger.info('Initiating new token refresh operation.'); + this.refreshPromise = this.performTokenRefresh(); + + // Return the promise so callers can wait + return this.refreshPromise; + } + + /** + * Performs the actual token refresh logic. + * This method is called by refreshAccessToken and wrapped in a promise. + */ + private async performTokenRefresh(): Promise<{ error?: string; success: boolean }> { + try { + // 获取配置信息 + const config = await this.getRemoteServerConfig(); + + if (!config.remoteServerUrl || !config.active) { + logger.warn('Remote server not active or configured, skipping refresh.'); + return { error: '远程服务器未激活或未配置', success: false }; + } + + // 获取刷新令牌 + const refreshToken = await this.getRefreshToken(); + if (!refreshToken) { + logger.error('No refresh token available for refresh operation.'); + return { error: '没有可用的刷新令牌', success: false }; + } + + // 构造刷新请求 + const remoteUrl = await this.getRemoteServerUrl(config); + + const tokenUrl = new URL('/oidc/token', remoteUrl); + + // 构造请求体 + const body = querystring.stringify({ + client_id: 'lobehub-desktop', + grant_type: 'refresh_token', + refresh_token: refreshToken, + }); + + logger.debug(`Sending token refresh request to ${tokenUrl.toString()}`); + + // 发送请求 + const response = await fetch(tokenUrl.toString(), { + body, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + method: 'POST', + }); + + if (!response.ok) { + // 尝试解析错误响应 + const errorData = await response.json().catch(() => ({})); + const errorMessage = `刷新令牌失败: ${response.status} ${response.statusText} ${ + errorData.error_description || errorData.error || '' + }`.trim(); + logger.error(errorMessage, errorData); + return { error: errorMessage, success: false }; + } + + // 解析响应 + const data = await response.json(); + + // 检查响应中是否包含必要令牌 + if (!data.access_token || !data.refresh_token) { + logger.error('Refresh response missing access_token or refresh_token', data); + return { error: '刷新响应中缺少令牌', success: false }; + } + + // 保存新令牌 + logger.info('Token refresh successful, saving new tokens.'); + await this.saveTokens(data.access_token, data.refresh_token); + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error('Exception during token refresh operation:', errorMessage, error); + return { error: `刷新令牌时发生异常: ${errorMessage}`, success: false }; + } finally { + // Ensure the promise reference is cleared once the operation completes + logger.debug('Clearing the refresh promise reference.'); + this.refreshPromise = null; + } + } + + /** + * Load encrypted tokens from persistent storage (electron-store) into memory. + * This should be called during initialization or if memory tokens are missing. + */ + private loadTokensFromStore() { + logger.debug(`Attempting to load tokens from store key: ${this.encryptedTokensKey}`); + const storedTokens = this.app.storeManager.get(this.encryptedTokensKey); + + if (storedTokens && storedTokens.accessToken && storedTokens.refreshToken) { + logger.info('Successfully loaded tokens from store into memory.'); + this.encryptedAccessToken = storedTokens.accessToken; + this.encryptedRefreshToken = storedTokens.refreshToken; + } else { + logger.debug('No valid tokens found in store.'); + } + } + + // Initialize by loading tokens from store when the controller is ready + // We might need a dedicated lifecycle method if constructor is too early for storeManager + afterAppReady() { + this.loadTokensFromStore(); + } + + async getRemoteServerUrl(config?: DataSyncConfig) { + const dataConfig = config ? config : await this.getRemoteServerConfig(); + + return dataConfig.storageMode === 'cloud' ? OFFICIAL_CLOUD_SERVER : dataConfig.remoteServerUrl; + } +} diff --git a/apps/desktop/src/main/controllers/RemoteServerSyncCtr.ts b/apps/desktop/src/main/controllers/RemoteServerSyncCtr.ts new file mode 100644 index 0000000000..7f55d0421b --- /dev/null +++ b/apps/desktop/src/main/controllers/RemoteServerSyncCtr.ts @@ -0,0 +1,321 @@ +import { + ProxyTRPCRequestParams, + ProxyTRPCRequestResult, +} from '@lobechat/electron-client-ipc/src/types/proxyTRPCRequest'; +import { Buffer } from 'node:buffer'; +import http, { IncomingMessage, OutgoingHttpHeaders } from 'node:http'; +import https from 'node:https'; +import { URL } from 'node:url'; + +import { createLogger } from '@/utils/logger'; + +import RemoteServerConfigCtr from './RemoteServerConfigCtr'; +import { ControllerModule, ipcClientEvent } from './index'; + +// Create logger +const logger = createLogger('controllers:RemoteServerSyncCtr'); + +/** + * Remote Server Sync Controller + * For handling data synchronization with remote servers via IPC. + */ +export default class RemoteServerSyncCtr extends ControllerModule { + /** + * Cached instance of RemoteServerConfigCtr + */ + private _remoteServerConfigCtrInstance: RemoteServerConfigCtr | null = null; + + /** + * Remote server configuration controller + */ + private get remoteServerConfigCtr() { + if (!this._remoteServerConfigCtrInstance) { + this._remoteServerConfigCtrInstance = this.app.getController(RemoteServerConfigCtr); + } + return this._remoteServerConfigCtrInstance; + } + + /** + * Controller initialization - No specific logic needed here now for request handling + */ + afterAppReady() { + logger.info('RemoteServerSyncCtr initialized (IPC based)'); + // No need to register protocol handler anymore + } + + /** + * Helper function to perform the actual request forwarding to the remote server. + * Accepts arguments from IPC and returns response details. + */ + private async forwardRequest(args: { + accessToken: string | null; + body?: string | ArrayBuffer; + headers: Record; + method: string; + remoteServerUrl: string; + urlPath: string; // Pass the base URL + }): Promise<{ + // Node headers type + body: Buffer; + headers: Record; + status: number; + statusText: string; // Return body as Buffer + }> { + const { + urlPath, + method, + headers: originalHeaders, + body: requestBody, + accessToken, + remoteServerUrl, + } = args; + + const logPrefix = `[ForwardRequest ${method} ${urlPath}]`; // Add prefix for easier correlation + + if (!accessToken) { + logger.error(`${logPrefix} No access token provided`); // Enhanced log + return { + body: Buffer.from(''), + headers: {}, + status: 401, + statusText: 'Authentication required, missing token', + }; + } + + // 1. Determine target URL and prepare request options + const targetUrl = new URL(urlPath, remoteServerUrl); // Combine base URL and path + + logger.debug(`${logPrefix} Forwarding to ${targetUrl.toString()}`); // Enhanced log + + // Prepare headers, cloning and adding Authorization + const requestHeaders: OutgoingHttpHeaders = { ...originalHeaders }; // Use OutgoingHttpHeaders + requestHeaders['Authorization'] = `Bearer ${accessToken}`; + + // Let node handle Host, Content-Length etc. Remove potentially problematic headers + delete requestHeaders['host']; + delete requestHeaders['connection']; // Often causes issues + // delete requestHeaders['content-length']; // Let node handle it based on body + + const requestOptions: https.RequestOptions | http.RequestOptions = { + // Use union type + headers: requestHeaders, + hostname: targetUrl.hostname, + method: method, + path: targetUrl.pathname + targetUrl.search, + port: targetUrl.port || (targetUrl.protocol === 'https:' ? 443 : 80), + protocol: targetUrl.protocol, + // agent: false, // Consider for keep-alive issues if they arise + }; + + const requester = targetUrl.protocol === 'https:' ? https : http; + + // 2. Make the request and capture response + return new Promise((resolve) => { + const clientReq = requester.request(requestOptions, (clientRes: IncomingMessage) => { + const chunks: Buffer[] = []; + clientRes.on('data', (chunk) => { + chunks.push(chunk); + }); + + clientRes.on('end', () => { + const responseBody = Buffer.concat(chunks); + logger.debug( + `${logPrefix} Received response from ${targetUrl.toString()}: ${clientRes.statusCode}`, + ); // Enhanced log + resolve({ + // These are IncomingHttpHeaders + body: responseBody, + + headers: clientRes.headers, + + status: clientRes.statusCode || 500, + statusText: clientRes.statusMessage || 'Unknown Status', + }); + }); + + clientRes.on('error', (error) => { + // Error during response streaming + logger.error( + `${logPrefix} Error reading response stream from ${targetUrl.toString()}:`, + error, + ); // Enhanced log + // Rejecting might be better, but we need to resolve the outer promise for proxyTRPCRequest + resolve({ + body: Buffer.from(`Error reading response stream: ${error.message}`), + headers: {}, + + status: 502, + // Bad Gateway + statusText: 'Error reading response stream', + }); + }); + }); + + clientReq.on('error', (error) => { + logger.error(`${logPrefix} Error forwarding request to ${targetUrl.toString()}:`, error); // Enhanced log + // Reject or resolve with error status for the outer promise + resolve({ + body: Buffer.from(`Error forwarding request: ${error.message}`), + headers: {}, + + status: 502, + // Bad Gateway + statusText: 'Error forwarding request', + }); + }); + + // 3. Send request body if present + if (requestBody) { + if (typeof requestBody === 'string') { + clientReq.write(requestBody, 'utf8'); // Specify encoding for strings + } else if (requestBody instanceof ArrayBuffer) { + clientReq.write(Buffer.from(requestBody)); // Convert ArrayBuffer to Buffer + } else { + // Should not happen based on type, but handle defensively + logger.warn(`${logPrefix} Unsupported request body type received:`, typeof requestBody); // Enhanced log + } + } + + clientReq.end(); // Finalize the request + }); + } + + /** + * Handles the 'proxy-trpc-request' IPC call from the renderer process. + * This method should be invoked by the ipcMain.handle setup in your main process entry point. + */ + @ipcClientEvent('proxyTRPCRequest') + public async proxyTRPCRequest(args: ProxyTRPCRequestParams): Promise { + logger.debug('Received proxyTRPCRequest IPC call:', { + headers: args.headers, + method: args.method, + urlPath: args.urlPath, // Log headers too for context + }); + + const logPrefix = `[ProxyTRPC ${args.method} ${args.urlPath}]`; // Prefix for this specific request + + try { + const config = await this.remoteServerConfigCtr.getRemoteServerConfig(); + if (!config.active || (config.storageMode === 'selfHost' && !config.remoteServerUrl)) { + logger.warn( + `${logPrefix} Remote server sync not active or configured. Rejecting proxy request.`, + ); // Enhanced log + return { + body: Buffer.from('Remote server sync not active or configured').buffer, + headers: {}, + + status: 503, + // Service Unavailable + statusText: 'Remote server sync not active or configured', // Return ArrayBuffer + }; + } + const remoteServerUrl = await this.remoteServerConfigCtr.getRemoteServerUrl(); + + // Get initial token + let token = await this.remoteServerConfigCtr.getAccessToken(); + logger.debug( + `${logPrefix} Initial token check: ${token ? 'Token exists' : 'No token found'}`, + ); // Added log + + logger.info(`${logPrefix} Attempting to forward request...`); // Added log + let response = await this.forwardRequest({ ...args, accessToken: token, remoteServerUrl }); + + // Handle 401: Refresh token and retry if necessary + if (response.status === 401) { + logger.info(`${logPrefix} Received 401 from forwarded request. Attempting token refresh.`); // Enhanced log + const refreshed = await this.refreshTokenIfNeeded(logPrefix); // Pass prefix for context + + if (refreshed) { + const newToken = await this.remoteServerConfigCtr.getAccessToken(); + if (newToken) { + logger.info(`${logPrefix} Token refreshed successfully, retrying the request.`); // Enhanced log + response = await this.forwardRequest({ + ...args, + accessToken: newToken, + remoteServerUrl, + }); + } else { + logger.error( + `${logPrefix} Token refresh reported success, but failed to retrieve new token. Keeping original 401 response.`, + ); // Enhanced log + // Keep the original 401 response + } + } else { + logger.error(`${logPrefix} Token refresh failed. Keeping original 401 response.`); // Enhanced log + // Keep the original 401 response + } + } + + // Convert headers and body to format defined in IPC event + const responseHeaders: Record = {}; + for (const [key, value] of Object.entries(response.headers)) { + if (value !== undefined) { + responseHeaders[key.toLowerCase()] = Array.isArray(value) ? value.join(', ') : value; + } + } + + // Return the final response, ensuring body is serializable (string or ArrayBuffer) + const responseBody = response.body; // Buffer + + // IMPORTANT: Check IPC limits. Large bodies might fail. Consider chunking if needed. + // Convert Buffer to ArrayBuffer for IPC + const finalBody = responseBody.buffer.slice( + responseBody.byteOffset, + responseBody.byteOffset + responseBody.byteLength, + ); + + logger.debug(`${logPrefix} Forwarding successful. Status: ${response.status}`); // Added log + return { + body: finalBody as ArrayBuffer, + headers: responseHeaders, + status: response.status, + statusText: response.statusText, // Return ArrayBuffer + }; + } catch (error) { + logger.error(`${logPrefix} Unhandled error processing proxyTRPCRequest:`, error); // Enhanced log + // Ensure a serializable error response is returned + return { + body: Buffer.from( + `Internal Server Error: ${error instanceof Error ? error.message : 'Unknown error'}`, + ).buffer, + headers: {}, + status: 500, + statusText: 'Internal Server Error during proxy', // Return ArrayBuffer + }; + } + } + + /** + * Attempts to refresh the access token by calling the RemoteServerConfigCtr. + * @returns Whether token refresh was successful + */ + private async refreshTokenIfNeeded(callerLogPrefix: string = '[RefreshToken]'): Promise { + // Added prefix parameter + const logPrefix = `${callerLogPrefix} [RefreshTrigger]`; // Updated prefix + logger.debug(`${logPrefix} Entered refreshTokenIfNeeded.`); + + try { + logger.info(`${logPrefix} Triggering refreshAccessToken in RemoteServerConfigCtr.`); + const result = await this.remoteServerConfigCtr.refreshAccessToken(); + + if (result.success) { + logger.info(`${logPrefix} refreshAccessToken call completed successfully.`); + return true; + } else { + logger.error(`${logPrefix} refreshAccessToken call failed: ${result.error}`); + return false; + } + } catch (error) { + logger.error(`${logPrefix} Exception occurred while calling refreshAccessToken:`, error); + return false; + } + } + + /** + * Clean up resources - No protocol handler to unregister anymore + */ + destroy() { + logger.info('Destroying RemoteServerSyncCtr'); + // Nothing specific to clean up here regarding request handling now + } +} diff --git a/apps/desktop/src/main/controllers/ShortcutCtr.ts b/apps/desktop/src/main/controllers/ShortcutCtr.ts new file mode 100644 index 0000000000..fccdaa6f31 --- /dev/null +++ b/apps/desktop/src/main/controllers/ShortcutCtr.ts @@ -0,0 +1,19 @@ +import { ControllerModule, ipcClientEvent } from '.'; + +export default class ShortcutController extends ControllerModule { + /** + * 获取所有快捷键配置 + */ + @ipcClientEvent('getShortcutsConfig') + getShortcutsConfig() { + return this.app.shortcutManager.getShortcutsConfig(); + } + + /** + * 更新单个快捷键配置 + */ + @ipcClientEvent('updateShortcutConfig') + updateShortcutConfig(id: string, accelerator: string): boolean { + return this.app.shortcutManager.updateShortcutConfig(id, accelerator); + } +} diff --git a/apps/desktop/src/main/controllers/SystemCtr.ts b/apps/desktop/src/main/controllers/SystemCtr.ts new file mode 100644 index 0000000000..08baee4cce --- /dev/null +++ b/apps/desktop/src/main/controllers/SystemCtr.ts @@ -0,0 +1,93 @@ +import { ElectronAppState } from '@lobechat/electron-client-ipc'; +import { app, systemPreferences } from 'electron'; +import { macOS } from 'electron-is'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import process from 'node:process'; + +import { DB_SCHEMA_HASH_FILENAME, LOCAL_DATABASE_DIR, userDataDir } from '@/const/dir'; + +import { ControllerModule, ipcClientEvent, ipcServerEvent } from './index'; + +export default class SystemController extends ControllerModule { + /** + * Handles the 'getDesktopAppState' IPC request. + * Gathers essential application and system information. + */ + @ipcClientEvent('getDesktopAppState') + async getAppState(): Promise { + const platform = process.platform; + const arch = process.arch; + + return { + // System Info + arch, + isLinux: platform === 'linux', + isMac: platform === 'darwin', + isWindows: platform === 'win32', + platform: platform as 'darwin' | 'win32' | 'linux', + userPath: { + // User Paths (ensure keys match UserPathData / DesktopAppState interface) + desktop: app.getPath('desktop'), + documents: app.getPath('documents'), + downloads: app.getPath('downloads'), + home: app.getPath('home'), + music: app.getPath('music'), + pictures: app.getPath('pictures'), + userData: app.getPath('userData'), + videos: app.getPath('videos'), + }, + }; + } + + /** + * 检查可用性 + */ + @ipcClientEvent('checkSystemAccessibility') + checkAccessibilityForMacOS() { + if (!macOS()) return; + return systemPreferences.isTrustedAccessibilityClient(true); + } + + /** + * 更新应用语言设置 + */ + @ipcClientEvent('updateLocale') + async updateLocale(locale: string) { + // 保存语言设置 + this.app.storeManager.set('locale', locale); + + // 更新i18n实例的语言 + await this.app.i18n.changeLanguage(locale === 'auto' ? app.getLocale() : locale); + + return { success: true }; + } + + @ipcServerEvent('getDatabasePath') + async getDatabasePath() { + return join(this.app.appStoragePath, LOCAL_DATABASE_DIR); + } + + @ipcServerEvent('getDatabaseSchemaHash') + async getDatabaseSchemaHash() { + try { + return readFileSync(this.DB_SCHEMA_HASH_PATH, 'utf8'); + } catch { + return undefined; + } + } + + @ipcServerEvent('getUserDataPath') + async getUserDataPath() { + return userDataDir; + } + + @ipcServerEvent('setDatabaseSchemaHash') + async setDatabaseSchemaHash(hash: string) { + writeFileSync(this.DB_SCHEMA_HASH_PATH, hash, 'utf8'); + } + + private get DB_SCHEMA_HASH_PATH() { + return join(this.app.appStoragePath, DB_SCHEMA_HASH_FILENAME); + } +} diff --git a/apps/desktop/src/main/controllers/UpdaterCtr.ts b/apps/desktop/src/main/controllers/UpdaterCtr.ts new file mode 100644 index 0000000000..481bb8da5d --- /dev/null +++ b/apps/desktop/src/main/controllers/UpdaterCtr.ts @@ -0,0 +1,43 @@ +import { createLogger } from '@/utils/logger'; + +import { ControllerModule, ipcClientEvent } from './index'; + +const logger = createLogger('controllers:UpdaterCtr'); + +export default class UpdaterCtr extends ControllerModule { + /** + * 检查更新 + */ + @ipcClientEvent('checkUpdate') + async checkForUpdates() { + logger.info('Check for updates requested'); + await this.app.updaterManager.checkForUpdates(); + } + + /** + * 下载更新 + */ + @ipcClientEvent('downloadUpdate') + async downloadUpdate() { + logger.info('Download update requested'); + await this.app.updaterManager.downloadUpdate(); + } + + /** + * 关闭应用并安装更新 + */ + @ipcClientEvent('installNow') + quitAndInstallUpdate() { + logger.info('Quit and install update requested'); + this.app.updaterManager.installNow(); + } + + /** + * 下次启动时安装更新 + */ + @ipcClientEvent('installLater') + installLater() { + logger.info('Install later requested'); + this.app.updaterManager.installLater(); + } +} diff --git a/apps/desktop/src/main/controllers/UploadFileCtr.ts b/apps/desktop/src/main/controllers/UploadFileCtr.ts new file mode 100644 index 0000000000..47a5e2e25c --- /dev/null +++ b/apps/desktop/src/main/controllers/UploadFileCtr.ts @@ -0,0 +1,34 @@ +import FileService from '@/services/fileSrv'; + +import { ControllerModule, ipcClientEvent, ipcServerEvent } from './index'; + +interface UploadFileParams { + content: ArrayBuffer; + filename: string; + hash: string; + path: string; + type: string; +} + +export default class UploadFileCtr extends ControllerModule { + private get fileService() { + return this.app.getService(FileService); + } + + @ipcClientEvent('createFile') + async uploadFile(params: UploadFileParams) { + return this.fileService.uploadFile(params); + } + + // ======== server event + + @ipcServerEvent('getStaticFilePath') + async getFileUrlById(id: string) { + return this.fileService.getFilePath(id); + } + + @ipcServerEvent('deleteFiles') + async deleteFiles(paths: string[]) { + return this.fileService.deleteFiles(paths); + } +} diff --git a/apps/desktop/src/main/controllers/_template.ts b/apps/desktop/src/main/controllers/_template.ts new file mode 100644 index 0000000000..add75b04d9 --- /dev/null +++ b/apps/desktop/src/main/controllers/_template.ts @@ -0,0 +1,9 @@ +import { ControllerModule, ipcClientEvent } from './index'; + +export default class DevtoolsCtr extends ControllerModule { + @ipcClientEvent('openDevtools') + async openDevtools() { + const devtoolsBrowser = this.app.browserManager.retrieveByIdentifier('devtools'); + devtoolsBrowser.show(); + } +} diff --git a/apps/desktop/src/main/controllers/index.ts b/apps/desktop/src/main/controllers/index.ts new file mode 100644 index 0000000000..dd421a00ff --- /dev/null +++ b/apps/desktop/src/main/controllers/index.ts @@ -0,0 +1,58 @@ +import type { ClientDispatchEvents } from '@lobechat/electron-client-ipc'; +import type { ServerDispatchEvents } from '@lobechat/electron-server-ipc'; + +import type { App } from '@/core/App'; +import { IoCContainer } from '@/core/IoCContainer'; +import { ShortcutActionType } from '@/shortcuts'; + +const ipcDecorator = + (name: string, mode: 'client' | 'server') => + (target: any, methodName: string, descriptor?: any) => { + const actions = IoCContainer.controllers.get(target.constructor) || []; + actions.push({ + methodName, + mode, + name, + }); + IoCContainer.controllers.set(target.constructor, actions); + return descriptor; + }; + +/** + * controller 用的 ipc client event 装饰器 + */ +export const ipcClientEvent = (method: keyof ClientDispatchEvents) => + ipcDecorator(method, 'client'); + +/** + * controller 用的 ipc server event 装饰器 + */ +export const ipcServerEvent = (method: keyof ServerDispatchEvents) => + ipcDecorator(method, 'server'); + +const shortcutDecorator = (name: string) => (target: any, methodName: string, descriptor?: any) => { + const actions = IoCContainer.shortcuts.get(target.constructor) || []; + actions.push({ methodName, name }); + + IoCContainer.shortcuts.set(target.constructor, actions); + + return descriptor; +}; + +/** + * shortcut inject decorator + */ +export const shortcut = (method: ShortcutActionType) => shortcutDecorator(method); + +interface IControllerModule { + afterAppReady?(): void; + app: App; + beforeAppReady?(): void; +} +export class ControllerModule implements IControllerModule { + constructor(public app: App) { + this.app = app; + } +} + +export type IControlModule = typeof ControllerModule; diff --git a/apps/desktop/src/main/core/App.ts b/apps/desktop/src/main/core/App.ts new file mode 100644 index 0000000000..d493c568b3 --- /dev/null +++ b/apps/desktop/src/main/core/App.ts @@ -0,0 +1,370 @@ +import { ElectronIPCEventHandler, ElectronIPCServer } from '@lobechat/electron-server-ipc'; +import { Session, app, ipcMain, protocol } from 'electron'; +import { macOS, windows } from 'electron-is'; +import { join } from 'node:path'; + +import { name } from '@/../../package.json'; +import { buildDir, nextStandaloneDir } from '@/const/dir'; +import { isDev } from '@/const/env'; +import { IControlModule } from '@/controllers'; +import { IServiceModule } from '@/services'; +import { createLogger } from '@/utils/logger'; +import { CustomRequestHandler, createHandler } from '@/utils/next-electron-rsc'; + +import BrowserManager from './BrowserManager'; +import { I18nManager } from './I18nManager'; +import { IoCContainer } from './IoCContainer'; +import MenuManager from './MenuManager'; +import { ShortcutManager } from './ShortcutManager'; +import { StoreManager } from './StoreManager'; +import { UpdaterManager } from './UpdaterManager'; + +const logger = createLogger('core:App'); + +export type IPCEventMap = Map; +export type ShortcutMethodMap = Map Promise>; + +type Class = new (...args: any[]) => T; + +const importAll = (r: any) => Object.values(r).map((v: any) => v.default); + +export class App { + nextServerUrl = 'http://localhost:3015'; + + browserManager: BrowserManager; + menuManager: MenuManager; + i18n: I18nManager; + storeManager: StoreManager; + updaterManager: UpdaterManager; + shortcutManager: ShortcutManager; + + /** + * whether app is in quiting + */ + isQuiting: boolean = false; + + get appStoragePath() { + const storagePath = this.storeManager.get('storagePath'); + + if (!storagePath) { + throw new Error('Storage path not found in store'); + } + + return storagePath; + } + + constructor() { + logger.info('----------------------------------------------'); + logger.info('Starting LobeHub...'); + + logger.debug('Initializing App'); + // Initialize store manager + this.storeManager = new StoreManager(this); + + // load controllers + const controllers: IControlModule[] = importAll( + (import.meta as any).glob('@/controllers/*Ctr.ts', { eager: true }), + ); + + logger.debug(`Loading ${controllers.length} controllers`); + controllers.forEach((controller) => this.addController(controller)); + + // load services + const services: IServiceModule[] = importAll( + (import.meta as any).glob('@/services/*Srv.ts', { eager: true }), + ); + + logger.debug(`Loading ${services.length} services`); + services.forEach((service) => this.addService(service)); + + this.initializeIPCEvents(); + + this.i18n = new I18nManager(this); + this.browserManager = new BrowserManager(this); + this.menuManager = new MenuManager(this); + this.updaterManager = new UpdaterManager(this); + this.shortcutManager = new ShortcutManager(this); + + // register the schema to interceptor url + // it should register before app ready + this.registerNextHandler(); + + // 统一处理 before-quit 事件 + app.on('before-quit', this.handleBeforeQuit); + + logger.info('App initialization completed'); + } + + bootstrap = async () => { + logger.info('Bootstrapping application'); + // make single instance + const isSingle = app.requestSingleInstanceLock(); + if (!isSingle) { + logger.info('Another instance is already running, exiting'); + app.exit(0); + } + + this.initDevBranding(); + + // ============== + await this.ipcServer.start(); + logger.debug('IPC server started'); + + // Initialize app + await this.makeAppReady(); + + // Initialize i18n. Note: app.getLocale() must be called after app.whenReady() to get the correct value + await this.i18n.init(); + this.menuManager.initialize(); + + // Initialize global shortcuts: globalShortcut must be called after app.whenReady() + this.shortcutManager.initialize(); + + this.browserManager.initializeBrowsers(); + + // Initialize updater manager + await this.updaterManager.initialize(); + + // Set global application exit state + this.isQuiting = false; + + app.on('window-all-closed', () => { + if (windows()) { + logger.info('All windows closed, quitting application (Windows)'); + app.quit(); + } + }); + + app.on('activate', this.onActivate); + logger.info('Application bootstrap completed'); + }; + + getService(serviceClass: Class): T { + return this.services.get(serviceClass); + } + + getController(controllerClass: Class): T { + return this.controllers.get(controllerClass); + } + + private onActivate = () => { + logger.debug('Application activated'); + this.browserManager.showMainWindow(); + }; + + /** + * Call beforeAppReady method on all controllers before the application is ready + */ + private makeAppReady = async () => { + logger.debug('Preparing application ready state'); + this.controllers.forEach((controller) => { + if (typeof controller.beforeAppReady === 'function') { + try { + controller.beforeAppReady(); + } catch (error) { + logger.error(`Error in controller.beforeAppReady:`, error); + console.error(`[App] Error in controller.beforeAppReady:`, error); + } + } + }); + + logger.debug('Waiting for app to be ready'); + await app.whenReady(); + logger.debug('Application ready'); + + this.controllers.forEach((controller) => { + if (typeof controller.afterAppReady === 'function') { + try { + controller.afterAppReady(); + } catch (error) { + logger.error(`Error in controller.afterAppReady:`, error); + console.error(`[App] Error in controller.beforeAppReady:`, error); + } + } + }); + logger.info('Application ready state completed'); + }; + + // ============= helper ============= // + + /** + * all controllers in app + */ + private controllers = new Map, any>(); + /** + * all services in app + */ + private services = new Map, any>(); + + private ipcServer: ElectronIPCServer; + /** + * events dispatched from webview layer + */ + private ipcClientEventMap: IPCEventMap = new Map(); + private ipcServerEventMap: IPCEventMap = new Map(); + shortcutMethodMap: ShortcutMethodMap = new Map(); + + /** + * use in next router interceptor in prod browser render + */ + nextInterceptor: (params: { session: Session }) => () => void; + + /** + * Collection of unregister functions for custom request handlers + */ + private customHandlerUnregisterFns: Array<() => void> = []; + + /** + * Function to register custom request handler + */ + private registerCustomHandlerFn?: (handler: CustomRequestHandler) => () => void; + + /** + * Register custom request handler + * @param handler Custom request handler function + * @returns Function to unregister the handler + */ + registerRequestHandler = (handler: CustomRequestHandler): (() => void) => { + if (!this.registerCustomHandlerFn) { + logger.warn('Custom request handler registration is not available'); + return () => {}; + } + + logger.debug('Registering custom request handler'); + const unregisterFn = this.registerCustomHandlerFn(handler); + this.customHandlerUnregisterFns.push(unregisterFn); + + return () => { + unregisterFn(); + const index = this.customHandlerUnregisterFns.indexOf(unregisterFn); + if (index !== -1) { + this.customHandlerUnregisterFns.splice(index, 1); + } + }; + }; + + /** + * Unregister all custom request handlers + */ + unregisterAllRequestHandlers = () => { + this.customHandlerUnregisterFns.forEach((unregister) => unregister()); + this.customHandlerUnregisterFns = []; + }; + + private addController = (ControllerClass: IControlModule) => { + const controller = new ControllerClass(this); + this.controllers.set(ControllerClass, controller); + + IoCContainer.controllers.get(ControllerClass)?.forEach((event) => { + if (event.mode === 'client') { + // Store all objects from event decorator in ipcClientEventMap + this.ipcClientEventMap.set(event.name, { + controller, + methodName: event.methodName, + }); + } + + if (event.mode === 'server') { + // Store all objects from event decorator in ipcServerEventMap + this.ipcServerEventMap.set(event.name, { + controller, + methodName: event.methodName, + }); + } + }); + + IoCContainer.shortcuts.get(ControllerClass)?.forEach((shortcut) => { + this.shortcutMethodMap.set(shortcut.name, async () => { + controller[shortcut.methodName](); + }); + }); + }; + + private addService = (ServiceClass: IServiceModule) => { + const service = new ServiceClass(this); + this.services.set(ServiceClass, service); + }; + + private initDevBranding = () => { + if (!isDev) return; + + logger.debug('Setting up dev branding'); + app.setName('lobehub-desktop-dev'); + if (macOS()) { + app.dock!.setIcon(join(buildDir, 'icon-dev.png')); + } + }; + + private registerNextHandler() { + logger.debug('Registering Next.js handler'); + const handler = createHandler({ + debug: true, + localhostUrl: this.nextServerUrl, + protocol, + standaloneDir: nextStandaloneDir, + }); + + // Log output based on development or production mode + if (isDev) { + logger.info( + `Development mode: Custom request handler enabled, but Next.js interception disabled`, + ); + } else { + logger.info( + `Production mode: ${this.nextServerUrl} will be intercepted to ${nextStandaloneDir}`, + ); + } + + this.nextInterceptor = handler.createInterceptor; + + // Save custom handler registration function + if (handler.registerCustomHandler) { + this.registerCustomHandlerFn = handler.registerCustomHandler; + logger.debug('Custom request handler registration is available'); + } else { + logger.warn('Custom request handler registration is not available'); + } + } + + private initializeIPCEvents() { + logger.debug('Initializing IPC events'); + // Register batch controller client events for render side consumption + this.ipcClientEventMap.forEach((eventInfo, key) => { + const { controller, methodName } = eventInfo; + + ipcMain.handle(key, async (e, ...data) => { + try { + return await controller[methodName](...data); + } catch (error) { + logger.error(`Error handling IPC event ${key}:`, error); + return { error: error.message }; + } + }); + }); + + // Batch register server events from controllers for next server consumption + const ipcServerEvents = {} as ElectronIPCEventHandler; + + this.ipcServerEventMap.forEach((eventInfo, key) => { + const { controller, methodName } = eventInfo; + + ipcServerEvents[key] = async (payload) => { + try { + return await controller[methodName](payload); + } catch (error) { + return { error: error.message }; + } + }; + }); + + this.ipcServer = new ElectronIPCServer(name, ipcServerEvents); + } + + // 新增 before-quit 处理函数 + private handleBeforeQuit = () => { + this.isQuiting = true; // 首先设置标志 + + // 执行清理操作 + this.unregisterAllRequestHandlers(); + }; +} diff --git a/apps/desktop/src/main/core/Browser.ts b/apps/desktop/src/main/core/Browser.ts new file mode 100644 index 0000000000..78dc136384 --- /dev/null +++ b/apps/desktop/src/main/core/Browser.ts @@ -0,0 +1,345 @@ +import { MainBroadcastEventKey, MainBroadcastParams } from '@lobechat/electron-client-ipc'; +import { BrowserWindow, BrowserWindowConstructorOptions, ipcMain } from 'electron'; +import { join } from 'node:path'; + +import { createLogger } from '@/utils/logger'; + +import { preloadDir, resourcesDir } from '../const/dir'; +import type { App } from './App'; + +// Create logger +const logger = createLogger('core:Browser'); + +export interface BrowserWindowOpts extends BrowserWindowConstructorOptions { + devTools?: boolean; + height?: number; + /** + * URL + */ + identifier: string; + keepAlive?: boolean; + path: string; + showOnInit?: boolean; + title?: string; + width?: number; +} + +export default class Browser { + private app: App; + + /** + * Internal electron window + */ + private _browserWindow?: BrowserWindow; + + private stopInterceptHandler; + /** + * Identifier + */ + identifier: string; + + /** + * Options at creation + */ + options: BrowserWindowOpts; + + /** + * Key for storing window state in storeManager + */ + private readonly windowStateKey: string; + + /** + * Method to expose window externally + */ + get browserWindow() { + return this.retrieveOrInitialize(); + } + + /** + * Method to construct BrowserWindows object + * @param options + * @param application + */ + constructor(options: BrowserWindowOpts, application: App) { + logger.debug(`Creating Browser instance: ${options.identifier}`); + logger.debug(`Browser options: ${JSON.stringify(options)}`); + this.app = application; + this.identifier = options.identifier; + this.options = options; + this.windowStateKey = `windowSize_${this.identifier}`; + + // Initialization + this.retrieveOrInitialize(); + } + + loadUrl = async (path: string) => { + const initUrl = this.app.nextServerUrl + path; + + try { + logger.debug(`[${this.identifier}] Attempting to load URL: ${initUrl}`); + await this._browserWindow.loadURL(initUrl); + logger.debug(`[${this.identifier}] Successfully loaded URL: ${initUrl}`); + } catch (error) { + logger.error(`[${this.identifier}] Failed to load URL (${initUrl}):`, error); + + // Try to load local error page + try { + logger.info(`[${this.identifier}] Attempting to load error page...`); + await this._browserWindow.loadFile(join(resourcesDir, 'error.html')); + logger.info(`[${this.identifier}] Error page loaded successfully.`); + + // Remove previously set retry listeners to avoid duplicates + ipcMain.removeHandler('retry-connection'); + logger.debug(`[${this.identifier}] Removed existing retry-connection handler if any.`); + + // Set retry logic + ipcMain.handle('retry-connection', async () => { + logger.info(`[${this.identifier}] Retry connection requested for: ${initUrl}`); + try { + await this._browserWindow?.loadURL(initUrl); + logger.info(`[${this.identifier}] Reconnection successful to ${initUrl}`); + return { success: true }; + } catch (err) { + logger.error(`[${this.identifier}] Retry connection failed for ${initUrl}:`, err); + // Reload error page + try { + logger.info(`[${this.identifier}] Reloading error page after failed retry...`); + await this._browserWindow?.loadFile(join(resourcesDir, 'error.html')); + logger.info(`[${this.identifier}] Error page reloaded.`); + } catch (loadErr) { + logger.error('[${this.identifier}] Failed to reload error page:', loadErr); + } + return { error: err.message, success: false }; + } + }); + logger.debug(`[${this.identifier}] Set up retry-connection handler.`); + } catch (err) { + logger.error(`[${this.identifier}] Failed to load error page:`, err); + // If even the error page can't be loaded, at least show a simple error message + try { + logger.warn(`[${this.identifier}] Attempting to load fallback error HTML string...`); + await this._browserWindow.loadURL( + 'data:text/html,

Loading Failed

Unable to connect to server, please restart the application

', + ); + logger.info(`[${this.identifier}] Fallback error HTML string loaded.`); + } catch (finalErr) { + logger.error(`[${this.identifier}] Unable to display any page:`, finalErr); + } + } + } + }; + + loadPlaceholder = async () => { + logger.debug(`[${this.identifier}] Loading splash screen placeholder`); + // First load a local HTML loading page + await this._browserWindow.loadFile(join(resourcesDir, 'splash.html')); + logger.debug(`[${this.identifier}] Splash screen placeholder loaded.`); + }; + + show() { + logger.debug(`Showing window: ${this.identifier}`); + this.browserWindow.show(); + } + + hide() { + logger.debug(`Hiding window: ${this.identifier}`); + this.browserWindow.hide(); + } + + close() { + logger.debug(`Attempting to close window: ${this.identifier}`); + this.browserWindow.close(); + } + + /** + * Destroy instance + */ + destroy() { + logger.debug(`Destroying window instance: ${this.identifier}`); + this.stopInterceptHandler?.(); + this._browserWindow = undefined; + } + + /** + * Initialize + */ + retrieveOrInitialize() { + // When there is this window and it has not been destroyed + if (this._browserWindow && !this._browserWindow.isDestroyed()) { + logger.debug(`[${this.identifier}] Returning existing BrowserWindow instance.`); + return this._browserWindow; + } + + const { path, title, width, height, devTools, showOnInit, ...res } = this.options; + + // Load window state + const savedState = this.app.storeManager.get(this.windowStateKey as any) as + | { height?: number; width?: number } + | undefined; // Keep type for now, but only use w/h + logger.info(`Creating new BrowserWindow instance: ${this.identifier}`); + logger.debug(`[${this.identifier}] Options for new window: ${JSON.stringify(this.options)}`); + logger.debug( + `[${this.identifier}] Saved window state (only size used): ${JSON.stringify(savedState)}`, + ); + + const browserWindow = new BrowserWindow({ + ...res, + + height: savedState?.height || height, + + show: false, + + // Always create hidden first + title, + + transparent: true, + + webPreferences: { + // Context isolation environment + // https://www.electronjs.org/docs/tutorial/context-isolation + contextIsolation: true, + preload: join(preloadDir, 'index.js'), + // devTools: isDev, + }, + // Use saved state if available, otherwise use options. Do not set x/y + // x: savedState?.x, // Don't restore x + // y: savedState?.y, // Don't restore y + width: savedState?.width || width, + }); + + this._browserWindow = browserWindow; + logger.debug(`[${this.identifier}] BrowserWindow instance created.`); + + logger.debug(`[${this.identifier}] Setting up nextInterceptor.`); + this.stopInterceptHandler = this.app.nextInterceptor({ + session: browserWindow.webContents.session, + }); + + // Windows 11 can use this new API + if (process.platform === 'win32' && browserWindow.setBackgroundMaterial) { + logger.debug(`[${this.identifier}] Setting window background material for Windows 11`); + browserWindow.setBackgroundMaterial('acrylic'); + } + + logger.debug(`[${this.identifier}] Initiating placeholder and URL loading sequence.`); + this.loadPlaceholder().then(() => { + this.loadUrl(path).catch((e) => { + logger.error(`[${this.identifier}] Initial loadUrl error for path '${path}':`, e); + }); + }); + + // Show devtools if enabled + if (devTools) { + logger.debug(`[${this.identifier}] Opening DevTools because devTools option is true.`); + browserWindow.webContents.openDevTools(); + } + + logger.debug(`[${this.identifier}] Setting up 'ready-to-show' event listener.`); + browserWindow.once('ready-to-show', () => { + logger.debug(`[${this.identifier}] Window 'ready-to-show' event fired.`); + if (showOnInit) { + logger.debug(`Showing window ${this.identifier} because showOnInit is true.`); + browserWindow?.show(); + } else { + logger.debug( + `Window ${this.identifier} not shown on 'ready-to-show' because showOnInit is false.`, + ); + } + }); + + logger.debug(`[${this.identifier}] Setting up 'close' event listener.`); + browserWindow.on('close', (e) => { + logger.debug(`Window 'close' event triggered for: ${this.identifier}`); + logger.debug( + `[${this.identifier}] State during close event: isQuiting=${this.app.isQuiting}, keepAlive=${this.options.keepAlive}`, + ); + + // If in application quitting process, allow window to be closed + if (this.app.isQuiting) { + logger.debug(`[${this.identifier}] App is quitting, allowing window to close naturally.`); + // Save state before quitting + try { + const { width, height } = browserWindow.getBounds(); // Get only width and height + const sizeState = { height, width }; + logger.debug( + `[${this.identifier}] Saving window size on quit: ${JSON.stringify(sizeState)}`, + ); + this.app.storeManager.set(this.windowStateKey as any, sizeState); // Save only size + } catch (error) { + logger.error(`[${this.identifier}] Failed to save window state on quit:`, error); + } + // Need to clean up intercept handler + this.stopInterceptHandler?.(); + return; + } + + // Prevent window from being destroyed, just hide it (if marked as keepAlive) + if (this.options.keepAlive) { + logger.debug( + `[${this.identifier}] keepAlive is true, preventing default close and hiding window.`, + ); + // Optionally save state when hiding if desired, but primary save is on actual close/quit + // try { + // const bounds = browserWindow.getBounds(); + // logger.debug(`[${this.identifier}] Saving window state on hide: ${JSON.stringify(bounds)}`); + // this.app.storeManager.set(this.windowStateKey, bounds); + // } catch (error) { + // logger.error(`[${this.identifier}] Failed to save window state on hide:`, error); + // } + e.preventDefault(); + browserWindow.hide(); + } else { + // Window is actually closing (not keepAlive) + logger.debug( + `[${this.identifier}] keepAlive is false, allowing window to close. Saving size...`, // Updated log message + ); + try { + const { width, height } = browserWindow.getBounds(); // Get only width and height + const sizeState = { height, width }; + logger.debug( + `[${this.identifier}] Saving window size on close: ${JSON.stringify(sizeState)}`, + ); + this.app.storeManager.set(this.windowStateKey as any, sizeState); // Save only size + } catch (error) { + logger.error(`[${this.identifier}] Failed to save window state on close:`, error); + } + // Need to clean up intercept handler + this.stopInterceptHandler?.(); + } + }); + + logger.debug(`[${this.identifier}] retrieveOrInitialize completed.`); + return browserWindow; + } + + moveToCenter() { + logger.debug(`Centering window: ${this.identifier}`); + this._browserWindow?.center(); + } + + setWindowSize(boundSize: { height?: number; width?: number }) { + logger.debug( + `Setting window size for ${this.identifier}: width=${boundSize.width}, height=${boundSize.height}`, + ); + const windowSize = this._browserWindow.getBounds(); + this._browserWindow?.setBounds({ + height: boundSize.height || windowSize.height, + width: boundSize.width || windowSize.width, + }); + } + + broadcast = (channel: T, data?: MainBroadcastParams) => { + logger.debug(`Broadcasting to window ${this.identifier}, channel: ${channel}`); + this._browserWindow.webContents.send(channel, data); + }; + + toggleVisible() { + logger.debug(`Toggling visibility for window: ${this.identifier}`); + if (this._browserWindow.isVisible() && this._browserWindow.isFocused()) { + this._browserWindow.hide(); + } else { + this._browserWindow.show(); + this._browserWindow.focus(); + } + } +} diff --git a/apps/desktop/src/main/core/BrowserManager.ts b/apps/desktop/src/main/core/BrowserManager.ts new file mode 100644 index 0000000000..5b7a0a43f4 --- /dev/null +++ b/apps/desktop/src/main/core/BrowserManager.ts @@ -0,0 +1,154 @@ +import { MainBroadcastEventKey, MainBroadcastParams } from '@lobechat/electron-client-ipc'; + +import { createLogger } from '@/utils/logger'; + +import { AppBrowsersIdentifiers, appBrowsers } from '../appBrowsers'; +import type { App } from './App'; +import type { BrowserWindowOpts } from './Browser'; +import Browser from './Browser'; + +// Create logger +const logger = createLogger('core:BrowserManager'); + +export default class BrowserManager { + app: App; + + browsers: Map = new Map(); + + constructor(app: App) { + logger.debug('Initializing BrowserManager'); + this.app = app; + } + + getMainWindow() { + return this.retrieveByIdentifier('chat'); + } + + showMainWindow() { + logger.debug('Showing main window'); + const window = this.getMainWindow(); + window.show(); + } + + showSettingsWindow() { + logger.debug('Showing settings window'); + const window = this.retrieveByIdentifier('settings'); + window.show(); + return window; + } + + broadcastToAllWindows = ( + event: T, + data: MainBroadcastParams, + ) => { + logger.debug(`Broadcasting event ${event} to all windows`); + this.browsers.forEach((browser) => { + browser.broadcast(event, data); + }); + }; + + broadcastToWindow = ( + identifier: AppBrowsersIdentifiers, + event: T, + data: MainBroadcastParams, + ) => { + logger.debug(`Broadcasting event ${event} to window: ${identifier}`); + this.browsers.get(identifier).broadcast(event, data); + }; + + /** + * Display the settings window and navigate to a specific tab + * @param tab Settings window sub-path tab + */ + async showSettingsWindowWithTab(tab?: string) { + logger.debug(`Showing settings window with tab: ${tab || 'default'}`); + // common is the main path for settings route + if (tab && tab !== 'common') { + const browser = await this.redirectToPage('settings', tab); + + // make provider page more large + if (tab.startsWith('provider/')) { + logger.debug('Resizing window for provider settings'); + browser.setWindowSize({ height: 1000, width: 1400 }); + browser.moveToCenter(); + } + + return browser; + } else { + return this.showSettingsWindow(); + } + } + + /** + * Navigate window to specific sub-path + * @param identifier Window identifier + * @param subPath Sub-path, such as 'agent', 'about', etc. + */ + async redirectToPage(identifier: AppBrowsersIdentifiers, subPath?: string) { + try { + // Ensure window is retrieved or created + const browser = this.retrieveByIdentifier(identifier); + browser.hide(); + + const baseRoute = appBrowsers[identifier].path; + + // Build complete URL path + const fullPath = subPath ? `${baseRoute}/${subPath}` : baseRoute; + + logger.debug(`Redirecting to: ${fullPath}`); + + // Load URL and show window + await browser.loadUrl(fullPath); + browser.show(); + + return browser; + } catch (error) { + logger.error(`Failed to redirect (${identifier}/${subPath}):`, error); + throw error; + } + } + + /** + * get Browser by identifier + */ + retrieveByIdentifier(identifier: AppBrowsersIdentifiers) { + const browser = this.browsers.get(identifier); + + if (browser) return browser; + + logger.debug(`Browser ${identifier} not found, initializing new instance`); + return this.retrieveOrInitialize(appBrowsers[identifier]); + } + + /** + * Initialize all browsers when app starts up + */ + initializeBrowsers() { + logger.info('Initializing all browsers'); + Object.values(appBrowsers).forEach((browser) => { + logger.debug(`Initializing browser: ${browser.identifier}`); + this.retrieveOrInitialize(browser); + }); + } + + // helper + + /** + * Retrieve existing browser or initialize a new one + * @param options Browser window options + */ + private retrieveOrInitialize(options: BrowserWindowOpts) { + let browser = this.browsers.get(options.identifier as AppBrowsersIdentifiers); + if (browser) { + logger.debug(`Retrieved existing browser: ${options.identifier}`); + return browser; + } + + logger.debug(`Creating new browser: ${options.identifier}`); + browser = new Browser(options, this.app); + + this.browsers.set(options.identifier as AppBrowsersIdentifiers, browser); + + return browser; + } +} diff --git a/apps/desktop/src/main/core/I18nManager.ts b/apps/desktop/src/main/core/I18nManager.ts new file mode 100644 index 0000000000..455945b0bc --- /dev/null +++ b/apps/desktop/src/main/core/I18nManager.ts @@ -0,0 +1,185 @@ +import { app } from 'electron'; +import i18next from 'i18next'; + +import { App } from '@/core/App'; +import { loadResources } from '@/locales/resources'; +import { createLogger } from '@/utils/logger'; + +// Create logger +const logger = createLogger('core:I18nManager'); + +export class I18nManager { + private i18n: typeof i18next; + private initialized: boolean = false; + private app: App; + + constructor(app: App) { + logger.debug('Initializing I18nManager'); + this.app = app; + this.i18n = i18next.createInstance(); + } + + /** + * Initialize i18next instance + */ + async init(lang?: string) { + if (this.initialized) { + logger.debug('I18nManager already initialized, skipping'); + return this.i18n; + } + + // Priority: parameter language > stored locale > system language + const storedLocale = this.app.storeManager.get('locale', 'auto') as string; + const defaultLanguage = + lang || (storedLocale !== 'auto' ? storedLocale : app.getLocale()) || 'en-US'; + + logger.info( + `Initializing i18n, app locale: ${defaultLanguage}, stored locale: ${storedLocale}`, + ); + + await this.i18n.init({ + defaultNS: 'menu', + fallbackLng: 'en-US', + // Load resources as needed + initAsync: true, + interpolation: { + escapeValue: false, + }, + + lng: defaultLanguage, + + ns: ['menu', 'dialog', 'common'], + partialBundledLanguages: true, + }); + + logger.info(`i18n initialized, language: ${this.i18n.language}`); + + // Preload base namespaces + await this.loadLocale(this.i18n.language); + + this.initialized = true; + + this.refreshMainUI(); + + // Listen for language change events + this.i18n.on('languageChanged', this.handleLanguageChanged); + + return this.i18n; + } + + /** + * Basic translation function + */ + t = (key: string, options?: any) => { + const result = this.i18n.t(key, options) as string; + + // If translation result is the same as key, translation might be missing + if (result === key) { + logger.warn(`${this.i18n.language} key: ${key} is not found`); + } + + return result; + }; + + /** + * Create a translation function bound to a specific namespace + * @param namespace Namespace + * @returns Translation function bound to namespace + */ + createNamespacedT(namespace: string) { + return (key: string, options: any = {}) => { + // Copy options to avoid modifying the original object + const mergedOptions = { ...options }; + // Set namespace + mergedOptions.ns = namespace; + + return this.t(key, mergedOptions); + }; + } + + /** + * Get translation function by namespace + * Provides a more convenient calling method + */ + ns = (namespace: string) => this.createNamespacedT(namespace); + + /** + * Get current language + */ + getCurrentLanguage() { + return this.i18n.language; + } + + /** + * Change application language + * @param lng Target language + */ + public async changeLanguage(lng: string): Promise { + logger.info(`Changing language to: ${lng}`); + + if (!this.initialized) { + await this.init(); + } + + await this.i18n.changeLanguage(lng); + // Language change event will trigger handleLanguageChanged + } + + /** + * Handle language change event + */ + private handleLanguageChanged = async (lang: string) => { + logger.info(`Language changed to: ${lang}`); + await this.loadLocale(lang); + + // Notify other parts of main process to refresh UI + this.refreshMainUI(); + }; + + /** + * Refresh main process UI (menus, etc.) + */ + private refreshMainUI() { + logger.debug('Refreshing main UI after language change'); + this.app.menuManager.refreshMenus(); + } + + /** + * Notify renderer process that language has changed + */ + private notifyRendererProcess(lng: string) { + logger.debug(`Notifying renderer process of language change: ${lng}`); + + // Send language change event to all windows + // const windows = this.app.browserManager.windows; + // + // if (windows && windows.length > 0) { + // windows.forEach((window) => { + // if (window?.webContents) { + // window.webContents.send('language-changed', lng); + // } + // }); + // } + } + + private async loadLocale(language: string) { + logger.debug(`Loading locale for language: ${language}`); + // Preload base namespaces + await Promise.all(['menu', 'dialog', 'common'].map((ns) => this.loadNamespace(language, ns))); + } + + /** + * Load translation resources for specific namespace + */ + private async loadNamespace(lng: string, ns: string) { + try { + logger.debug(`Loading namespace: ${lng}/${ns}`); + const resources = await loadResources(lng, ns); + this.i18n.addResourceBundle(lng, ns, resources, true, true); + return true; + } catch (error) { + logger.error(`Failed to load namespace: ${lng}/${ns}`, error); + return false; + } + } +} diff --git a/apps/desktop/src/main/core/IoCContainer.ts b/apps/desktop/src/main/core/IoCContainer.ts new file mode 100644 index 0000000000..361623fffc --- /dev/null +++ b/apps/desktop/src/main/core/IoCContainer.ts @@ -0,0 +1,12 @@ +/** + * 存储应用中需要用装饰器的类 + */ +export class IoCContainer { + static controllers: WeakMap< + any, + { methodName: string; mode: 'client' | 'server'; name: string }[] + > = new WeakMap(); + + static shortcuts: WeakMap = new WeakMap(); + init() {} +} diff --git a/apps/desktop/src/main/core/MenuManager.ts b/apps/desktop/src/main/core/MenuManager.ts new file mode 100644 index 0000000000..0421695c9f --- /dev/null +++ b/apps/desktop/src/main/core/MenuManager.ts @@ -0,0 +1,64 @@ +import { Menu } from 'electron'; + +import { IMenuPlatform, MenuOptions, createMenuImpl } from '@/menus'; +import { createLogger } from '@/utils/logger'; + +import type { App } from './App'; + +// Create logger +const logger = createLogger('core:MenuManager'); + +export default class MenuManager { + app: App; + private platformImpl: IMenuPlatform; + + constructor(app: App) { + logger.debug('Initializing MenuManager'); + this.app = app; + this.platformImpl = createMenuImpl(app); + } + + /** + * Initialize menus (mainly application menu) + */ + initialize(options?: MenuOptions) { + logger.info('Initializing application menu'); + this.platformImpl.buildAndSetAppMenu(options); + } + + /** + * Build and show context menu + */ + showContextMenu(type: string, data?: any) { + logger.debug(`Showing context menu of type: ${type}`); + const menu = this.platformImpl.buildContextMenu(type, data); + menu.popup(); // popup must be called in main process + return { success: true }; + } + + /** + * Build tray menu (usually called by tray manager) + */ + buildTrayMenu(): Menu { + logger.debug('Building tray menu'); + return this.platformImpl.buildTrayMenu(); + } + + /** + * Refresh menus + */ + refreshMenus(options?: MenuOptions) { + logger.debug('Refreshing all menus'); + this.platformImpl.refresh(options); + return { success: true }; + } + + /** + * Rebuild and set application menu (e.g., when toggling dev menu visibility) + */ + rebuildAppMenu(options?: MenuOptions) { + logger.debug('Rebuilding application menu'); + this.platformImpl.buildAndSetAppMenu(options); + return { success: true }; + } +} diff --git a/apps/desktop/src/main/core/ShortcutManager.ts b/apps/desktop/src/main/core/ShortcutManager.ts new file mode 100644 index 0000000000..6d758a957f --- /dev/null +++ b/apps/desktop/src/main/core/ShortcutManager.ts @@ -0,0 +1,173 @@ +import { globalShortcut } from 'electron'; + +import { DEFAULT_SHORTCUTS_CONFIG } from '@/shortcuts'; +import { createLogger } from '@/utils/logger'; + +import type { App } from './App'; + +// Create logger +const logger = createLogger('core:ShortcutManager'); + +export class ShortcutManager { + private app: App; + private shortcuts: Map void> = new Map(); + private shortcutsConfig: Record = {}; + + constructor(app: App) { + logger.debug('Initializing ShortcutManager'); + this.app = app; + + app.shortcutMethodMap.forEach((method, key) => { + this.shortcuts.set(key, method); + }); + } + + initialize() { + logger.info('Initializing global shortcuts'); + // Load shortcuts configuration from storage + this.loadShortcutsConfig(); + // Register configured shortcuts + this.registerConfiguredShortcuts(); + } + + /** + * Get shortcuts configuration + */ + getShortcutsConfig(): Record { + return this.shortcutsConfig; + } + + /** + * Update a single shortcut configuration + */ + updateShortcutConfig(id: string, accelerator: string): boolean { + try { + logger.debug(`Updating shortcut ${id} to ${accelerator}`); + // Update configuration + this.shortcutsConfig[id] = accelerator; + + this.saveShortcutsConfig(); + this.registerConfiguredShortcuts(); + return true; + } catch (error) { + logger.error(`Error updating shortcut ${id}:`, error); + return false; + } + } + + /** + * Register global shortcut + * @param accelerator Shortcut key combination + * @param callback Callback function + * @returns Whether registration was successful + */ + registerShortcut(accelerator: string, callback: () => void): boolean { + try { + // If already registered, unregister first + if (this.shortcuts.has(accelerator)) { + this.unregisterShortcut(accelerator); + } + + // Register new shortcut + const success = globalShortcut.register(accelerator, callback); + + if (success) { + this.shortcuts.set(accelerator, callback); + logger.debug(`Registered shortcut: ${accelerator}`); + } else { + logger.error(`Failed to register shortcut: ${accelerator}`); + } + + return success; + } catch (error) { + logger.error(`Error registering shortcut: ${accelerator}`, error); + return false; + } + } + + /** + * Unregister global shortcut + * @param accelerator Shortcut key combination + */ + unregisterShortcut(accelerator: string): void { + try { + globalShortcut.unregister(accelerator); + this.shortcuts.delete(accelerator); + logger.debug(`Unregistered shortcut: ${accelerator}`); + } catch (error) { + logger.error(`Error unregistering shortcut: ${accelerator}`, error); + } + } + + /** + * Check if a shortcut is already registered + * @param accelerator Shortcut key combination + * @returns Whether it is registered + */ + isRegistered(accelerator: string): boolean { + return globalShortcut.isRegistered(accelerator); + } + + /** + * Unregister all shortcuts + */ + unregisterAll(): void { + globalShortcut.unregisterAll(); + logger.info('Unregistered all shortcuts'); + } + + /** + * Load shortcuts configuration from storage + */ + private loadShortcutsConfig() { + try { + // Try to get configuration from storage + const config = this.app.storeManager.get('shortcuts'); + + // If no configuration, use default configuration + if (!config || Object.keys(config).length === 0) { + logger.debug('No shortcuts config found, using defaults'); + this.shortcutsConfig = DEFAULT_SHORTCUTS_CONFIG; + this.saveShortcutsConfig(); + } else { + this.shortcutsConfig = config; + } + + logger.debug('Loaded shortcuts config:', this.shortcutsConfig); + } catch (error) { + logger.error('Error loading shortcuts config:', error); + this.shortcutsConfig = DEFAULT_SHORTCUTS_CONFIG; + this.saveShortcutsConfig(); + } + } + + /** + * Save shortcuts configuration to storage + */ + private saveShortcutsConfig() { + try { + this.app.storeManager.set('shortcuts', this.shortcutsConfig); + logger.debug('Saved shortcuts config'); + } catch (error) { + logger.error('Error saving shortcuts config:', error); + } + } + + /** + * Register configured shortcuts + */ + private registerConfiguredShortcuts() { + // Unregister all shortcuts first + this.unregisterAll(); + + // Register each enabled shortcut + Object.entries(this.shortcutsConfig).forEach(([id, accelerator]) => { + logger.debug(`Registering shortcut '${id}' with ${accelerator}`); + + const method = this.shortcuts.get(id); + if (accelerator && method) { + this.registerShortcut(accelerator, method); + } + }); + } +} diff --git a/apps/desktop/src/main/core/StoreManager.ts b/apps/desktop/src/main/core/StoreManager.ts new file mode 100644 index 0000000000..424c13f025 --- /dev/null +++ b/apps/desktop/src/main/core/StoreManager.ts @@ -0,0 +1,89 @@ +import Store from 'electron-store'; + +import { STORE_DEFAULTS, STORE_NAME } from '@/const/store'; +import { ElectronMainStore, StoreKey } from '@/types/store'; +import { makeSureDirExist } from '@/utils/file-system'; +import { createLogger } from '@/utils/logger'; + +import { App } from './App'; + +// Create logger +const logger = createLogger('core:StoreManager'); + +/** + * Application configuration storage manager + */ +export class StoreManager { + /** + * Global configuration store instance + */ + private store: Store; + private app: App; + + constructor(app: App) { + logger.debug('Initializing StoreManager'); + this.app = app; + this.store = new Store({ + defaults: STORE_DEFAULTS, + name: STORE_NAME, + }); + logger.info('StoreManager initialized with store name:', STORE_NAME); + + const storagePath = this.store.get('storagePath'); + logger.info('app storage path:', storagePath); + + makeSureDirExist(storagePath); + } + + /** + * Get configuration item + * @param key Configuration key + * @param defaultValue Default value + */ + get(key: K, defaultValue?: ElectronMainStore[K]): ElectronMainStore[K] { + logger.debug('Getting configuration value for key:', key); + return this.store.get(key, defaultValue as any); + } + + /** + * Set configuration item + * @param key Configuration key + * @param value Configuration value + */ + set(key: K, value: ElectronMainStore[K]): void { + logger.debug('Setting configuration value for key:', key); + this.store.set(key, value); + } + + /** + * Delete configuration item + * @param key Configuration key + */ + delete(key: StoreKey): void { + logger.debug('Deleting configuration key:', key); + this.store.delete(key); + } + + /** + * Clear all storage + */ + clear(): void { + logger.warn('Clearing all store data'); + this.store.clear(); + } + + /** + * Check if a configuration item exists + * @param key Configuration key + */ + has(key: StoreKey): boolean { + const exists = this.store.has(key); + logger.debug('Checking if key exists:', key, exists); + return exists; + } + + async openInEditor() { + logger.info('Opening store in editor'); + await this.store.openInEditor(); + } +} diff --git a/apps/desktop/src/main/core/UpdaterManager.ts b/apps/desktop/src/main/core/UpdaterManager.ts new file mode 100644 index 0000000000..3105643a7d --- /dev/null +++ b/apps/desktop/src/main/core/UpdaterManager.ts @@ -0,0 +1,321 @@ +import log from 'electron-log'; +import { autoUpdater } from 'electron-updater'; + +import { isDev } from '@/const/env'; +import { UPDATE_CHANNEL as channel, updaterConfig } from '@/modules/updater/configs'; +import { createLogger } from '@/utils/logger'; + +import type { App as AppCore } from './App'; + +// Create logger +const logger = createLogger('core:UpdaterManager'); + +export class UpdaterManager { + private app: AppCore; + private checking: boolean = false; + private downloading: boolean = false; + private updateAvailable: boolean = false; + private isManualCheck: boolean = false; + + constructor(app: AppCore) { + this.app = app; + + // 设置日志 + log.transports.file.level = 'info'; + autoUpdater.logger = log; + + logger.debug(`[Updater] Log file should be at: ${log.transports.file.getFile().path}`); // 打印路径 + } + + get mainWindow() { + return this.app.browserManager.getMainWindow(); + } + + public initialize = async () => { + logger.debug('Initializing UpdaterManager'); + // If updates are disabled and in production environment, don't initialize updates + if (!updaterConfig.enableAppUpdate && !isDev) { + logger.info('App updates are disabled, skipping updater initialization'); + return; + } + + // 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 + autoUpdater.forceDevUpdateConfig = true; + } + + // Register events + this.registerEvents(); + + // If auto-check for updates is configured, set up periodic checks + if (updaterConfig.app.autoCheckUpdate) { + // Delay update check by 1 minute after startup to avoid network instability + setTimeout(() => this.checkForUpdates(), 60 * 1000); + + // Set up periodic checks + setInterval(() => this.checkForUpdates(), updaterConfig.app.checkUpdateInterval); + } + + // Log the channel and allowPrerelease values + logger.debug( + `Initialized with channel: ${autoUpdater.channel}, allowPrerelease: ${autoUpdater.allowPrerelease}`, + ); + + logger.info('UpdaterManager initialization completed'); + }; + + /** + * Check for updates + * @param manual whether this is a manual check for updates + */ + public checkForUpdates = async ({ manual = false }: { manual?: boolean } = {}) => { + if (this.checking || this.downloading) return; + + this.checking = true; + this.isManualCheck = manual; + logger.info(`${manual ? 'Manually checking' : 'Auto checking'} for updates...`); + + // If manual check, notify renderer process about check start + if (manual) { + this.mainWindow.broadcast('manualUpdateCheckStart'); + } + + try { + await autoUpdater.checkForUpdates(); + } catch (error) { + logger.error('Error checking for updates:', error.message); + + // If manual check, notify renderer process about check error + if (manual) { + this.mainWindow.broadcast('updateError', (error as Error).message); + } + } finally { + this.checking = false; + } + }; + + /** + * Download update + * @param manual whether this is a manual download + */ + public downloadUpdate = async (manual: boolean = false) => { + if (this.downloading || !this.updateAvailable) return; + + this.downloading = true; + logger.info(`${manual ? 'Manually downloading' : 'Auto downloading'} update...`); + + // If manual download or manual check, notify renderer process about download start + if (manual || this.isManualCheck) { + this.mainWindow.broadcast('updateDownloadStart'); + } + + try { + await autoUpdater.downloadUpdate(); + } catch (error) { + this.downloading = false; + logger.error('Error downloading update:', error); + + // If manual download or manual check, notify renderer process about download error + if (manual || this.isManualCheck) { + this.mainWindow.broadcast('updateError', (error as Error).message); + } + } + }; + + /** + * Install update immediately + */ + public installNow = () => { + logger.info('Installing update now...'); + + // Mark application for exit + this.app.isQuiting = true; + + // Delay installation by 1 second to ensure window is closed + autoUpdater.quitAndInstall(); + }; + + /** + * Install update on next launch + */ + public installLater = () => { + logger.info('Update will be installed on next restart'); + + // Mark for installation on next launch, but don't exit application + autoUpdater.autoInstallOnAppQuit = true; + + // Notify renderer process that update will be installed on next launch + this.mainWindow.broadcast('updateWillInstallLater'); + }; + + /** + * Test mode: Simulate update available + * Only for use in development environment + */ + public simulateUpdateAvailable = () => { + if (!isDev) return; + + logger.info('Simulating update available...'); + + const mainWindow = this.mainWindow; + // Simulate a new version update + const mockUpdateInfo = { + releaseDate: new Date().toISOString(), + releaseNotes: ` #### Version 1.0.0 Release Notes +- Added some great new features +- Fixed bugs affecting usability +- Optimized overall application performance +- Updated dependency libraries +`, + version: '1.0.0', + }; + + // Set update available state + this.updateAvailable = true; + + // Notify renderer process + if (this.isManualCheck) { + mainWindow.broadcast('manualUpdateAvailable', mockUpdateInfo); + } else { + // In auto-check mode, directly simulate download + this.simulateDownloadProgress(); + } + }; + + /** + * Test mode: Simulate update downloaded + * Only for use in development environment + */ + public simulateUpdateDownloaded = () => { + if (!isDev) return; + + logger.info('Simulating update downloaded...'); + + const mainWindow = this.app.browserManager.getMainWindow(); + if (mainWindow) { + // Simulate a new version update + const mockUpdateInfo = { + releaseDate: new Date().toISOString(), + releaseNotes: ` #### Version 1.0.0 Release Notes +- Added some great new features +- Fixed bugs affecting usability +- Optimized overall application performance +- Updated dependency libraries +`, + version: '1.0.0', + }; + + // Set download state + this.downloading = false; + + // Notify renderer process + mainWindow.broadcast('updateDownloaded', mockUpdateInfo); + } + }; + + /** + * Test mode: Simulate update download progress + * Only for use in development environment + */ + public simulateDownloadProgress = () => { + if (!isDev) return; + + logger.info('Simulating download progress...'); + + const mainWindow = this.app.browserManager.getMainWindow(); + + // Set download state + this.downloading = true; + + // Only broadcast download start event if manual check + if (this.isManualCheck) { + mainWindow.broadcast('updateDownloadStart'); + } + + // Simulate progress updates + let progress = 0; + const interval = setInterval(() => { + progress += 10; + + if ( + progress <= 100 && // Only broadcast download progress if manual check + this.isManualCheck + ) { + mainWindow.broadcast('updateDownloadProgress', { + bytesPerSecond: 1024 * 1024, + percent: progress, // 1MB/s + total: 1024 * 1024 * 100, // 100MB + transferred: 1024 * 1024 * progress, // Progress * 1MB + }); + } + + if (progress >= 100) { + clearInterval(interval); + this.simulateUpdateDownloaded(); + } + }, 300); + }; + + private registerEvents() { + logger.debug('Registering updater events'); + + autoUpdater.on('checking-for-update', () => { + logger.info('[Updater] Checking for update...'); + }); + + autoUpdater.on('update-available', (info) => { + logger.info(`Update available: ${info.version}`); + this.updateAvailable = true; + + if (this.isManualCheck) { + this.mainWindow.broadcast('manualUpdateAvailable', info); + } else { + // If it's an automatic check, start downloading automatically + logger.info('Auto check found update, starting download automatically...'); + this.downloadUpdate(); + } + }); + + autoUpdater.on('update-not-available', (info) => { + logger.info(`Update not available. Current: ${info.version}`); + if (this.isManualCheck) { + this.mainWindow.broadcast('manualUpdateNotAvailable', info); + } + }); + + autoUpdater.on('error', (err) => { + logger.error('Error in auto-updater:', err); + if (this.isManualCheck) { + this.mainWindow.broadcast('updateError', err.message); + } + }); + + autoUpdater.on('download-progress', (progressObj) => { + logger.debug( + `Download speed: ${progressObj.bytesPerSecond} - Downloaded ${progressObj.percent}% (${progressObj.transferred}/${progressObj.total})`, + ); + if (this.isManualCheck) { + this.mainWindow.broadcast('updateDownloadProgress', progressObj); + } + }); + + autoUpdater.on('update-downloaded', (info) => { + logger.info(`Update downloaded: ${info.version}`); + this.downloading = false; + // Always notify about downloaded update + this.mainWindow.broadcast('updateDownloaded', info); + }); + + logger.debug('Updater events registered'); + } +} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts new file mode 100644 index 0000000000..e5720c849c --- /dev/null +++ b/apps/desktop/src/main/index.ts @@ -0,0 +1,5 @@ +import { App } from './core/App'; + +const app = new App(); + +app.bootstrap(); diff --git a/apps/desktop/src/main/locales/default/common.ts b/apps/desktop/src/main/locales/default/common.ts new file mode 100644 index 0000000000..6da3762c9d --- /dev/null +++ b/apps/desktop/src/main/locales/default/common.ts @@ -0,0 +1,34 @@ +const common = { + actions: { + add: '添加', + back: '返回', + cancel: '取消', + close: '关闭', + confirm: '确认', + delete: '删除', + edit: '编辑', + more: '更多', + next: '下一步', + ok: '确定', + previous: '上一步', + refresh: '刷新', + remove: '移除', + retry: '重试', + save: '保存', + search: '搜索', + submit: '提交', + }, + app: { + description: '你的 AI 助手协作平台', + name: 'LobeHub', + }, + status: { + error: '错误', + info: '信息', + loading: '加载中', + success: '成功', + warning: '警告', + }, +}; + +export default common; diff --git a/apps/desktop/src/main/locales/default/dialog.ts b/apps/desktop/src/main/locales/default/dialog.ts new file mode 100644 index 0000000000..fdd4d0da44 --- /dev/null +++ b/apps/desktop/src/main/locales/default/dialog.ts @@ -0,0 +1,33 @@ +const dialog = { + about: { + button: '确定', + detail: '一个基于大语言模型的聊天应用', + message: '{{appName}} {{appVersion}}', + title: '关于', + }, + confirm: { + cancel: '取消', + no: '否', + title: '确认', + yes: '是', + }, + error: { + button: '确定', + detail: '操作过程中发生错误,请稍后重试', + message: '发生错误', + title: '错误', + }, + update: { + downloadAndInstall: '下载并安装', + downloadComplete: '下载完成', + downloadCompleteMessage: '更新包已下载完成,是否立即安装?', + installLater: '稍后安装', + installNow: '立即安装', + later: '稍后提醒', + newVersion: '发现新版本', + newVersionAvailable: '发现新版本: {{version}}', + skipThisVersion: '跳过此版本', + }, +}; + +export default dialog; diff --git a/apps/desktop/src/main/locales/default/index.ts b/apps/desktop/src/main/locales/default/index.ts new file mode 100644 index 0000000000..21cf89c1c3 --- /dev/null +++ b/apps/desktop/src/main/locales/default/index.ts @@ -0,0 +1,11 @@ +import common from './common'; +import dialog from './dialog'; +import menu from './menu'; + +const resources = { + common, + dialog, + menu, +} as const; + +export default resources; diff --git a/apps/desktop/src/main/locales/default/menu.ts b/apps/desktop/src/main/locales/default/menu.ts new file mode 100644 index 0000000000..97933b8437 --- /dev/null +++ b/apps/desktop/src/main/locales/default/menu.ts @@ -0,0 +1,72 @@ +const menu = { + common: { + checkUpdates: '检查更新...', + }, + dev: { + devPanel: '开发者面板', + devTools: '开发者工具', + forceReload: '强制重新加载', + openStore: '打开存储文件', + refreshMenu: '刷新菜单', + reload: '重新加载', + title: '开发', + }, + edit: { + copy: '复制', + cut: '剪切', + paste: '粘贴', + redo: '重做', + selectAll: '全选', + speech: '语音', + startSpeaking: '开始朗读', + stopSpeaking: '停止朗读', + title: '编辑', + undo: '撤销', + }, + file: { + preferences: '首选项', + quit: '退出', + title: '文件', + }, + help: { + about: '关于', + githubRepo: 'GitHub 仓库', + reportIssue: '报告问题', + title: '帮助', + visitWebsite: '访问官网', + }, + macOS: { + about: '关于 {{appName}}', + devTools: 'LobeHub 开发者工具', + hide: '隐藏 {{appName}}', + hideOthers: '隐藏其他', + preferences: '偏好设置...', + services: '服务', + unhide: '全部显示', + }, + tray: { + open: '打开 {{appName}}', + quit: '退出', + show: '显示 {{appName}}', + }, + view: { + forceReload: '强制重新加载', + reload: '重新加载', + resetZoom: '重置缩放', + title: '视图', + toggleFullscreen: '切换全屏', + zoomIn: '放大', + zoomOut: '缩小', + }, + window: { + bringAllToFront: '前置所有窗口', + close: '关闭', + front: '前置所有窗口', + minimize: '最小化', + title: '窗口', + toggleFullscreen: '切换全屏', + zoom: '缩放', + }, +}; + +export default menu; diff --git a/apps/desktop/src/main/locales/resources.ts b/apps/desktop/src/main/locales/resources.ts new file mode 100644 index 0000000000..22408e6904 --- /dev/null +++ b/apps/desktop/src/main/locales/resources.ts @@ -0,0 +1,35 @@ +import { isDev } from '@/const/env'; + +/** + * 规范化语言代码 + */ +export const normalizeLocale = (locale: string) => { + return locale.toLowerCase().replace('_', '-'); +}; + +/** + * 按需加载翻译资源 + */ +export const loadResources = async (lng: string, ns: string) => { + // 开发环境下,直接使用中文源文件 + if (isDev && lng === 'zh-CN') { + try { + // 使用 require 加载模块,这在 Electron 中更可靠 + const { default: content } = await import(`@/locales/default/${ns}.ts`); + + return content; + } catch (error) { + console.error(`[I18n] 无法加载翻译文件: ${ns}`, error); + return {}; + } + } + + // 生产环境使用编译后的 JSON 文件 + + try { + return await import(`@/../../resources/locales/${lng}/${ns}.json`); + } catch (error) { + console.error(`无法加载翻译文件: ${lng} - ${ns}`, error); + return {}; + } +}; diff --git a/apps/desktop/src/main/menus/impls/BaseMenuPlatform.ts b/apps/desktop/src/main/menus/impls/BaseMenuPlatform.ts new file mode 100644 index 0000000000..e44df1b482 --- /dev/null +++ b/apps/desktop/src/main/menus/impls/BaseMenuPlatform.ts @@ -0,0 +1,10 @@ +// apps/desktop/src/main/menus/impl/BaseMenuPlatform.ts +import type { App } from '@/core/App'; + +export abstract class BaseMenuPlatform { + protected app: App; + + constructor(app: App) { + this.app = app; + } +} diff --git a/apps/desktop/src/main/menus/impls/linux.ts b/apps/desktop/src/main/menus/impls/linux.ts new file mode 100644 index 0000000000..92004735d7 --- /dev/null +++ b/apps/desktop/src/main/menus/impls/linux.ts @@ -0,0 +1,243 @@ +import { Menu, MenuItemConstructorOptions, app, dialog, shell } from 'electron'; + +import { isDev } from '@/const/env'; + +import type { IMenuPlatform, MenuOptions } from '../types'; +import { BaseMenuPlatform } from './BaseMenuPlatform'; + +export class LinuxMenu extends BaseMenuPlatform implements IMenuPlatform { + private appMenu: Menu | null = null; + private trayMenu: Menu | null = null; + + buildAndSetAppMenu(options?: MenuOptions): Menu { + const template = this.getAppMenuTemplate(options); + this.appMenu = Menu.buildFromTemplate(template); + Menu.setApplicationMenu(this.appMenu); + return this.appMenu; + } + + buildContextMenu(type: string, data?: any): Menu { + let template: MenuItemConstructorOptions[]; + switch (type) { + case 'chat': { + template = this.getChatContextMenuTemplate(data); + break; + } + case 'editor': { + template = this.getEditorContextMenuTemplate(data); + break; + } + default: { + template = this.getDefaultContextMenuTemplate(); + } + } + return Menu.buildFromTemplate(template); + } + + buildTrayMenu(): Menu { + const template = this.getTrayMenuTemplate(); + this.trayMenu = Menu.buildFromTemplate(template); + return this.trayMenu; + } + + refresh(options?: MenuOptions): void { + this.buildAndSetAppMenu(options); + } + + // --- 私有方法:定义菜单模板和逻辑 --- + + private getAppMenuTemplate(options?: MenuOptions): MenuItemConstructorOptions[] { + const showDev = isDev || options?.showDevItems; + const t = this.app.i18n.ns('menu'); + + const template: MenuItemConstructorOptions[] = [ + { + label: t('file.title'), + submenu: [ + { + click: () => this.app.browserManager.retrieveByIdentifier('settings').show(), + label: t('file.preferences'), + }, + { + click: () => { + this.app.updaterManager.checkForUpdates({ manual: true }); + }, + label: t('common.checkUpdates') || '检查更新', + }, + { type: 'separator' }, + { + accelerator: 'Ctrl+W', + label: t('window.close'), + role: 'close', + }, + { + accelerator: 'Ctrl+M', + label: t('window.minimize'), + role: 'minimize', + }, + { type: 'separator' }, + { label: t('file.quit'), role: 'quit' }, + ], + }, + { + label: t('edit.title'), + submenu: [ + { accelerator: 'Ctrl+Z', label: t('edit.undo'), role: 'undo' }, + { accelerator: 'Ctrl+Shift+Z', label: t('edit.redo'), role: 'redo' }, + { type: 'separator' }, + { accelerator: 'Ctrl+X', label: t('edit.cut'), role: 'cut' }, + { accelerator: 'Ctrl+C', label: t('edit.copy'), role: 'copy' }, + { accelerator: 'Ctrl+V', label: t('edit.paste'), role: 'paste' }, + { type: 'separator' }, + { accelerator: 'Ctrl+A', label: t('edit.selectAll'), role: 'selectAll' }, + ], + }, + { + label: t('view.title'), + submenu: [ + { accelerator: 'Ctrl+0', label: t('view.resetZoom'), role: 'resetZoom' }, + { accelerator: 'Ctrl+Plus', label: t('view.zoomIn'), role: 'zoomIn' }, + { accelerator: 'Ctrl+-', label: t('view.zoomOut'), role: 'zoomOut' }, + { type: 'separator' }, + { accelerator: 'F11', label: t('view.toggleFullscreen'), role: 'togglefullscreen' }, + ], + }, + { + label: t('window.title'), + submenu: [ + { label: t('window.minimize'), role: 'minimize' }, + { label: t('window.close'), role: 'close' }, + ], + }, + { + label: t('help.title'), + submenu: [ + { + click: async () => { + await shell.openExternal('https://lobehub.com'); + }, + label: t('help.visitWebsite'), + }, + { + click: async () => { + await shell.openExternal('https://github.com/lobehub/lobe-chat'); + }, + label: t('help.githubRepo'), + }, + { type: 'separator' }, + { + click: () => { + const commonT = this.app.i18n.ns('common'); + const dialogT = this.app.i18n.ns('dialog'); + + dialog.showMessageBox({ + buttons: [commonT('actions.ok')], + detail: dialogT('about.detail'), + message: dialogT('about.message', { + appName: app.getName(), + appVersion: app.getVersion(), + }), + title: dialogT('about.title'), + type: 'info', + }); + }, + label: t('help.about'), + }, + ], + }, + ]; + + if (showDev) { + template.push({ + label: t('dev.title'), + submenu: [ + { accelerator: 'Ctrl+R', label: t('dev.reload'), role: 'reload' }, + { accelerator: 'Ctrl+Shift+R', label: t('dev.forceReload'), role: 'forceReload' }, + { accelerator: 'Ctrl+Shift+I', label: t('dev.devTools'), role: 'toggleDevTools' }, + { type: 'separator' }, + { + click: () => { + this.app.browserManager.retrieveByIdentifier('devtools').show(); + }, + label: t('dev.devPanel'), + }, + ], + }); + } + + return template; + } + + private getDefaultContextMenuTemplate(): MenuItemConstructorOptions[] { + const t = this.app.i18n.ns('menu'); + + return [ + { label: t('edit.cut'), role: 'cut' }, + { label: t('edit.copy'), role: 'copy' }, + { label: t('edit.paste'), role: 'paste' }, + { type: 'separator' }, + { label: t('edit.selectAll'), role: 'selectAll' }, + ]; + } + + private getChatContextMenuTemplate(data?: any): MenuItemConstructorOptions[] { + const t = this.app.i18n.ns('menu'); + const commonT = this.app.i18n.ns('common'); + + const items: MenuItemConstructorOptions[] = [ + { label: t('edit.copy'), role: 'copy' }, + { label: t('edit.paste'), role: 'paste' }, + { type: 'separator' }, + { label: t('edit.selectAll'), role: 'selectAll' }, + ]; + + if (data?.messageId) { + items.push( + { type: 'separator' }, + { + click: () => { + console.log('尝试删除消息:', data.messageId); + }, + label: commonT('actions.delete'), + }, + ); + } + + return items; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + private getEditorContextMenuTemplate(_data?: any): MenuItemConstructorOptions[] { + const t = this.app.i18n.ns('menu'); + + return [ + { label: t('edit.cut'), role: 'cut' }, + { label: t('edit.copy'), role: 'copy' }, + { label: t('edit.paste'), role: 'paste' }, + { type: 'separator' }, + { label: t('edit.undo'), role: 'undo' }, + { label: t('edit.redo'), role: 'redo' }, + { type: 'separator' }, + { label: t('edit.selectAll'), role: 'selectAll' }, + ]; + } + + private getTrayMenuTemplate(): MenuItemConstructorOptions[] { + const t = this.app.i18n.ns('menu'); + const appName = app.getName(); + + return [ + { + click: () => this.app.browserManager.showMainWindow(), + label: t('tray.open', { appName }), + }, + { type: 'separator' }, + { + click: () => this.app.browserManager.retrieveByIdentifier('settings').show(), + label: t('file.preferences'), + }, + { type: 'separator' }, + { label: t('tray.quit'), role: 'quit' }, + ]; + } +} diff --git a/apps/desktop/src/main/menus/impls/macOS.ts b/apps/desktop/src/main/menus/impls/macOS.ts new file mode 100644 index 0000000000..b8c23e759a --- /dev/null +++ b/apps/desktop/src/main/menus/impls/macOS.ts @@ -0,0 +1,360 @@ +import { Menu, MenuItemConstructorOptions, app, shell } from 'electron'; +import * as path from 'node:path'; + +import { isDev } from '@/const/env'; + +import type { IMenuPlatform, MenuOptions } from '../types'; +import { BaseMenuPlatform } from './BaseMenuPlatform'; + +export class MacOSMenu extends BaseMenuPlatform implements IMenuPlatform { + private appMenu: Menu | null = null; + private trayMenu: Menu | null = null; + + buildAndSetAppMenu(options?: MenuOptions): Menu { + const template = this.getAppMenuTemplate(options); + + this.appMenu = Menu.buildFromTemplate(template); + + Menu.setApplicationMenu(this.appMenu); + + return this.appMenu; + } + + buildContextMenu(type: string, data?: any): Menu { + let template: MenuItemConstructorOptions[]; + switch (type) { + case 'chat': { + template = this.getChatContextMenuTemplate(data); + break; + } + case 'editor': { + template = this.getEditorContextMenuTemplate(data); + break; + } + default: { + template = this.getDefaultContextMenuTemplate(); + } + } + return Menu.buildFromTemplate(template); + } + + buildTrayMenu(): Menu { + const template = this.getTrayMenuTemplate(); + this.trayMenu = Menu.buildFromTemplate(template); + return this.trayMenu; + } + + refresh(options?: MenuOptions): void { + // 重建应用菜单 + this.buildAndSetAppMenu(options); + // 如果托盘菜单存在,也重建它(如果需要动态更新) + // this.trayMenu = this.buildTrayMenu(); + // 需要考虑如何更新现有托盘图标的菜单 + } + + // --- 私有方法:定义菜单模板和逻辑 --- + + private getAppMenuTemplate(options?: MenuOptions): MenuItemConstructorOptions[] { + const appName = app.getName(); + const showDev = isDev || options?.showDevItems; + + // 创建命名空间翻译函数 + const t = this.app.i18n.ns('menu'); + + // 添加调试日志 + // console.log('[MacOSMenu] 菜单渲染, i18n实例:', !!this.app.i18n); + + const template: MenuItemConstructorOptions[] = [ + { + label: appName, + submenu: [ + { + label: t('macOS.about', { appName }), + role: 'about', + }, + { + click: () => { + this.app.updaterManager.checkForUpdates({ manual: true }); + }, + label: t('common.checkUpdates'), + }, + { type: 'separator' }, + { + accelerator: 'Command+,', + click: () => { + this.app.browserManager.showSettingsWindow(); + }, + label: t('macOS.preferences'), + }, + { type: 'separator' }, + { + label: t('macOS.services'), + role: 'services', + submenu: [], + }, + { type: 'separator' }, + { + accelerator: 'Command+H', + label: t('macOS.hide', { appName }), + role: 'hide', + }, + { + accelerator: 'Command+Alt+H', + label: t('macOS.hideOthers'), + role: 'hideOthers', + }, + { + label: t('macOS.unhide'), + role: 'unhide', + }, + { type: 'separator' }, + { + accelerator: 'Command+Q', + label: t('file.quit'), + role: 'quit', + }, + ], + }, + { + label: t('file.title'), + submenu: [ + { + accelerator: 'Command+W', + label: t('window.close'), + role: 'close', + }, + ], + }, + { + label: t('edit.title'), + submenu: [ + { accelerator: 'Command+Z', label: t('edit.undo'), role: 'undo' }, + { accelerator: 'Shift+Command+Z', label: t('edit.redo'), role: 'redo' }, + { type: 'separator' }, + { accelerator: 'Command+X', label: t('edit.cut'), role: 'cut' }, + { accelerator: 'Command+C', label: t('edit.copy'), role: 'copy' }, + { accelerator: 'Command+V', label: t('edit.paste'), role: 'paste' }, + { type: 'separator' }, + { + label: t('edit.speech'), + submenu: [ + { label: t('edit.startSpeaking'), role: 'startSpeaking' }, + { label: t('edit.stopSpeaking'), role: 'stopSpeaking' }, + ], + }, + { type: 'separator' }, + { accelerator: 'Command+A', label: t('edit.selectAll'), role: 'selectAll' }, + ], + }, + { + label: t('view.title'), + submenu: [ + { label: t('view.reload'), role: 'reload' }, + { label: t('view.forceReload'), role: 'forceReload' }, + { accelerator: 'F12', label: t('dev.devTools'), role: 'toggleDevTools' }, + { type: 'separator' }, + { accelerator: 'Command+0', label: t('view.resetZoom'), role: 'resetZoom' }, + { accelerator: 'Command+Plus', label: t('view.zoomIn'), role: 'zoomIn' }, + { accelerator: 'Command+-', label: t('view.zoomOut'), role: 'zoomOut' }, + { type: 'separator' }, + { accelerator: 'F11', label: t('view.toggleFullscreen'), role: 'togglefullscreen' }, + ], + }, + { + label: t('window.title'), + role: 'windowMenu', + }, + { + label: t('help.title'), + role: 'help', + submenu: [ + { + click: async () => { + await shell.openExternal('https://lobehub.com'); + }, + label: t('help.visitWebsite'), + }, + { + click: async () => { + await shell.openExternal('https://github.com/lobehub/lobe-chat'); + }, + label: t('help.githubRepo'), + }, + { + click: async () => { + await shell.openExternal('https://github.com/lobehub/lobe-chat/issues/new/choose'); + }, + label: t('help.reportIssue'), + }, + { type: 'separator' }, + { + click: () => { + const logsPath = app.getPath('logs'); + console.log(`[Menu] Opening logs directory: ${logsPath}`); + shell.openPath(logsPath).catch((err) => { + console.error(`[Menu] Error opening path ${logsPath}:`, err); + // Optionally show an error dialog to the user + }); + }, + label: '打开日志目录', + }, + { + click: () => { + const userDataPath = app.getPath('userData'); + console.log(`[Menu] Opening user data directory: ${userDataPath}`); + shell.openPath(userDataPath).catch((err) => { + console.error(`[Menu] Error opening path ${userDataPath}:`, err); + // Optionally show an error dialog to the user + }); + }, + label: '配置目录', + }, + ], + }, + ]; + + if (showDev) { + template.push({ + label: t('dev.title'), + submenu: [ + { + click: () => { + this.app.browserManager.retrieveByIdentifier('devtools').show(); + }, + label: t('dev.devPanel'), + }, + { + click: () => { + this.app.menuManager.rebuildAppMenu(); + }, + label: t('dev.refreshMenu'), + }, + { type: 'separator' }, + { + click: () => { + const userDataPath = app.getPath('userData'); + shell.openPath(userDataPath).catch((err) => { + console.error(`[Menu] Error opening path ${userDataPath}:`, err); + }); + }, + label: '用户配置目录', + }, + { + click: () => { + // @ts-expect-error cache 目录好像暂时不在类型定义里 + const cachePath = app.getPath('cache'); + + const updaterCachePath = path.join(cachePath, `${app.getName()}-updater`); + shell.openPath(updaterCachePath).catch((err) => { + console.error(`[Menu] Error opening path ${updaterCachePath}:`, err); + }); + }, + label: '更新缓存目录', + }, + { type: 'separator' }, + { + label: '自动更新测试模拟', + submenu: [ + { + click: () => { + this.app.updaterManager.simulateUpdateAvailable(); + }, + label: '模拟启动后台自动下载更新(3s 下完)', + }, + { + click: () => { + this.app.updaterManager.simulateDownloadProgress(); + }, + label: '模拟下载进度', + }, + { + click: () => { + this.app.updaterManager.simulateUpdateDownloaded(); + }, + label: '模拟下载完成', + }, + ], + }, + ], + }); + } + + return template; + } + + private getDefaultContextMenuTemplate(): MenuItemConstructorOptions[] { + const t = this.app.i18n.ns('menu'); + + return [ + { label: t('edit.cut'), role: 'cut' }, + { label: t('edit.copy'), role: 'copy' }, + { label: t('edit.paste'), role: 'paste' }, + { label: t('edit.selectAll'), role: 'selectAll' }, + { type: 'separator' }, + ]; + } + + private getChatContextMenuTemplate(data?: any): MenuItemConstructorOptions[] { + const t = this.app.i18n.ns('menu'); + const commonT = this.app.i18n.ns('common'); + + const items: MenuItemConstructorOptions[] = [ + { label: t('edit.cut'), role: 'cut' }, + { label: t('edit.copy'), role: 'copy' }, + { label: t('edit.paste'), role: 'paste' }, + { type: 'separator' }, + { label: t('edit.selectAll'), role: 'selectAll' }, + ]; + + if (data?.messageId) { + items.push( + { type: 'separator' }, + { + click: () => { + console.log('尝试删除消息:', data.messageId); + // 调用 MessageService (假设存在) + // const messageService = this.app.getService(MessageService); + // messageService?.deleteMessage(data.messageId); + }, + label: commonT('actions.delete'), + }, + ); + } + return items; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + private getEditorContextMenuTemplate(_data?: any): MenuItemConstructorOptions[] { + const t = this.app.i18n.ns('menu'); + + // 编辑器特定的上下文菜单 + return [ + { label: t('edit.cut'), role: 'cut' }, + { label: t('edit.copy'), role: 'copy' }, + { label: t('edit.paste'), role: 'paste' }, + { type: 'separator' }, + { label: t('edit.undo'), role: 'undo' }, + { label: t('edit.redo'), role: 'redo' }, + { type: 'separator' }, + { label: t('edit.selectAll'), role: 'selectAll' }, + ]; + } + + private getTrayMenuTemplate(): MenuItemConstructorOptions[] { + const t = this.app.i18n.ns('menu'); + const appName = app.getName(); + + return [ + { + click: () => this.app.browserManager.showMainWindow(), + label: t('tray.show', { appName }), + }, + { + click: () => this.app.browserManager.retrieveByIdentifier('settings').show(), + label: t('file.preferences'), + }, + { type: 'separator' }, + { label: t('tray.quit'), role: 'quit' }, + ]; + } +} diff --git a/apps/desktop/src/main/menus/impls/windows.ts b/apps/desktop/src/main/menus/impls/windows.ts new file mode 100644 index 0000000000..ff5d0f3900 --- /dev/null +++ b/apps/desktop/src/main/menus/impls/windows.ts @@ -0,0 +1,226 @@ +import { Menu, MenuItemConstructorOptions, app, shell } from 'electron'; + +import { isDev } from '@/const/env'; + +import type { IMenuPlatform, MenuOptions } from '../types'; +import { BaseMenuPlatform } from './BaseMenuPlatform'; + +export class WindowsMenu extends BaseMenuPlatform implements IMenuPlatform { + private appMenu: Menu | null = null; + private trayMenu: Menu | null = null; + + buildAndSetAppMenu(options?: MenuOptions): Menu { + const template = this.getAppMenuTemplate(options); + this.appMenu = Menu.buildFromTemplate(template); + Menu.setApplicationMenu(this.appMenu); + return this.appMenu; + } + + buildContextMenu(type: string, data?: any): Menu { + let template: MenuItemConstructorOptions[]; + switch (type) { + case 'chat': { + template = this.getChatContextMenuTemplate(data); + break; + } + case 'editor': { + template = this.getEditorContextMenuTemplate(data); + break; + } + default: { + template = this.getDefaultContextMenuTemplate(); + } + } + return Menu.buildFromTemplate(template); + } + + buildTrayMenu(): Menu { + const template = this.getTrayMenuTemplate(); + this.trayMenu = Menu.buildFromTemplate(template); + return this.trayMenu; + } + + refresh(options?: MenuOptions): void { + this.buildAndSetAppMenu(options); + // 如果有必要更新托盘菜单,可以在这里添加逻辑 + } + + private getAppMenuTemplate(options?: MenuOptions): MenuItemConstructorOptions[] { + const showDev = isDev || options?.showDevItems; + const t = this.app.i18n.ns('menu'); + + const template: MenuItemConstructorOptions[] = [ + { + label: t('file.title'), + submenu: [ + { + click: () => this.app.browserManager.retrieveByIdentifier('settings').show(), + label: t('file.preferences'), + }, + { + click: () => { + this.app.updaterManager.checkForUpdates({ manual: true }); + }, + label: t('common.checkUpdates') || '检查更新', + }, + { type: 'separator' }, + { + accelerator: 'Alt+F4', + label: t('window.close'), + role: 'close', + }, + { + accelerator: 'Ctrl+M', + label: t('window.minimize'), + role: 'minimize', + }, + { type: 'separator' }, + { label: t('file.quit'), role: 'quit' }, + ], + }, + { + label: t('edit.title'), + submenu: [ + { accelerator: 'Ctrl+Z', label: t('edit.undo'), role: 'undo' }, + { accelerator: 'Ctrl+Y', label: t('edit.redo'), role: 'redo' }, + { type: 'separator' }, + { accelerator: 'Ctrl+X', label: t('edit.cut'), role: 'cut' }, + { accelerator: 'Ctrl+C', label: t('edit.copy'), role: 'copy' }, + { accelerator: 'Ctrl+V', label: t('edit.paste'), role: 'paste' }, + { type: 'separator' }, + { accelerator: 'Ctrl+A', label: t('edit.selectAll'), role: 'selectAll' }, + ], + }, + { + label: t('view.title'), + submenu: [ + { accelerator: 'Ctrl+0', label: t('view.resetZoom'), role: 'resetZoom' }, + { accelerator: 'Ctrl+Plus', label: t('view.zoomIn'), role: 'zoomIn' }, + { accelerator: 'Ctrl+-', label: t('view.zoomOut'), role: 'zoomOut' }, + { type: 'separator' }, + { accelerator: 'F11', label: t('view.toggleFullscreen'), role: 'togglefullscreen' }, + ], + }, + { + label: t('window.title'), + submenu: [ + { label: t('window.minimize'), role: 'minimize' }, + { label: t('window.close'), role: 'close' }, + ], + }, + { + label: t('help.title'), + submenu: [ + { + click: async () => { + await shell.openExternal('https://lobehub.com'); + }, + label: t('help.visitWebsite'), + }, + { + click: async () => { + await shell.openExternal('https://github.com/lobehub/lobe-chat'); + }, + label: t('help.githubRepo'), + }, + ], + }, + ]; + + if (showDev) { + template.push({ + label: t('dev.title'), + submenu: [ + { accelerator: 'Ctrl+R', label: t('dev.reload'), role: 'reload' }, + { accelerator: 'Ctrl+Shift+R', label: t('dev.forceReload'), role: 'forceReload' }, + { accelerator: 'Ctrl+Shift+I', label: t('dev.devTools'), role: 'toggleDevTools' }, + { type: 'separator' }, + { + click: () => { + this.app.browserManager.retrieveByIdentifier('devtools').show(); + }, + label: t('dev.devPanel'), + }, + ], + }); + } + + return template; + } + + private getDefaultContextMenuTemplate(): MenuItemConstructorOptions[] { + const t = this.app.i18n.ns('menu'); + + return [ + { label: t('edit.cut'), role: 'cut' }, + { label: t('edit.copy'), role: 'copy' }, + { label: t('edit.paste'), role: 'paste' }, + { type: 'separator' }, + { label: t('edit.selectAll'), role: 'selectAll' }, + ]; + } + + private getChatContextMenuTemplate(data?: any): MenuItemConstructorOptions[] { + const t = this.app.i18n.ns('menu'); + const commonT = this.app.i18n.ns('common'); + + const items: MenuItemConstructorOptions[] = [ + { label: t('edit.copy'), role: 'copy' }, + { label: t('edit.paste'), role: 'paste' }, + { type: 'separator' }, + { label: t('edit.selectAll'), role: 'selectAll' }, + ]; + + if (data?.messageId) { + items.push( + { type: 'separator' }, + { + click: () => { + console.log('尝试删除消息:', data.messageId); + // 调用 MessageService (假设存在) + // const messageService = this.app.getService(MessageService); + // messageService?.deleteMessage(data.messageId); + }, + label: commonT('actions.delete'), + }, + ); + } + + return items; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + private getEditorContextMenuTemplate(_data?: any): MenuItemConstructorOptions[] { + const t = this.app.i18n.ns('menu'); + + return [ + { label: t('edit.cut'), role: 'cut' }, + { label: t('edit.copy'), role: 'copy' }, + { label: t('edit.paste'), role: 'paste' }, + { type: 'separator' }, + { label: t('edit.undo'), role: 'undo' }, + { label: t('edit.redo'), role: 'redo' }, + { type: 'separator' }, + { label: t('edit.selectAll'), role: 'selectAll' }, + ]; + } + + private getTrayMenuTemplate(): MenuItemConstructorOptions[] { + const t = this.app.i18n.ns('menu'); + const appName = app.getName(); + + return [ + { + click: () => this.app.browserManager.showMainWindow(), + label: t('tray.open', { appName }), + }, + { type: 'separator' }, + { + click: () => this.app.browserManager.retrieveByIdentifier('settings').show(), + label: t('file.preferences'), + }, + { type: 'separator' }, + { label: t('tray.quit'), role: 'quit' }, + ]; + } +} diff --git a/apps/desktop/src/main/menus/index.ts b/apps/desktop/src/main/menus/index.ts new file mode 100644 index 0000000000..a97ec70be1 --- /dev/null +++ b/apps/desktop/src/main/menus/index.ts @@ -0,0 +1,34 @@ +import { platform } from 'node:os'; + +import { App } from '@/core/App'; + +import { LinuxMenu } from './impls/linux'; +import { MacOSMenu } from './impls/macOS'; +import { WindowsMenu } from './impls/windows'; +import { IMenuPlatform } from './types'; + +export type { IMenuPlatform, MenuOptions } from './types'; + +export const createMenuImpl = (app: App): IMenuPlatform => { + const currentPlatform = platform(); + + switch (currentPlatform) { + case 'darwin': { + return new MacOSMenu(app); + } + case 'win32': { + return new WindowsMenu(app); + } + case 'linux': { + return new LinuxMenu(app); + } + + default: { + // 提供一个备用或抛出错误 + console.warn( + `Unsupported platform for menu: ${currentPlatform}, using Windows implementation as fallback.`, + ); + return new WindowsMenu(app); + } + } +}; diff --git a/apps/desktop/src/main/menus/types.ts b/apps/desktop/src/main/menus/types.ts new file mode 100644 index 0000000000..67e28e9ceb --- /dev/null +++ b/apps/desktop/src/main/menus/types.ts @@ -0,0 +1,28 @@ +import { Menu } from 'electron'; + +export interface MenuOptions { + showDevItems?: boolean; + // 其他可能的配置项 +} + +export interface IMenuPlatform { + /** + * 构建并设置应用菜单 + */ + buildAndSetAppMenu(options?: MenuOptions): Menu; + + /** + * 构建上下文菜单 + */ + buildContextMenu(type: string, data?: any): Menu; + + /** + * 构建托盘菜单 + */ + buildTrayMenu(): Menu; + + /** + * 刷新菜单 + */ + refresh(options?: MenuOptions): void; +} diff --git a/apps/desktop/src/main/modules/fileSearch/impl/macOS.ts b/apps/desktop/src/main/modules/fileSearch/impl/macOS.ts new file mode 100644 index 0000000000..16a7e9b96d --- /dev/null +++ b/apps/desktop/src/main/modules/fileSearch/impl/macOS.ts @@ -0,0 +1,577 @@ +import { exec, spawn } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import readline from 'node:readline'; +import { promisify } from 'node:util'; + +import { FileResult, SearchOptions } from '@/types/fileSearch'; +import { createLogger } from '@/utils/logger'; + +import { FileSearchImpl } from '../type'; + +const execPromise = promisify(exec); +const statPromise = promisify(fs.stat); + +// Create logger +const logger = createLogger('module:FileSearch:macOS'); + +export class MacOSSearchServiceImpl extends FileSearchImpl { + /** + * Perform file search + * @param options Search options + * @returns Promise of search result list + */ + async search(options: SearchOptions): Promise { + // Build the command first, regardless of execution method + const command = this.buildSearchCommand(options); + logger.debug(`Executing command: ${command}`); + + // Use spawn for both live and non-live updates to handle large outputs + return new Promise((resolve, reject) => { + const [cmd, ...args] = command.split(' '); + const childProcess = spawn(cmd, args); + + let results: string[] = []; // Store raw file paths + let stderrData = ''; + + // Create a readline interface to process stdout line by line + const rl = readline.createInterface({ + crlfDelay: Infinity, + input: childProcess.stdout, // Handle different line endings + }); + + rl.on('line', (line) => { + const trimmedLine = line.trim(); + if (trimmedLine) { + results.push(trimmedLine); + + // If we have a limit and we've reached it (in non-live mode), stop processing + if (!options.liveUpdate && options.limit && results.length >= options.limit) { + logger.debug(`Reached limit (${options.limit}), closing readline and killing process.`); + rl.close(); // Stop reading lines + childProcess.kill(); // Terminate the mdfind process + } + } + }); + + childProcess.stderr.on('data', (data) => { + const errorMsg = data.toString(); + stderrData += errorMsg; + logger.warn(`Search stderr: ${errorMsg}`); + }); + + childProcess.on('error', (error) => { + logger.error(`Search process error: ${error.message}`, error); + reject(new Error(`Search process failed to start: ${error.message}`)); + }); + + childProcess.on('close', async (code) => { + logger.debug(`Search process exited with code ${code}`); + + // Even if the process was killed due to limit, code might be null or non-zero. + // Process the results collected so far. + if (code !== 0 && stderrData && results.length === 0) { + // If exited with error code and we have stderr message and no results, reject. + // Filter specific ignorable errors if necessary + if (!stderrData.includes('Index is unavailable') && !stderrData.includes('kMD')) { + // Avoid rejecting for common Spotlight query syntax errors or index issues if some results might still be valid + reject(new Error(`Search process exited with code ${code}: ${stderrData}`)); + return; + } else { + logger.warn( + `Search process exited with code ${code} but contained potentially ignorable errors: ${stderrData}`, + ); + } + } + + try { + // Process the collected file paths + // Ensure limit is applied again here in case killing the process didn't stop exactly at the limit + const limitedResults = + options.limit && results.length > options.limit + ? results.slice(0, options.limit) + : results; + + const processedResults = await this.processSearchResultsFromPaths( + limitedResults, + options, + ); + resolve(processedResults); + } catch (processingError) { + logger.error('Error processing search results:', processingError); + reject(new Error(`Failed to process search results: ${processingError.message}`)); + } + }); + + // Handle live update specific logic (if needed in the future, e.g., sending initial batch) + if (options.liveUpdate) { + // For live update, we might want to resolve an initial batch + // or rely purely on events sent elsewhere. + // Current implementation resolves when the stream closes. + // We could add a timeout to resolve with initial results if needed. + logger.debug('Live update enabled, results will be processed on close.'); + // Note: The previous `executeLiveSearch` logic is now integrated here. + // If specific live update event emission is needed, it would be added here, + // potentially calling a callback provided in options. + } + }); + } + + /** + * Check search service status + * @returns Promise indicating if Spotlight service is available + */ + async checkSearchServiceStatus(): Promise { + return this.checkSpotlightStatus(); + } + + /** + * Update search index + * @param path Optional specified path + * @returns Promise indicating operation success + */ + async updateSearchIndex(path?: string): Promise { + return this.updateSpotlightIndex(path); + } + + /** + * Build mdfind command string + * @param options Search options + * @returns Complete command string + */ + private buildSearchCommand(options: SearchOptions): string { + // Basic command + let command = 'mdfind'; + + // Add options + const mdFindOptions: string[] = []; + + // macOS mdfind doesn't support -limit parameter, we'll limit results in post-processing + + // Search in specific directory + if (options.onlyIn) { + mdFindOptions.push(`-onlyin "${options.onlyIn}"`); + } + + // Live update + if (options.liveUpdate) { + mdFindOptions.push('-live'); + } + + // Detailed metadata + if (options.detailed) { + mdFindOptions.push( + '-attr kMDItemDisplayName kMDItemContentType kMDItemKind kMDItemFSSize kMDItemFSCreationDate kMDItemFSContentChangeDate', + ); + } + + // Build query expression + let queryExpression = ''; + + // Basic query + if (options.keywords) { + // If the query string doesn't use Spotlight query syntax (doesn't contain kMDItem properties), + // treat it as plain text search + if (!options.keywords.includes('kMDItem')) { + queryExpression = `"${options.keywords.replaceAll('"', '\\"')}"`; + } else { + queryExpression = options.keywords; + } + } + + // File content search + if (options.contentContains) { + if (queryExpression) { + queryExpression = `${queryExpression} && kMDItemTextContent == "*${options.contentContains}*"cd`; + } else { + queryExpression = `kMDItemTextContent == "*${options.contentContains}*"cd`; + } + } + + // File type filtering + if (options.fileTypes && options.fileTypes.length > 0) { + const typeConditions = options.fileTypes + .map((type) => `kMDItemContentType == "${type}"`) + .join(' || '); + if (queryExpression) { + queryExpression = `${queryExpression} && (${typeConditions})`; + } else { + queryExpression = `(${typeConditions})`; + } + } + + // Date filtering - Modified date + if (options.modifiedAfter || options.modifiedBefore) { + let dateCondition = ''; + + if (options.modifiedAfter) { + const dateString = options.modifiedAfter.toISOString().split('T')[0]; + dateCondition += `kMDItemFSContentChangeDate >= $time.iso(${dateString})`; + } + + if (options.modifiedBefore) { + if (dateCondition) dateCondition += ' && '; + const dateString = options.modifiedBefore.toISOString().split('T')[0]; + dateCondition += `kMDItemFSContentChangeDate <= $time.iso(${dateString})`; + } + + if (queryExpression) { + queryExpression = `${queryExpression} && (${dateCondition})`; + } else { + queryExpression = dateCondition; + } + } + + // Date filtering - Creation date + if (options.createdAfter || options.createdBefore) { + let dateCondition = ''; + + if (options.createdAfter) { + const dateString = options.createdAfter.toISOString().split('T')[0]; + dateCondition += `kMDItemFSCreationDate >= $time.iso(${dateString})`; + } + + if (options.createdBefore) { + if (dateCondition) dateCondition += ' && '; + const dateString = options.createdBefore.toISOString().split('T')[0]; + dateCondition += `kMDItemFSCreationDate <= $time.iso(${dateString})`; + } + + if (queryExpression) { + queryExpression = `${queryExpression} && (${dateCondition})`; + } else { + queryExpression = dateCondition; + } + } + + // Combine complete command + if (mdFindOptions.length > 0) { + command += ' ' + mdFindOptions.join(' '); + } + + // Finally add query expression + command += ` ${queryExpression}`; + + return command; + } + + /** + * Execute live search, returns initial results and sets callback + * @param command mdfind command + * @param options Search options + * @returns Promise of initial search results + * @deprecated This logic is now integrated into the main search method using spawn. + */ + // private executeLiveSearch(command: string, options: SearchOptions): Promise { ... } + // Remove or comment out the old executeLiveSearch method + + /** + * Process search results from a list of file paths + * @param filePaths Array of file path strings + * @param options Search options + * @returns Formatted file result list + */ + private async processSearchResultsFromPaths( + filePaths: string[], + options: SearchOptions, + ): Promise { + // Create a result object for each file path + const resultPromises = filePaths.map(async (filePath) => { + try { + // Get file information + const stats = await statPromise(filePath); + + // Create basic result object + const result: FileResult = { + createdTime: stats.birthtime, + isDirectory: stats.isDirectory(), + lastAccessTime: stats.atime, + metadata: {}, + modifiedTime: stats.mtime, + name: path.basename(filePath), + path: filePath, + size: stats.size, + type: path.extname(filePath).toLowerCase().replace('.', ''), + }; + + // If detailed information is needed, get additional metadata + if (options.detailed) { + result.metadata = await this.getDetailedMetadata(filePath); + } + + // Determine content type + result.contentType = this.determineContentType(result.name, result.type); + + return result; + } catch (error) { + logger.warn(`Error processing file stats for ${filePath}: ${error.message}`, error); + // Return partial information, even if unable to get complete file stats + return { + contentType: 'unknown', + createdTime: new Date(), + isDirectory: false, + lastAccessTime: new Date(), + modifiedTime: new Date(), + name: path.basename(filePath), + path: filePath, + size: 0, + type: path.extname(filePath).toLowerCase().replace('.', ''), + }; + } + }); + + // Wait for all file information processing to complete + let results = await Promise.all(resultPromises); + + // Sort results + if (options.sortBy) { + results = this.sortResults(results, options.sortBy, options.sortDirection); + } + + // Apply limit here as mdfind doesn't support -limit parameter + if (options.limit && options.limit > 0 && results.length > options.limit) { + results = results.slice(0, options.limit); + } + + return results; + } + + /** + * Process search results + * @param stdout Command output (now unused directly, processing happens line by line) + * @param options Search options + * @returns Formatted file result list + * @deprecated Use processSearchResultsFromPaths instead. + */ + // private async processSearchResults(stdout: string, options: SearchOptions): Promise { ... } + // Remove or comment out the old processSearchResults method + + /** + * Get detailed metadata for a file + * @param filePath File path + * @returns Metadata object + */ + private async getDetailedMetadata(filePath: string): Promise> { + try { + // Use mdls command to get all metadata + const { stdout } = await execPromise(`mdls "${filePath}"`); + + // Parse mdls output + const metadata: Record = {}; + const lines = stdout.split('\n'); + + let currentKey = ''; + let isMultilineValue = false; + let multilineValue: string[] = []; + + for (const line of lines) { + if (isMultilineValue) { + if (line.includes(')')) { + // Multiline value ends + multilineValue.push(line.trim()); + metadata[currentKey] = multilineValue.join(' '); + isMultilineValue = false; + multilineValue = []; + } else { + // Continue collecting multiline value + multilineValue.push(line.trim()); + } + continue; + } + + const match = line.match(/^(\w+)\s+=\s+(.*)$/); + if (match) { + currentKey = match[1]; + const value = match[2].trim(); + + // Check for multiline value start + if (value.includes('(') && !value.includes(')')) { + isMultilineValue = true; + multilineValue = [value]; + } else { + // Process single line value + metadata[currentKey] = this.parseMetadataValue(value); + } + } + } + + return metadata; + } catch (error) { + logger.warn(`Error getting metadata for ${filePath}: ${error.message}`, error); + return {}; + } + } + + /** + * Parse metadata value + * @param value Metadata raw value string + * @returns Parsed value + */ + private parseMetadataValue(input: string): any { + let value = input; + // Remove quotes from mdls output + if (value.startsWith('"') && value.endsWith('"')) { + // eslint-disable-next-line unicorn/prefer-string-slice + value = value.substring(1, value.length - 1); + } + + // Handle special values + if (value === '(null)') return null; + if (value === 'Yes' || value === 'true') return true; + if (value === 'No' || value === 'false') return false; + + // Try to parse date (format like "2023-05-16 14:30:45 +0000") + const dateMatch = value.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} [+-]\d{4})$/); + if (dateMatch) { + try { + return new Date(value); + } catch { + // If date parsing fails, return original string + return value; + } + } + + // Try to parse number + if (/^-?\d+(\.\d+)?$/.test(value)) { + return Number(value); + } + + // Default return string + return value; + } + + /** + * Determine file content type + * @param fileName File name + * @param extension File extension + * @returns Content type description + */ + private determineContentType(fileName: string, extension: string): string { + // Map common file extensions to content types + const typeMap: Record = { + '7z': 'archive', + 'aac': 'audio', + // Others + 'app': 'application', + 'avi': 'video', + 'c': 'code', + 'cpp': 'code', + 'css': 'code', + 'dmg': 'disk-image', + 'doc': 'document', + 'docx': 'document', + 'gif': 'image', + 'gz': 'archive', + 'heic': 'image', + 'html': 'code', + 'iso': 'disk-image', + 'java': 'code', + 'jpeg': 'image', + // Images + 'jpg': 'image', + // Code + 'js': 'code', + 'json': 'code', + 'mkv': 'video', + 'mov': 'video', + // Audio + 'mp3': 'audio', + // Video + 'mp4': 'video', + 'ogg': 'audio', + // Documents + 'pdf': 'document', + 'png': 'image', + 'ppt': 'presentation', + 'pptx': 'presentation', + 'py': 'code', + 'rar': 'archive', + 'rtf': 'text', + 'svg': 'image', + 'swift': 'code', + 'tar': 'archive', + 'ts': 'code', + 'txt': 'text', + 'wav': 'audio', + 'webp': 'image', + 'xls': 'spreadsheet', + 'xlsx': 'spreadsheet', + // Archive files + 'zip': 'archive', + }; + + // Find matching content type + return typeMap[extension.toLowerCase()] || 'unknown'; + } + + /** + * Sort results + * @param results Result list + * @param sortBy Sort field + * @param direction Sort direction + * @returns Sorted result list + */ + private sortResults( + results: FileResult[], + sortBy: 'name' | 'date' | 'size', + direction: 'asc' | 'desc' = 'asc', + ): FileResult[] { + const sortedResults = [...results]; + + sortedResults.sort((a, b) => { + let comparison = 0; + + switch (sortBy) { + case 'name': { + comparison = a.name.localeCompare(b.name); + break; + } + case 'date': { + comparison = a.modifiedTime.getTime() - b.modifiedTime.getTime(); + break; + } + case 'size': { + comparison = a.size - b.size; + break; + } + } + + return direction === 'asc' ? comparison : -comparison; + }); + + return sortedResults; + } + + /** + * Check Spotlight service status + * @returns Promise indicating if Spotlight is available + */ + private async checkSpotlightStatus(): Promise { + try { + // Try to run a simple mdfind command - macOS doesn't support -limit parameter + await execPromise('mdfind -name test -onlyin ~ -count'); + return true; + } catch (error) { + logger.error(`Spotlight is not available: ${error.message}`, error); + return false; + } + } + + /** + * Update Spotlight index + * @param path Optional specified path + * @returns Promise indicating operation success + */ + private async updateSpotlightIndex(path?: string): Promise { + try { + // mdutil command is used to manage Spotlight index + const command = path ? `mdutil -E "${path}"` : 'mdutil -E /'; + + await execPromise(command); + return true; + } catch (error) { + logger.error(`Failed to update Spotlight index: ${error.message}`, error); + return false; + } + } +} diff --git a/apps/desktop/src/main/modules/fileSearch/index.ts b/apps/desktop/src/main/modules/fileSearch/index.ts new file mode 100644 index 0000000000..fbe8b9a06d --- /dev/null +++ b/apps/desktop/src/main/modules/fileSearch/index.ts @@ -0,0 +1,23 @@ +import { platform } from 'node:os'; + +import { MacOSSearchServiceImpl } from './impl/macOS'; + +export const createFileSearchModule = () => { + const currentPlatform = platform(); + + switch (currentPlatform) { + case 'darwin': { + return new MacOSSearchServiceImpl(); + } + // case 'win32': + // return new WindowsSearchServiceImpl(); + // case 'linux': + // return new LinuxSearchServiceImpl(); + default: { + return new MacOSSearchServiceImpl(); + // throw new Error(`Unsupported platform: ${currentPlatform}`); + } + } +}; + +export { FileSearchImpl } from './type'; diff --git a/apps/desktop/src/main/modules/fileSearch/type.ts b/apps/desktop/src/main/modules/fileSearch/type.ts new file mode 100644 index 0000000000..6ede6322ae --- /dev/null +++ b/apps/desktop/src/main/modules/fileSearch/type.ts @@ -0,0 +1,27 @@ +import { FileResult, SearchOptions } from '@/types/fileSearch'; + +/** + * File Search Service Implementation Abstract Class + * Defines the interface that different platform file search implementations need to implement + */ +export abstract class FileSearchImpl { + /** + * Perform file search + * @param options Search options + * @returns Promise of search result list + */ + abstract search(options: SearchOptions): Promise; + + /** + * Check search service status + * @returns Promise indicating if service is available + */ + abstract checkSearchServiceStatus(): Promise; + + /** + * Update search index + * @param path Optional specified path + * @returns Promise indicating operation success + */ + abstract updateSearchIndex(path?: string): Promise; +} diff --git a/apps/desktop/src/main/modules/updater/configs.ts b/apps/desktop/src/main/modules/updater/configs.ts new file mode 100644 index 0000000000..1c4aa3928c --- /dev/null +++ b/apps/desktop/src/main/modules/updater/configs.ts @@ -0,0 +1,22 @@ +import { isDev } from '@/const/env'; + +// 更新频道(stable, beta, alpha 等) +export const UPDATE_CHANNEL = process.env.UPDATE_CHANNEL; + +export const updaterConfig = { + // 应用更新配置 + app: { + // 是否自动检查更新 + autoCheckUpdate: true, + // 是否自动下载更新 + autoDownloadUpdate: true, + // 检查更新的时间间隔(毫秒) + checkUpdateInterval: 60 * 60 * 1000, // 1小时 + }, + + // 是否启用应用更新 + enableAppUpdate: !isDev, + + // 是否启用渲染层热更新 + enableRenderHotUpdate: !isDev, +}; diff --git a/apps/desktop/src/main/modules/updater/utils.ts b/apps/desktop/src/main/modules/updater/utils.ts new file mode 100644 index 0000000000..e358896992 --- /dev/null +++ b/apps/desktop/src/main/modules/updater/utils.ts @@ -0,0 +1,33 @@ +import semver from 'semver'; + +/** + * 判断是否需要应用更新而非仅渲染层更新 + * @param currentVersion 当前版本 + * @param nextVersion 新版本 + * @returns 是否需要应用更新 + */ +export const shouldUpdateApp = (currentVersion: string, nextVersion: string): boolean => { + // 如果版本号包含 .app 后缀,强制进行应用更新 + if (nextVersion.includes('.app')) { + return true; + } + + try { + // 解析版本号 + const current = semver.parse(currentVersion); + const next = semver.parse(nextVersion); + + if (!current || !next) return true; + + // 主版本号或次版本号变更时,需要进行应用更新 + if (current.major !== next.major || current.minor !== next.minor) { + return true; + } + + // 仅修订版本号变更,优先进行渲染层热更新 + return false; + } catch { + // 解析失败时,默认进行应用更新 + return true; + } +}; diff --git a/apps/desktop/src/main/services/fileSearchSrv.ts b/apps/desktop/src/main/services/fileSearchSrv.ts new file mode 100644 index 0000000000..a631e88981 --- /dev/null +++ b/apps/desktop/src/main/services/fileSearchSrv.ts @@ -0,0 +1,35 @@ +import { FileSearchImpl, createFileSearchModule } from '@/modules/fileSearch'; +import { FileResult, SearchOptions } from '@/types/fileSearch'; + +import { ServiceModule } from './index'; + +/** + * File Search Service + * Main service class that uses platform-specific implementations internally + */ +export default class FileSearchService extends ServiceModule { + private impl: FileSearchImpl = createFileSearchModule(); + + /** + * Perform file search + */ + async search(query: string, options: Omit = {}): Promise { + return this.impl.search({ ...options, keywords: query }); + } + + /** + * Check search service status + */ + async checkSearchServiceStatus(): Promise { + return this.impl.checkSearchServiceStatus(); + } + + /** + * Update search index + * @param path Optional specified path + * @returns Promise indicating operation success + */ + async updateSearchIndex(path?: string): Promise { + return this.impl.updateSearchIndex(path); + } +} diff --git a/apps/desktop/src/main/services/fileSrv.ts b/apps/desktop/src/main/services/fileSrv.ts new file mode 100644 index 0000000000..4f4474db5f --- /dev/null +++ b/apps/desktop/src/main/services/fileSrv.ts @@ -0,0 +1,255 @@ +import { DeleteFilesResponse } from '@lobechat/electron-server-ipc'; +import * as fs from 'node:fs'; +import { writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; + +import { FILE_STORAGE_DIR } from '@/const/dir'; +import { makeSureDirExist } from '@/utils/file-system'; + +import { ServiceModule } from './index'; + +const readFilePromise = promisify(fs.readFile); +const unlinkPromise = promisify(fs.unlink); + +interface UploadFileParams { + content: ArrayBuffer; + filename: string; + hash: string; + path: string; + type: string; +} + +interface FileMetadata { + date: string; + dirname: string; + filename: string; + path: string; +} + +export default class FileService extends ServiceModule { + get UPLOADS_DIR() { + return join(this.app.appStoragePath, FILE_STORAGE_DIR, 'uploads'); + } + + constructor(app) { + super(app); + + // 初始化文件存储目录 + makeSureDirExist(this.UPLOADS_DIR); + } + + /** + * 上传文件到本地存储 + */ + async uploadFile({ + content, + filename, + hash, + type, + }: UploadFileParams): Promise<{ metadata: FileMetadata; success: boolean }> { + try { + // 创建时间戳目录 + const date = (Date.now() / 1000 / 60 / 60).toFixed(0); + const dirname = join(this.UPLOADS_DIR, date); + makeSureDirExist(dirname); + + // 生成文件保存路径 + const fileExt = filename.split('.').pop() || ''; + const savedFilename = `${hash}${fileExt ? `.${fileExt}` : ''}`; + const savedPath = join(dirname, savedFilename); + + // 写入文件内容 + const buffer = Buffer.from(content); + await writeFile(savedPath, buffer); + + // 写入元数据文件 + const metaFilePath = `${savedPath}.meta`; + const metadata = { + createdAt: Date.now(), + filename, + hash, + size: buffer.length, + type, + }; + await writeFile(metaFilePath, JSON.stringify(metadata, null, 2)); + + // 返回与S3兼容的元数据格式 + const desktopPath = `desktop://${date}/${savedFilename}`; + + return { + metadata: { + date, + dirname: date, + filename: savedFilename, + path: desktopPath, + }, + success: true, + }; + } catch (error) { + console.error('File upload failed:', error); + throw new Error(`File upload failed: ${(error as Error).message}`); + } + } + + /** + * 获取文件内容 + */ + async getFile(path: string): Promise<{ content: ArrayBuffer; mimeType: string }> { + try { + // 处理desktop://路径 + if (!path.startsWith('desktop://')) { + throw new Error(`Invalid desktop file path: ${path}`); + } + + // 标准化路径格式 + // 可能收到的格式: desktop:/12345/file.png 或 desktop://12345/file.png + const normalizedPath = path.replace(/^desktop:\/+/, 'desktop://'); + + // 解析路径 + const relativePath = normalizedPath.replace('desktop://', ''); + const filePath = join(this.UPLOADS_DIR, relativePath); + + console.log('Reading file from:', filePath); + + // 读取文件内容 + const content = await readFilePromise(filePath); + + // 读取元数据获取MIME类型 + const metaFilePath = `${filePath}.meta`; + let mimeType = 'application/octet-stream'; // 默认MIME类型 + + try { + const metaContent = await readFilePromise(metaFilePath, 'utf8'); + const metadata = JSON.parse(metaContent); + mimeType = metadata.type || mimeType; + } catch (metaError) { + console.warn(`Failed to read metadata file: ${metaError.message}, using default MIME type`); + // 如果元数据文件不存在,尝试从文件扩展名猜测MIME类型 + const ext = path.split('.').pop()?.toLowerCase(); + if (ext) { + if (['jpg', 'jpeg'].includes(ext)) mimeType = 'image/jpeg'; + else + switch (ext) { + case 'png': { + mimeType = 'image/png'; + break; + } + case 'gif': { + mimeType = 'image/gif'; + break; + } + case 'webp': { + mimeType = 'image/webp'; + break; + } + case 'svg': { + mimeType = 'image/svg+xml'; + break; + } + case 'pdf': { + { + mimeType = 'application/pdf'; + // No default + } + break; + } + } + } + } + + return { + content: content.buffer as ArrayBuffer, + mimeType, + }; + } catch (error) { + console.error('File retrieval failed:', error); + throw new Error(`File retrieval failed: ${(error as Error).message}`); + } + } + + /** + * 删除文件 + */ + async deleteFile(path: string): Promise<{ success: boolean }> { + try { + // 处理desktop://路径 + if (!path.startsWith('desktop://')) { + throw new Error(`Invalid desktop file path: ${path}`); + } + + // 解析路径 + const relativePath = path.replace('desktop://', ''); + const filePath = join(this.UPLOADS_DIR, relativePath); + + // 删除文件及其元数据 + await unlinkPromise(filePath); + + // 尝试删除元数据文件,但不强制要求存在 + try { + await unlinkPromise(`${filePath}.meta`); + } catch (error) { + console.warn(`Failed to delete metadata file: ${(error as Error).message}`); + } + + return { success: true }; + } catch (error) { + console.error('File deletion failed:', error); + throw new Error(`File deletion failed: ${(error as Error).message}`); + } + } + + /** + * 批量删除文件 + */ + async deleteFiles(paths: string[]): Promise { + const errors: { message: string; path: string }[] = []; + + // 并行处理所有删除请求 + const results = await Promise.allSettled( + paths.map(async (path) => { + try { + await this.deleteFile(path); + return { path, success: true }; + } catch (error) { + return { + error: (error as Error).message, + path, + success: false, + }; + } + }), + ); + + // 处理结果 + results.forEach((result) => { + if (result.status === 'rejected') { + errors.push({ + message: `Unexpected error: ${result.reason}`, + path: 'unknown', + }); + } else if (!result.value.success) { + errors.push({ + message: result.value.error, + path: result.value.path, + }); + } + }); + + return { + success: errors.length === 0, + ...(errors.length > 0 && { errors }), + }; + } + + async getFilePath(path: string): Promise { + // 处理desktop://路径 + if (!path.startsWith('desktop://')) { + throw new Error(`Invalid desktop file path: ${path}`); + } + + // 解析路径 + const relativePath = path.replace('desktop://', ''); + return join(this.UPLOADS_DIR, relativePath); + } +} diff --git a/apps/desktop/src/main/services/index.ts b/apps/desktop/src/main/services/index.ts new file mode 100644 index 0000000000..7e15690273 --- /dev/null +++ b/apps/desktop/src/main/services/index.ts @@ -0,0 +1,9 @@ +import type { App } from '../core/App'; + +export class ServiceModule { + constructor(public app: App) { + this.app = app; + } +} + +export type IServiceModule = typeof ServiceModule; diff --git a/apps/desktop/src/main/shortcuts/config.ts b/apps/desktop/src/main/shortcuts/config.ts new file mode 100644 index 0000000000..36fd5f5827 --- /dev/null +++ b/apps/desktop/src/main/shortcuts/config.ts @@ -0,0 +1,18 @@ +/** + * 快捷键操作类型枚举 + */ +export const ShortcutActionEnum = { + /** + * 显示/隐藏主窗口 + */ + toggleMainWindow: 'toggleMainWindow', +} as const; + +export type ShortcutActionType = (typeof ShortcutActionEnum)[keyof typeof ShortcutActionEnum]; + +/** + * 默认快捷键配置 + */ +export const DEFAULT_SHORTCUTS_CONFIG: Record = { + [ShortcutActionEnum.toggleMainWindow]: 'CommandOrControl+E', +}; diff --git a/apps/desktop/src/main/shortcuts/index.ts b/apps/desktop/src/main/shortcuts/index.ts new file mode 100644 index 0000000000..f03c2281a9 --- /dev/null +++ b/apps/desktop/src/main/shortcuts/index.ts @@ -0,0 +1 @@ +export * from './config'; diff --git a/apps/desktop/src/main/types/fileSearch.ts b/apps/desktop/src/main/types/fileSearch.ts new file mode 100644 index 0000000000..fcc3d54e05 --- /dev/null +++ b/apps/desktop/src/main/types/fileSearch.ts @@ -0,0 +1,51 @@ +export interface FileResult { + contentType?: string; + createdTime: Date; + isDirectory: boolean; + lastAccessTime: Date; + // Spotlight specific metadata + metadata?: { + [key: string]: any; + }; + modifiedTime: Date; + name: string; + path: string; + size: number; + type: string; +} + +export interface SearchOptions { + // Directory options + // Content options + contentContains?: string; + // Created after specific date + createdAfter?: Date; + + // Created before specific date + createdBefore?: Date; + // Whether to return detailed results + detailed?: boolean; + + // Limit search to specific directories + exclude?: string[]; // Files containing specific content + + // File type options + fileTypes?: string[]; + + // Basic options + keywords: string; + limit?: number; + // Created before specific date + // Advanced options + liveUpdate?: boolean; + // File type filters, like "public.image", "public.movie" + // Time options + modifiedAfter?: Date; + + // Modified after specific date + modifiedBefore?: Date; + // Path options + onlyIn?: string; // Whether to return detailed metadata + sortBy?: 'name' | 'date' | 'size'; // Result sorting + sortDirection?: 'asc' | 'desc'; // Sort direction +} diff --git a/apps/desktop/src/main/types/store.ts b/apps/desktop/src/main/types/store.ts new file mode 100644 index 0000000000..df04280181 --- /dev/null +++ b/apps/desktop/src/main/types/store.ts @@ -0,0 +1,14 @@ +import { DataSyncConfig } from '@lobechat/electron-client-ipc'; + +export interface ElectronMainStore { + dataSyncConfig: DataSyncConfig; + encryptedTokens: { + accessToken?: string; + refreshToken?: string; + }; + locale: string; + shortcuts: Record; + storagePath: string; +} + +export type StoreKey = keyof ElectronMainStore; diff --git a/apps/desktop/src/main/utils/file-system.ts b/apps/desktop/src/main/utils/file-system.ts new file mode 100644 index 0000000000..eb8f4a50e5 --- /dev/null +++ b/apps/desktop/src/main/utils/file-system.ts @@ -0,0 +1,15 @@ +import { mkdirSync, statSync } from 'node:fs'; + +export const makeSureDirExist = (dir: string) => { + try { + statSync(dir); + } catch { + // 使用 recursive: true,如果目录已存在则此操作无效果,如果不存在则创建 + try { + mkdirSync(dir, { recursive: true }); + } catch (mkdirError: any) { + // 如果创建目录失败(例如权限问题),则抛出错误 + throw new Error(`Could not create target directory: ${dir}. Error: ${mkdirError.message}`); + } + } +}; diff --git a/apps/desktop/src/main/utils/logger.ts b/apps/desktop/src/main/utils/logger.ts new file mode 100644 index 0000000000..679c3db8e2 --- /dev/null +++ b/apps/desktop/src/main/utils/logger.ts @@ -0,0 +1,44 @@ +import debug from 'debug'; +import electronLog from 'electron-log'; + +// 配置 electron-log +electronLog.transports.file.level = 'info'; // 生产环境记录 info 及以上级别 +electronLog.transports.console.level = + process.env.NODE_ENV === 'development' + ? 'debug' // 开发环境显示更多日志 + : 'warn'; // 生产环境只显示警告和错误 + +// 创建命名空间调试器 +export const createLogger = (namespace: string) => { + const debugLogger = debug(namespace); + + return { + debug: (message, ...args) => { + debugLogger(message, ...args); + }, + error: (message, ...args) => { + if (process.env.NODE_ENV === 'production') { + electronLog.error(message, ...args); + } + debugLogger(`ERROR: ${message}`, ...args); + }, + info: (message, ...args) => { + if (process.env.NODE_ENV === 'production') { + electronLog.info(message, ...args); + } + debugLogger(`INFO: ${message}`, ...args); + }, + verbose: (message, ...args) => { + electronLog.verbose(message, ...args); + if (process.env.DEBUG_VERBOSE) { + debugLogger(`VERBOSE: ${message}`, ...args); + } + }, + warn: (message, ...args) => { + if (process.env.NODE_ENV === 'production') { + electronLog.warn(message, ...args); + } + debugLogger(`WARN: ${message}`, ...args); + }, + }; +}; diff --git a/apps/desktop/src/main/utils/next-electron-rsc.ts b/apps/desktop/src/main/utils/next-electron-rsc.ts new file mode 100644 index 0000000000..2b9ed42012 --- /dev/null +++ b/apps/desktop/src/main/utils/next-electron-rsc.ts @@ -0,0 +1,383 @@ +// copy from https://github.com/kirill-konshin/next-electron-rsc +import { serialize as serializeCookie } from 'cookie'; +import type { Protocol, Session } from 'electron'; +import type { NextConfig } from 'next'; +import type NextNodeServer from 'next/dist/server/next-server'; +import assert from 'node:assert'; +import { IncomingMessage, ServerResponse } from 'node:http'; +import { Socket } from 'node:net'; +import path from 'node:path'; +import { parse } from 'node:url'; +import resolve from 'resolve'; +import { parse as parseCookie, splitCookiesString } from 'set-cookie-parser'; + +import { isDev } from '@/const/env'; +import { createLogger } from '@/utils/logger'; + +// 创建日志记录器 +const logger = createLogger('utils:next-electron-rsc'); + +// 定义自定义处理器类型 +export type CustomRequestHandler = (request: Request) => Promise; + +export const createRequest = async ({ + socket, + request, + session, +}: { + request: Request; + session: Session; + socket: Socket; +}): Promise => { + const req = new IncomingMessage(socket); + + const url = new URL(request.url); + + // Normal Next.js URL does not contain schema and host/port, otherwise endless loops due to butchering of schema by normalizeRepeatedSlashes in resolve-routes + req.url = url.pathname + (url.search || ''); + req.method = request.method; + + request.headers.forEach((value, key) => { + req.headers[key] = value; + }); + + try { + // @see https://github.com/electron/electron/issues/39525#issue-1852825052 + const cookies = await session.cookies.get({ + url: request.url, + // domain: url.hostname, + // path: url.pathname, + // `secure: true` Cookies should not be sent via http + // secure: url.protocol === 'http:' ? false : undefined, + // theoretically not possible to implement sameSite because we don't know the url + // of the website that is requesting the resource + }); + + if (cookies.length) { + const cookiesHeader = []; + + for (const cookie of cookies) { + const { name, value } = cookie; + cookiesHeader.push(serializeCookie(name, value)); // ...(options as any)? + } + + req.headers.cookie = cookiesHeader.join('; '); + } + } catch (e) { + throw new Error('Failed to parse cookies', { cause: e }); + } + + if (request.body) { + req.push(Buffer.from(await request.arrayBuffer())); + } + + req.push(null); + req.complete = true; + + return req; +}; + +export class ReadableServerResponse extends ServerResponse { + private responsePromise: Promise; + + constructor(req: IncomingMessage) { + super(req); + + this.responsePromise = new Promise((resolve) => { + const readableStream = new ReadableStream({ + cancel: () => {}, + pull: () => { + this.emit('drain'); + }, + start: (controller) => { + let onData; + + this.on( + 'data', + (onData = (chunk) => { + controller.enqueue(chunk); + }), + ); + + this.once('end', (chunk) => { + controller.enqueue(chunk); + controller.close(); + this.off('data', onData); + }); + }, + }); + + this.once('writeHead', (statusCode) => { + resolve( + new Response(readableStream, { + headers: this.getHeaders() as any, + status: statusCode, + statusText: this.statusMessage, + }), + ); + }); + }); + } + + write(chunk: any, ...args): boolean { + this.emit('data', chunk); + return super.write(chunk, ...args); + } + + end(chunk: any, ...args): this { + this.emit('end', chunk); + return super.end(chunk, ...args); + } + + writeHead(statusCode: number, ...args: any): this { + this.emit('writeHead', statusCode); + return super.writeHead(statusCode, ...args); + } + + getResponse() { + return this.responsePromise; + } +} + +/** + * https://nextjs.org/docs/pages/building-your-application/configuring/custom-server + * https://github.com/vercel/next.js/pull/68167/files#diff-d0d8b7158bcb066cdbbeb548a29909fe8dc4e98f682a6d88654b1684e523edac + * https://github.com/vercel/next.js/blob/canary/examples/custom-server/server.ts + * + * @param {string} standaloneDir + * @param {string} localhostUrl + * @param {import('electron').Protocol} protocol + * @param {boolean} debug + */ +export function createHandler({ + standaloneDir, + localhostUrl, + protocol, + debug = false, +}: { + debug?: boolean; + localhostUrl: string; + protocol: Protocol; + standaloneDir: string; +}) { + assert(standaloneDir, 'standaloneDir is required'); + assert(protocol, 'protocol is required'); + + // 存储自定义请求处理器的数组 + const customHandlers: CustomRequestHandler[] = []; + + // 注册自定义请求处理器的方法 - 在开发和生产环境中都提供此功能 + function registerCustomHandler(handler: CustomRequestHandler) { + logger.debug('Registering custom request handler'); + customHandlers.push(handler); + return () => { + const index = customHandlers.indexOf(handler); + if (index !== -1) { + logger.debug('Unregistering custom request handler'); + customHandlers.splice(index, 1); + } + }; + } + let registerProtocolHandle = false; + + protocol.registerSchemesAsPrivileged([ + { + privileges: { + secure: true, + standard: true, + supportFetchAPI: true, + }, + scheme: 'http', + }, + ]); + logger.debug('Registered HTTP scheme as privileged'); + + // 初始化 Next.js 应用(仅在生产环境中使用) + let app: NextNodeServer | null = null; + let handler: any = null; + let preparePromise: Promise | null = null; + + if (!isDev) { + logger.info('Initializing Next.js app for production'); + const next = require(resolve.sync('next', { basedir: standaloneDir })); + + // @see https://github.com/vercel/next.js/issues/64031#issuecomment-2078708340 + const config = require(path.join(standaloneDir, '.next', 'required-server-files.json')) + .config as NextConfig; + process.env.__NEXT_PRIVATE_STANDALONE_CONFIG = JSON.stringify(config); + + app = next({ + dev: false, + dir: standaloneDir, + }) as NextNodeServer; + + handler = app.getRequestHandler(); + preparePromise = app.prepare(); + } else { + logger.debug('Starting in development mode'); + } + + // 通用的请求处理函数 - 开发和生产环境共用 + const handleRequest = async ( + request: Request, + session: Session, + socket: Socket, + ): Promise => { + try { + // 先尝试使用自定义处理器处理请求 + for (const customHandler of customHandlers) { + try { + const response = await customHandler(request); + if (response) { + if (debug) logger.debug(`Custom handler processed: ${request.url}`); + return response; + } + } catch (error) { + if (debug) logger.error(`Custom handler error: ${error}`); + // 继续尝试下一个处理器 + } + } + + // 创建 Node.js 请求对象 + const req = await createRequest({ request, session, socket }); + // 创建可读取响应的 Response 对象 + const res = new ReadableServerResponse(req); + + if (isDev) { + // 开发环境:转发请求到开发服务器 + if (debug) logger.debug(`Forwarding request to dev server: ${request.url}`); + + // 修改 URL 以指向开发服务器 + const devUrl = new URL(req.url, localhostUrl); + + // 使用 node:http 模块发送请求到开发服务器 + const http = require('node:http'); + const devReq = http.request( + { + headers: req.headers, + hostname: devUrl.hostname, + method: req.method, + path: devUrl.pathname + (devUrl.search || ''), + port: devUrl.port, + }, + (devRes) => { + // 设置响应状态码和头部 + res.statusCode = devRes.statusCode; + res.statusMessage = devRes.statusMessage; + + // 复制响应头 + Object.keys(devRes.headers).forEach((key) => { + res.setHeader(key, devRes.headers[key]); + }); + + // 流式传输响应内容 + devRes.pipe(res); + }, + ); + + // 处理错误 + devReq.on('error', (err) => { + if (debug) logger.error(`Error forwarding request: ${err}`); + }); + + // 传输请求体 + req.pipe(devReq); + } else { + // 生产环境:使用 Next.js 处理请求 + if (debug) logger.debug(`Processing with Next.js handler: ${request.url}`); + + // 确保 Next.js 已准备就绪 + if (preparePromise) await preparePromise; + + const url = parse(req.url, true); + handler(req, res, url); + } + + // 获取 Response 对象 + const response = await res.getResponse(); + + // 处理 cookies(两种环境通用处理) + try { + const cookies = parseCookie( + response.headers.getSetCookie().reduce((r, c) => { + return [...r, ...splitCookiesString(c)]; + }, []), + ); + + for (const cookie of cookies) { + const expires = cookie.expires + ? cookie.expires.getTime() + : cookie.maxAge + ? Date.now() + cookie.maxAge * 1000 + : undefined; + + if (expires && expires < Date.now()) { + await session.cookies.remove(request.url, cookie.name); + continue; + } + + await session.cookies.set({ + domain: cookie.domain, + expirationDate: expires, + httpOnly: cookie.httpOnly, + name: cookie.name, + path: cookie.path, + secure: cookie.secure, + url: request.url, + value: cookie.value, + } as any); + } + } catch (e) { + logger.error('Failed to set cookies', e); + } + + if (debug) logger.debug(`Request processed: ${request.url}, status: ${response.status}`); + return response; + } catch (e) { + if (debug) logger.error(`Error handling request: ${e}`); + return new Response(e.message, { status: 500 }); + } + }; + + // 创建拦截器函数 + const createInterceptor = ({ session }: { session: Session }) => { + assert(session, 'Session is required'); + logger.debug( + `Creating interceptor with session in ${isDev ? 'development' : 'production'} mode`, + ); + + const socket = new Socket(); + + const closeSocket = () => socket.end(); + + process.on('SIGTERM', () => closeSocket); + process.on('SIGINT', () => closeSocket); + + if (!isDev && !registerProtocolHandle) { + logger.debug( + `Registering HTTP protocol handler in ${isDev ? 'development' : 'production'} mode`, + ); + protocol.handle('http', async (request) => { + if (!isDev) { + assert(request.url.startsWith(localhostUrl), 'External HTTP not supported, use HTTPS'); + } + + return handleRequest(request, session, socket); + }); + registerProtocolHandle = true; + } + + return function stopIntercept() { + if (registerProtocolHandle) { + logger.debug('Unregistering HTTP protocol handler'); + protocol.unhandle('http'); + registerProtocolHandle = false; + } + process.off('SIGTERM', () => closeSocket); + process.off('SIGINT', () => closeSocket); + closeSocket(); + }; + }; + + return { createInterceptor, registerCustomHandler }; +} diff --git a/apps/desktop/src/preload/electronApi.ts b/apps/desktop/src/preload/electronApi.ts new file mode 100644 index 0000000000..2d870383f6 --- /dev/null +++ b/apps/desktop/src/preload/electronApi.ts @@ -0,0 +1,18 @@ +import { electronAPI } from '@electron-toolkit/preload'; +import { contextBridge } from 'electron'; + +import { invoke } from './invoke'; + +export const setupElectronApi = () => { + // Use `contextBridge` APIs to expose Electron APIs to + // renderer only if context isolation is enabled, otherwise + // just add to the DOM global. + + try { + contextBridge.exposeInMainWorld('electron', electronAPI); + } catch (error) { + console.error(error); + } + + contextBridge.exposeInMainWorld('electronAPI', { invoke }); +}; diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts new file mode 100644 index 0000000000..064996c86e --- /dev/null +++ b/apps/desktop/src/preload/index.ts @@ -0,0 +1,14 @@ +import { setupElectronApi } from './electronApi'; +import { setupRouteInterceptors } from './routeInterceptor'; + +const setupPreload = () => { + setupElectronApi(); + + // 设置路由拦截逻辑 + window.addEventListener('DOMContentLoaded', () => { + // 设置客户端路由拦截器 + setupRouteInterceptors(); + }); +}; + +setupPreload(); diff --git a/apps/desktop/src/preload/invoke.ts b/apps/desktop/src/preload/invoke.ts new file mode 100644 index 0000000000..d7779c4ef5 --- /dev/null +++ b/apps/desktop/src/preload/invoke.ts @@ -0,0 +1,10 @@ +import { ClientDispatchEventKey, DispatchInvoke } from '@lobechat/electron-client-ipc'; +import { ipcRenderer } from 'electron'; + +/** + * client 端请求 electron main 端方法 + */ +export const invoke: DispatchInvoke = async ( + event: T, + ...data: any[] +) => ipcRenderer.invoke(event, ...data); diff --git a/apps/desktop/src/preload/routeInterceptor.ts b/apps/desktop/src/preload/routeInterceptor.ts new file mode 100644 index 0000000000..7dac284f2d --- /dev/null +++ b/apps/desktop/src/preload/routeInterceptor.ts @@ -0,0 +1,162 @@ +import { findMatchingRoute } from '~common/routes'; + +import { invoke } from './invoke'; + +const interceptRoute = async ( + path: string, + source: 'link-click' | 'push-state' | 'replace-state', + url: string, +) => { + console.log(`[preload] Intercepted ${source} and prevented default behavior:`, path); + + // 使用electron-client-ipc的dispatch方法 + try { + await invoke('interceptRoute', { path, source, url }); + } catch (e) { + console.error(`[preload] Route interception (${source}) call failed`, e); + } +}; +/** + * 路由拦截器 - 负责捕获和拦截客户端路由导航 + */ +export const setupRouteInterceptors = function () { + console.log('[preload] Setting up route interceptors'); + + // 存储被阻止的路径,避免pushState重复触发 + const preventedPaths = new Set(); + + // 拦截所有a标签的点击事件 - 针对Next.js的Link组件 + document.addEventListener( + 'click', + async (e) => { + const link = (e.target as HTMLElement).closest('a'); + if (link && link.href) { + try { + const url = new URL(link.href); + + // 使用共享配置检查是否需要拦截 + const matchedRoute = findMatchingRoute(url.pathname); + + // 如果是需要拦截的路径,立即阻止默认行为 + if (matchedRoute) { + // 检查当前页面是否已经在目标路径下,如果是则不拦截 + const currentPath = window.location.pathname; + const isAlreadyInTargetPage = currentPath.startsWith(matchedRoute.pathPrefix); + + // 如果已经在目标页面下,则不拦截,让默认导航继续 + if (isAlreadyInTargetPage) return; + + // 立即阻止默认行为,避免Next.js接管路由 + e.preventDefault(); + e.stopPropagation(); + + await interceptRoute(url.pathname, 'link-click', link.href); + + return false; + } + } catch (err) { + console.error('[preload] Link interception error:', err); + } + } + }, + true, + ); + + // 拦截 history API (用于捕获Next.js的useRouter().push/replace等) + const originalPushState = history.pushState; + const originalReplaceState = history.replaceState; + + // 重写pushState + history.pushState = function () { + const url = arguments[2]; + if (typeof url === 'string') { + try { + // 只处理相对路径或当前域的URL + const parsedUrl = new URL(url, window.location.origin); + + // 使用共享配置检查是否需要拦截 + const matchedRoute = findMatchingRoute(parsedUrl.pathname); + + // 检查是否需要拦截这个导航 + if (matchedRoute) { + // 检查当前页面是否已经在目标路径下,如果是则不拦截 + const currentPath = window.location.pathname; + const isAlreadyInTargetPage = currentPath.startsWith(matchedRoute.pathPrefix); + + // 如果已经在目标页面下,则不拦截,让默认导航继续 + if (isAlreadyInTargetPage) { + console.log( + `[preload] Skip pushState interception for ${parsedUrl.pathname} because already in target page ${matchedRoute.pathPrefix}`, + ); + return Reflect.apply(originalPushState, this, arguments); + } + + // 将此路径添加到已阻止集合中 + preventedPaths.add(parsedUrl.pathname); + + interceptRoute(parsedUrl.pathname, 'push-state', parsedUrl.href); + + // 不执行原始的pushState操作,阻止导航发生 + // 但返回undefined以避免错误 + return; + } + } catch (err) { + console.error('[preload] pushState interception error:', err); + } + } + return Reflect.apply(originalPushState, this, arguments); + }; + + // 重写replaceState + history.replaceState = function () { + const url = arguments[2]; + if (typeof url === 'string') { + try { + const parsedUrl = new URL(url, window.location.origin); + + // 使用共享配置检查是否需要拦截 + const matchedRoute = findMatchingRoute(parsedUrl.pathname); + + // 检查是否需要拦截这个导航 + if (matchedRoute) { + // 检查当前页面是否已经在目标路径下,如果是则不拦截 + const currentPath = window.location.pathname; + const isAlreadyInTargetPage = currentPath.startsWith(matchedRoute.pathPrefix); + + // 如果已经在目标页面下,则不拦截,让默认导航继续 + if (isAlreadyInTargetPage) { + console.log( + `[preload] Skip replaceState interception for ${parsedUrl.pathname} because already in target page ${matchedRoute.pathPrefix}`, + ); + return Reflect.apply(originalReplaceState, this, arguments); + } + + // 添加到已阻止集合 + preventedPaths.add(parsedUrl.pathname); + + interceptRoute(parsedUrl.pathname, 'replace-state', parsedUrl.href); + + // 阻止导航 + return; + } + } catch (err) { + console.error('[preload] replaceState interception error:', err); + } + } + return Reflect.apply(originalReplaceState, this, arguments); + }; + + // 监听并拦截路由错误 - 有时Next.js会在路由错误时尝试恢复导航 + window.addEventListener( + 'error', + function (e) { + if (e.message && e.message.includes('navigation') && preventedPaths.size > 0) { + console.log('[preload] Captured possible routing error, preventing default behavior'); + e.preventDefault(); + } + }, + true, + ); + + console.log('[preload] Route interceptors setup completed'); +}; diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json new file mode 100644 index 0000000000..0875f8ddf4 --- /dev/null +++ b/apps/desktop/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "allowJs": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "target": "ESNext", + "esModuleInterop": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/main/*"], + "~common/*": ["src/common/*"] + } + }, + "include": ["src/main/**/*", "src/preload/**/*", "electron-builder.js"] +} diff --git a/packages/electron-client-ipc/src/events/remoteServer.ts b/packages/electron-client-ipc/src/events/remoteServer.ts index 3d4e0b46d4..7ab92f4f18 100644 --- a/packages/electron-client-ipc/src/events/remoteServer.ts +++ b/packages/electron-client-ipc/src/events/remoteServer.ts @@ -1,20 +1,27 @@ -import { RemoteServerConfig } from '../types/remoteServer'; +import { DataSyncConfig } from '../types/dataSync'; +import { ProxyTRPCRequestParams, ProxyTRPCRequestResult } from '../types/proxyTRPCRequest'; /** * 远程服务器配置相关的事件 */ export interface RemoteServerDispatchEvents { clearRemoteServerConfig: () => boolean; - getRemoteServerConfig: () => RemoteServerConfig; + getRemoteServerConfig: () => DataSyncConfig; + /** + * Proxy a tRPC request to the remote server. + * @param args - Request arguments. + * @returns Promise resolving with the response details. + */ + proxyTRPCRequest: (args: ProxyTRPCRequestParams) => ProxyTRPCRequestResult; refreshAccessToken: () => { error?: string; success: boolean; }; - requestAuthorization: (serverUrl: string) => { + requestAuthorization: (config: DataSyncConfig) => { error?: string; success: boolean; }; - setRemoteServerConfig: (config: RemoteServerConfig) => boolean; + setRemoteServerConfig: (config: DataSyncConfig) => boolean; } /** diff --git a/packages/electron-client-ipc/src/types/dataSync.ts b/packages/electron-client-ipc/src/types/dataSync.ts new file mode 100644 index 0000000000..5f174150fc --- /dev/null +++ b/packages/electron-client-ipc/src/types/dataSync.ts @@ -0,0 +1,15 @@ +export type StorageMode = 'local' | 'cloud' | 'selfHost'; +export enum StorageModeEnum { + Cloud = 'cloud', + Local = 'local', + SelfHost = 'selfHost', +} + +/** + * 远程服务器配置相关的事件 + */ +export interface DataSyncConfig { + active?: boolean; + remoteServerUrl?: string; + storageMode: StorageMode; +} diff --git a/packages/electron-client-ipc/src/types/index.ts b/packages/electron-client-ipc/src/types/index.ts index 3a1df38d7d..0ea853a099 100644 --- a/packages/electron-client-ipc/src/types/index.ts +++ b/packages/electron-client-ipc/src/types/index.ts @@ -1,6 +1,7 @@ +export * from './dataSync'; export * from './dispatch'; export * from './localFile'; -export * from './remoteServer'; +export * from './proxyTRPCRequest'; export * from './route'; export * from './shortcut'; export * from './system'; diff --git a/packages/electron-client-ipc/src/types/proxyTRPCRequest.ts b/packages/electron-client-ipc/src/types/proxyTRPCRequest.ts new file mode 100644 index 0000000000..604f1c7a5c --- /dev/null +++ b/packages/electron-client-ipc/src/types/proxyTRPCRequest.ts @@ -0,0 +1,21 @@ +export type ProxyTRPCRequestParams = { + /** Request body (can be string, ArrayBuffer, or null/undefined) */ + body?: string | ArrayBuffer; + /** Request headers */ + headers: Record; + /** The HTTP method (e.g., 'GET', 'POST') */ + method: string; + /** The path and query string of the request (e.g., '/trpc/lambda/...') */ + urlPath: string; +}; + +export interface ProxyTRPCRequestResult { + /** Response body (likely as ArrayBuffer or string) */ + body: ArrayBuffer | string; + /** Response headers */ + headers: Record; + /** Response status code */ + status: number; + /** Response status text */ + statusText: string; +} diff --git a/packages/electron-client-ipc/src/types/remoteServer.ts b/packages/electron-client-ipc/src/types/remoteServer.ts deleted file mode 100644 index 7d3a50225a..0000000000 --- a/packages/electron-client-ipc/src/types/remoteServer.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * 远程服务器配置相关的事件 - */ -export interface RemoteServerConfig { - active: boolean; - isSelfHosted: boolean; - remoteServerUrl?: string; -} diff --git a/packages/electron-server-ipc/src/const.ts b/packages/electron-server-ipc/src/const.ts index 471cea220f..ff947ab10c 100644 --- a/packages/electron-server-ipc/src/const.ts +++ b/packages/electron-server-ipc/src/const.ts @@ -1,5 +1,5 @@ -export const SOCK_FILE = 'lobehub-electron-ipc.sock'; +export const SOCK_FILE = (id: string) => `${id}-electron-ipc.sock`; -export const SOCK_INFO_FILE = 'lobehub-electron-ipc-info.json'; +export const SOCK_INFO_FILE = (id: string) => `${id}-electron-ipc-info.json`; -export const WINDOW_PIPE_FILE = '\\\\.\\pipe\\lobehub-electron-ipc'; +export const WINDOW_PIPE_FILE = (id: string) => `\\\\.\\pipe\\${id}-electron-ipc`; diff --git a/packages/electron-server-ipc/src/ipcClient.test.ts b/packages/electron-server-ipc/src/ipcClient.test.ts index 09d59177c9..997a9ae6e0 100644 --- a/packages/electron-server-ipc/src/ipcClient.test.ts +++ b/packages/electron-server-ipc/src/ipcClient.test.ts @@ -12,6 +12,7 @@ vi.mock('node:net'); vi.mock('node:os'); vi.mock('node:path'); +const appId = 'lobehub'; describe('ElectronIpcClient', () => { // Mock data const mockTempDir = '/mock/temp/dir'; @@ -54,7 +55,7 @@ describe('ElectronIpcClient', () => { vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockSocketInfo)); // Execute - new ElectronIpcClient(); + new ElectronIpcClient(appId); // Verify expect(fs.existsSync).toHaveBeenCalledWith(mockSocketInfoPath); @@ -66,7 +67,7 @@ describe('ElectronIpcClient', () => { vi.mocked(fs.existsSync).mockReturnValue(false); // Execute - new ElectronIpcClient(); + new ElectronIpcClient(appId); // Verify expect(fs.existsSync).toHaveBeenCalledWith(mockSocketInfoPath); @@ -75,7 +76,7 @@ describe('ElectronIpcClient', () => { // Test platform-specific behavior const originalPlatform = process.platform; Object.defineProperty(process, 'platform', { value: 'win32' }); - new ElectronIpcClient(); + new ElectronIpcClient(appId); Object.defineProperty(process, 'platform', { value: originalPlatform }); }); @@ -86,7 +87,7 @@ describe('ElectronIpcClient', () => { }); // Execute - new ElectronIpcClient(); + new ElectronIpcClient(appId); // Verify expect(console.error).toHaveBeenCalledWith( @@ -103,7 +104,7 @@ describe('ElectronIpcClient', () => { // Setup a client with a known socket path vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockSocketInfo)); - client = new ElectronIpcClient(); + client = new ElectronIpcClient(appId); // Reset socket mocks for each test mockSocket.on.mockReset(); @@ -170,7 +171,7 @@ describe('ElectronIpcClient', () => { // Setup a client with a known socket path vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockSocketInfo)); - client = new ElectronIpcClient(); + client = new ElectronIpcClient(appId); // Setup socket.on mockSocket.on.mockImplementation((event, callback) => { diff --git a/packages/electron-server-ipc/src/ipcClient.ts b/packages/electron-server-ipc/src/ipcClient.ts index 2d88f94f92..b7db665d53 100644 --- a/packages/electron-server-ipc/src/ipcClient.ts +++ b/packages/electron-server-ipc/src/ipcClient.ts @@ -20,18 +20,28 @@ export class ElectronIpcClient { private connectionAttempts: number = 0; private maxConnectionAttempts: number = 5; private dataBuffer: string = ''; + private readonly appId: string; - constructor() { - log('Initializing client'); + constructor(appId: string) { + log('Initializing client', appId); + this.appId = appId; this.initialize(); } // 初始化客户端 private initialize() { try { - // 从临时文件读取套接字路径 const tempDir = os.tmpdir(); - const socketInfoPath = path.join(tempDir, SOCK_INFO_FILE); + + // Windows 平台强制使用命名管道 + if (process.platform === 'win32') { + this.socketPath = WINDOW_PIPE_FILE(this.appId); + log('Using named pipe for Windows: %s', this.socketPath); + return; + } + + // 其他平台尝试读取 sock info 文件 + const socketInfoPath = path.join(tempDir, SOCK_INFO_FILE(this.appId)); log('Looking for socket info at: %s', socketInfoPath); if (fs.existsSync(socketInfoPath)) { @@ -39,10 +49,9 @@ export class ElectronIpcClient { this.socketPath = socketInfo.socketPath; log('Found socket path: %s', this.socketPath); } else { - // 如果找不到套接字信息,使用默认路径 - this.socketPath = - process.platform === 'win32' ? WINDOW_PIPE_FILE : path.join(os.tmpdir(), SOCK_FILE); - log('Socket info not found, using default path: %s', this.socketPath); + // 如果找不到套接字信息,使用默认 sock 文件路径 + this.socketPath = path.join(tempDir, SOCK_FILE(this.appId)); + log('Socket info not found, using default sock path: %s', this.socketPath); } } catch (err) { console.error('Failed to initialize IPC client:', err); diff --git a/packages/electron-server-ipc/src/ipcServer.ts b/packages/electron-server-ipc/src/ipcServer.ts index 520c3a2088..ebcfd7f5b9 100644 --- a/packages/electron-server-ipc/src/ipcServer.ts +++ b/packages/electron-server-ipc/src/ipcServer.ts @@ -13,12 +13,16 @@ const log = debug('electron-server-ipc:server'); export class ElectronIPCServer { private server: net.Server; private socketPath: string; + private appId: string; private eventHandler: ElectronIPCEventHandler; - constructor(eventHandler: ElectronIPCEventHandler) { + constructor(appId: string, eventHandler: ElectronIPCEventHandler) { + this.appId = appId; const isWindows = process.platform === 'win32'; // 创建唯一的套接字路径,避免冲突 - this.socketPath = isWindows ? WINDOW_PIPE_FILE : path.join(os.tmpdir(), SOCK_FILE); + this.socketPath = isWindows + ? WINDOW_PIPE_FILE(appId) + : path.join(os.tmpdir(), SOCK_FILE(appId)); // 如果是 Unix 套接字,确保文件不存在 if (!isWindows && fs.existsSync(this.socketPath)) { @@ -47,7 +51,7 @@ export class ElectronIPCServer { // 将套接字路径写入临时文件,供 Next.js 服务端读取 const tempDir = os.tmpdir(); - const socketInfoPath = path.join(tempDir, SOCK_INFO_FILE); + const socketInfoPath = path.join(tempDir, SOCK_INFO_FILE(this.appId)); log('Writing socket info to: %s', socketInfoPath); fs.writeFileSync(socketInfoPath, JSON.stringify({ socketPath: this.socketPath }), 'utf8'); diff --git a/packages/file-loaders/test/fixtures/test.pdf b/packages/file-loaders/test/fixtures/test.pdf new file mode 100644 index 0000000000..34c2f8fe22 Binary files /dev/null and b/packages/file-loaders/test/fixtures/test.pdf differ diff --git a/scripts/electronWorkflow/setDesktopVersion.ts b/scripts/electronWorkflow/setDesktopVersion.ts index d11e60930b..17f5827115 100644 --- a/scripts/electronWorkflow/setDesktopVersion.ts +++ b/scripts/electronWorkflow/setDesktopVersion.ts @@ -2,12 +2,24 @@ import fs from 'fs-extra'; import path from 'node:path'; +type ReleaseType = 'stable' | 'beta' | 'nightly'; + // 获取脚本的命令行参数 const version = process.argv[2]; -const isPr = process.argv[3] === 'true'; +const releaseType = process.argv[3] as ReleaseType; -if (!version) { - console.error('Missing version parameter, usage: bun run setDesktopVersion.ts [isPr]'); +// 验证参数 +if (!version || !releaseType) { + console.error( + 'Missing parameters. Usage: bun run setDesktopVersion.ts ', + ); + process.exit(1); +} + +if (!['stable', 'beta', 'nightly'].includes(releaseType)) { + console.error( + `Invalid release type: ${releaseType}. Must be one of 'stable', 'beta', 'nightly'.`, + ); process.exit(1); } @@ -16,81 +28,86 @@ const rootDir = path.resolve(__dirname, '../..'); // 桌面应用 package.json 的路径 const desktopPackageJsonPath = path.join(rootDir, 'apps/desktop/package.json'); +const buildDir = path.join(rootDir, 'apps/desktop/build'); // 更新应用图标 -function updateAppIcon() { +function updateAppIcon(type: 'beta' | 'nightly') { + console.log(`📦 Updating app icon for ${type} version...`); try { - const buildDir = path.join(rootDir, 'apps/desktop/build'); - - // 定义需要处理的图标映射,考虑到大小写敏感性 + const iconSuffix = type === 'beta' ? 'beta' : 'nightly'; const iconMappings = [ - // { ext: '.ico', nightly: 'icon-nightly.ico', normal: 'icon.ico' }, - { ext: '.png', nightly: 'icon-nightly.png', normal: 'icon.png' }, - { ext: '.icns', nightly: 'Icon-nightly.icns', normal: 'Icon.icns' }, + { ext: '.png', source: `icon-${iconSuffix}.png`, target: 'icon.png' }, + { ext: '.icns', source: `Icon-${iconSuffix}.icns`, target: 'Icon.icns' }, + { ext: '.ico', source: `icon-${iconSuffix}.ico`, target: 'icon.ico' }, ]; - // 处理每种图标格式 for (const mapping of iconMappings) { - const sourceFile = path.join(buildDir, mapping.nightly); - const targetFile = path.join(buildDir, mapping.normal); + const sourceFile = path.join(buildDir, mapping.source); + const targetFile = path.join(buildDir, mapping.target); - // 检查源文件是否存在 if (fs.existsSync(sourceFile)) { - // 只有当源文件和目标文件不同,才进行复制 if (sourceFile !== targetFile) { fs.copyFileSync(sourceFile, targetFile); - console.log(`Updated app icon: ${targetFile}`); + console.log(` ✅ Copied ${mapping.source} to ${mapping.target}`); } } else { - console.warn(`Warning: Source icon not found: ${sourceFile}`); + console.warn(` ⚠️ Warning: Source icon not found: ${sourceFile}`); } } } catch (error) { - console.error('Error updating icons:', error); - // 继续处理,不终止程序 + console.error(' ❌ Error updating icons:', error); + // 不终止程序,继续处理 package.json } } -function updateVersion() { +function updatePackageJson() { + console.log(`⚙️ Updating ${desktopPackageJsonPath} for ${releaseType} version ${version}...`); try { - // 确保文件存在 if (!fs.existsSync(desktopPackageJsonPath)) { - console.error(`Error: File not found ${desktopPackageJsonPath}`); + console.error(`❌ Error: File not found ${desktopPackageJsonPath}`); process.exit(1); } - // 读取 package.json 文件 const packageJson = fs.readJSONSync(desktopPackageJsonPath); - // 更新版本号 + // 始终更新版本号 packageJson.version = version; - packageJson.productName = 'LobeHub'; - packageJson.name = 'lobehub-desktop'; - // 如果是 PR 构建,设置为 Nightly 版本 - if (isPr) { - // 修改包名,添加 -nightly 后缀 - if (!packageJson.name.endsWith('-nightly')) { - packageJson.name = `${packageJson.name}-nightly`; + // 根据 releaseType 修改其他字段 + switch (releaseType) { + case 'stable': { + packageJson.productName = 'LobeHub'; + packageJson.name = 'lobehub-desktop'; + console.log('🌟 Setting as Stable version.'); + break; + } + case 'beta': { + packageJson.productName = 'LobeHub-Beta'; // Or 'LobeHub-Beta' if preferred + packageJson.name = 'lobehub-desktop-beta'; // Or 'lobehub-desktop' if preferred + console.log('🧪 Setting as Beta version.'); + updateAppIcon('beta'); + break; + } + case 'nightly': { + packageJson.productName = 'LobeHub-Nightly'; // Or 'LobeHub-Nightly' + packageJson.name = 'lobehub-desktop-nightly'; // Or 'lobehub-desktop-nightly' + console.log('🌙 Setting as Nightly version.'); + updateAppIcon('nightly'); + break; } - - // 修改产品名称为 LobeHub Nightly - packageJson.productName = 'LobeHub-Nightly'; - - console.log('🌙 Setting as Nightly version with modified package name and productName'); - - // 使用 nightly 图标替换常规图标 - updateAppIcon(); } // 写回文件 fs.writeJsonSync(desktopPackageJsonPath, packageJson, { spaces: 2 }); - console.log(`Desktop app version updated to: ${version}, isPr: ${isPr}`); + console.log( + `✅ Desktop app package.json updated successfully for ${releaseType} version ${version}.`, + ); } catch (error) { - console.error('Error updating version:', error); + console.error('❌ Error updating package.json:', error); process.exit(1); } } -updateVersion(); +// 执行更新 +updatePackageJson(); diff --git a/src/app/[variants]/(main)/_layout/Desktop/index.tsx b/src/app/[variants]/(main)/_layout/Desktop/index.tsx index 5acd08c235..13056e58fa 100644 --- a/src/app/[variants]/(main)/_layout/Desktop/index.tsx +++ b/src/app/[variants]/(main)/_layout/Desktop/index.tsx @@ -9,12 +9,12 @@ import { Flexbox } from 'react-layout-kit'; import { isDesktop } from '@/const/version'; import { BANNER_HEIGHT } from '@/features/AlertBanner/CloudBanner'; +import TitleBar, { TITLE_BAR_HEIGHT } from '@/features/ElectronTitlebar'; import HotkeyHelperPanel from '@/features/HotkeyHelperPanel'; import { usePlatform } from '@/hooks/usePlatform'; import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig'; import { HotkeyScopeEnum } from '@/types/hotkey'; -import TitleBar, { TITLE_BAR_HEIGHT } from './ElectronTitlebar'; import RegisterHotkeys from './RegisterHotkeys'; import SideBar from './SideBar'; diff --git a/src/components/Analytics/Desktop.tsx b/src/components/Analytics/Desktop.tsx new file mode 100644 index 0000000000..5927c528b6 --- /dev/null +++ b/src/components/Analytics/Desktop.tsx @@ -0,0 +1,19 @@ +'use client'; + +import Script from 'next/script'; +import { memo } from 'react'; +import urlJoin from 'url-join'; + +const DesktopAnalytics = memo( + () => + process.env.NEXT_PUBLIC_DESKTOP_PROJECT_ID && + process.env.NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL && ( +