feat(electron): enhance native module handling and improve desktop features (#11867)

* 🔧 refactor: streamline theme handling and title bar overlay

*  feat(titlebar): integrate theme update handling in SimpleTitleBar component

* 🔧 chore: move `node-mac-permissions` to optionalDependencies and add TypeScript module declaration

*  feat(electron): implement connection drawer state management and enhance auth modal functionality

* 🐛 fix(ci): fix Windows PowerShell Start-Job working directory issue

Start-Job runs in a separate process with default user directory,
causing npm install-isolated to fail. Fixed by setting correct
working directory in each job using $using:workingDir.

* 🐛 fix(ci): use Start-Process instead of Start-Job for Windows parallel install

Start-Job runs in isolated PowerShell process without inheriting PATH,
causing pnpm/npm commands to fail. Start-Process inherits environment
and provides proper exit code handling.

* 🐛 fix(ci): use desktop-build-setup action for Windows build

Use the same composite action as other desktop workflows instead of
custom PowerShell parallel install which has environment issues.

*  feat(menu): enhance context menu with additional options for image and link handling

* 🔧 fix(auth-modal): prevent modal from opening during desktop onboarding

*  feat(electron): enhance native module handling and improve localization resource loading

resolves LOBE-4370

- Added `copyNativeModulesToSource` function to resolve pnpm symlinks for native modules before packaging.
- Introduced `getNativeModulesFilesConfig` to explicitly include native modules in the build process.
- Updated `electron-builder` configuration to utilize the new functions for better native module management.
- Enhanced localization resource loading by splitting JSON files by namespace.

* 🐛 fix(lint): use slice instead of substring

* 🐛 fix(desktop): include global.d.ts in tsconfig for node-mac-permissions types

* 🐛 fix(desktop): add ts-ignore for optional node-mac-permissions module

* fix: update ui
This commit is contained in:
Innei 2026-01-27 01:30:08 +08:00 committed by GitHub
parent ba0fab13a1
commit e3c80d53ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 1025 additions and 175 deletions

View file

@ -184,18 +184,10 @@ jobs:
with:
fetch-depth: 0
- name: Setup Node & pnpm
uses: ./.github/actions/setup-node-pnpm
- name: Setup build environment
uses: ./.github/actions/desktop-build-setup
with:
node-version: ${{ env.NODE_VERSION }}
package-manager-cache: 'false'
- name: Install dependencies
shell: pwsh
run: |
$job1 = Start-Job -ScriptBlock { pnpm install --node-linker=hoisted }
$job2 = Start-Job -ScriptBlock { npm run install-isolated --prefix=./apps/desktop }
$job1, $job2 | Wait-Job | Receive-Job
- name: Set package version
run: npm run workflow:set-desktop-version ${{ needs.version.outputs.version }} ${{ inputs.channel }}

View file

@ -6,8 +6,9 @@ import { fileURLToPath } from 'node:url';
import {
copyNativeModules,
copyNativeModulesToSource,
getAsarUnpackPatterns,
getFilesPatterns,
getNativeModulesFilesConfig,
} from './native-deps.config.mjs';
dotenv.config();
@ -89,6 +90,13 @@ const getIconFileName = () => {
* @see https://www.electron.build/configuration
*/
const config = {
/**
* BeforePack hook to resolve pnpm symlinks for native modules.
* This ensures native modules are properly included in the asar archive.
*/
beforePack: async () => {
await copyNativeModulesToSource();
},
/**
* AfterPack hook for post-processing:
* 1. Copy native modules to asar.unpacked (resolving pnpm symlinks)
@ -204,10 +212,10 @@ const config = {
'!dist/next/packages',
'!dist/next/.next/server/app/sitemap',
'!dist/next/.next/static/media',
// Exclude node_modules from packaging (except native modules)
// Exclude all node_modules first
'!node_modules',
// Include native modules (defined in native-deps.config.mjs)
...getFilesPatterns(),
// Then explicitly include native modules using object form (handles pnpm symlinks)
...getNativeModulesFilesConfig(),
],
generateUpdatesFilesForAllChannels: true,
linux: {

View file

@ -24,6 +24,13 @@ export default defineConfig({
if (id.includes('node_modules/debug')) {
return 'vendor-debug';
}
// Split i18n json resources by namespace (ns), not by locale.
// Example: ".../resources/locales/zh-CN/common.json?import" -> "locales-common"
const normalizedId = id.replaceAll('\\', '/').split('?')[0];
const match = normalizedId.match(/\/locales\/[^/]+\/([^/]+)\.json$/);
if (match?.[1]) return `locales-${match[1]}`;
},
},
},

View file

@ -112,6 +112,19 @@ export function getFilesPatterns() {
return getAllDependencies().map((dep) => `node_modules/${dep}/**/*`);
}
/**
* Generate files config objects for electron-builder to explicitly copy native modules.
* This uses object form to ensure scoped packages with pnpm symlinks are properly copied.
* @returns {Array<{from: string, to: string, filter: string[]}>}
*/
export function getNativeModulesFilesConfig() {
return getAllDependencies().map((dep) => ({
filter: ['**/*'],
from: `node_modules/${dep}`,
to: `node_modules/${dep}`,
}));
}
/**
* Generate glob patterns for electron-builder asarUnpack config
* @returns {string[]} Array of glob patterns
@ -128,6 +141,47 @@ export function getExternalDependencies() {
return getAllDependencies();
}
/**
* Copy native modules to source node_modules, resolving pnpm symlinks.
* This is used in beforePack hook to ensure native modules are properly
* included in the asar archive (electron-builder glob doesn't follow symlinks).
*/
export async function copyNativeModulesToSource() {
const fsPromises = await import('node:fs/promises');
const deps = getAllDependencies();
const sourceNodeModules = path.join(__dirname, 'node_modules');
console.log(`📦 Resolving ${deps.length} native module symlinks for packaging...`);
for (const dep of deps) {
const modulePath = path.join(sourceNodeModules, dep);
try {
const stat = await fsPromises.lstat(modulePath);
if (stat.isSymbolicLink()) {
// Resolve the symlink to get the real path
const realPath = await fsPromises.realpath(modulePath);
console.log(` 📎 ${dep} (resolving symlink)`);
// Remove the symlink
await fsPromises.rm(modulePath, { force: true, recursive: true });
// Create parent directory if needed (for scoped packages like @napi-rs)
await fsPromises.mkdir(path.dirname(modulePath), { recursive: true });
// Copy the actual directory content in place of the symlink
await copyDir(realPath, modulePath);
}
} catch (err) {
// Module might not exist (optional dependency for different platform)
console.log(` ⏭️ ${dep} (skipped: ${err.code || err.message})`);
}
}
console.log(`✅ Native module symlinks resolved`);
}
/**
* Copy native modules to destination, resolving symlinks
* This is used in afterPack hook to handle pnpm symlinks correctly

View file

@ -40,13 +40,16 @@
"update-server": "sh scripts/update-test/run-test.sh"
},
"dependencies": {
"@napi-rs/canvas": "^0.1.70",
"electron-updater": "^6.6.2",
"electron-window-state": "^5.0.3",
"fetch-socks": "^1.3.2",
"get-port-please": "^3.2.0",
"node-mac-permissions": "^2.5.0",
"superjson": "^2.2.6"
},
"optionalDependencies": {
"node-mac-permissions": "^2.5.0"
},
"devDependencies": {
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.1.0",
@ -102,6 +105,7 @@
},
"pnpm": {
"onlyBuiltDependencies": [
"@napi-rs/canvas",
"electron",
"electron-builder",
"node-mac-permissions"

View file

@ -1,5 +1,13 @@
{
"common.checkUpdates": "التحقق من التحديثات...",
"context.copyImage": "نسخ الصورة",
"context.copyImageAddress": "نسخ عنوان الصورة",
"context.copyLink": "نسخ الرابط",
"context.inspectElement": "فحص العنصر",
"context.openLink": "فتح الرابط",
"context.saveImage": "حفظ الصورة",
"context.saveImageAs": "حفظ الصورة باسم…",
"context.searchWithGoogle": "البحث باستخدام جوجل",
"dev.devPanel": "لوحة المطور",
"dev.devTools": "أدوات المطور",
"dev.forceReload": "إعادة تحميل قسري",
@ -24,6 +32,7 @@
"edit.copy": "نسخ",
"edit.cut": "قص",
"edit.delete": "حذف",
"edit.lookUp": "البحث",
"edit.paste": "لصق",
"edit.redo": "إعادة",
"edit.selectAll": "تحديد الكل",

View file

@ -1,5 +1,13 @@
{
"common.checkUpdates": "Проверка за актуализации...",
"context.copyImage": "Копирай изображение",
"context.copyImageAddress": "Копирай адреса на изображението",
"context.copyLink": "Копирай връзката",
"context.inspectElement": "Инспектиране на елемент",
"context.openLink": "Отвори връзката",
"context.saveImage": "Запази изображението",
"context.saveImageAs": "Запази изображението като…",
"context.searchWithGoogle": "Търси с Google",
"dev.devPanel": "Панел на разработчика",
"dev.devTools": "Инструменти за разработчици",
"dev.forceReload": "Принудително презареждане",
@ -24,6 +32,7 @@
"edit.copy": "Копиране",
"edit.cut": "Изрязване",
"edit.delete": "Изтрий",
"edit.lookUp": "Потърси",
"edit.paste": "Поставяне",
"edit.redo": "Повторно",
"edit.selectAll": "Избери всичко",

View file

@ -1,5 +1,13 @@
{
"common.checkUpdates": "Überprüfen Sie auf Updates...",
"context.copyImage": "Bild kopieren",
"context.copyImageAddress": "Bildadresse kopieren",
"context.copyLink": "Link kopieren",
"context.inspectElement": "Element untersuchen",
"context.openLink": "Link öffnen",
"context.saveImage": "Bild speichern",
"context.saveImageAs": "Bild speichern unter…",
"context.searchWithGoogle": "Mit Google suchen",
"dev.devPanel": "Entwicklerpanel",
"dev.devTools": "Entwicklerwerkzeuge",
"dev.forceReload": "Erzwinge Neuladen",
@ -24,6 +32,7 @@
"edit.copy": "Kopieren",
"edit.cut": "Ausschneiden",
"edit.delete": "Löschen",
"edit.lookUp": "Nachschlagen",
"edit.paste": "Einfügen",
"edit.redo": "Wiederherstellen",
"edit.selectAll": "Alles auswählen",

View file

@ -23,4 +23,4 @@
"status.loading": "Loading",
"status.success": "Success",
"status.warning": "Warning"
}
}

View file

@ -24,4 +24,4 @@
"update.newVersion": "New Version Found",
"update.newVersionAvailable": "New version: {{version}}",
"update.skipThisVersion": "Skip This Version"
}
}

View file

@ -1,5 +1,13 @@
{
"common.checkUpdates": "Check for updates...",
"context.copyImage": "Copy Image",
"context.copyImageAddress": "Copy Image Address",
"context.copyLink": "Copy Link",
"context.inspectElement": "Inspect Element",
"context.openLink": "Open Link",
"context.saveImage": "Save Image",
"context.saveImageAs": "Save Image As…",
"context.searchWithGoogle": "Search with Google",
"dev.devPanel": "Developer Panel",
"dev.devTools": "Developer Tools",
"dev.forceReload": "Force Reload",
@ -24,6 +32,7 @@
"edit.copy": "Copy",
"edit.cut": "Cut",
"edit.delete": "Delete",
"edit.lookUp": "Look Up",
"edit.paste": "Paste",
"edit.redo": "Redo",
"edit.selectAll": "Select All",
@ -70,4 +79,4 @@
"window.title": "Window",
"window.toggleFullscreen": "Toggle Fullscreen",
"window.zoom": "Zoom"
}
}

View file

@ -1,5 +1,13 @@
{
"common.checkUpdates": "Comprobando actualizaciones...",
"context.copyImage": "Copiar imagen",
"context.copyImageAddress": "Copiar dirección de la imagen",
"context.copyLink": "Copiar enlace",
"context.inspectElement": "Inspeccionar elemento",
"context.openLink": "Abrir enlace",
"context.saveImage": "Guardar imagen",
"context.saveImageAs": "Guardar imagen como…",
"context.searchWithGoogle": "Buscar con Google",
"dev.devPanel": "Panel de desarrollador",
"dev.devTools": "Herramientas de desarrollador",
"dev.forceReload": "Recargar forzosamente",
@ -24,6 +32,7 @@
"edit.copy": "Copiar",
"edit.cut": "Cortar",
"edit.delete": "Eliminar",
"edit.lookUp": "Buscar",
"edit.paste": "Pegar",
"edit.redo": "Rehacer",
"edit.selectAll": "Seleccionar todo",

View file

@ -1,5 +1,13 @@
{
"common.checkUpdates": "بررسی به‌روزرسانی...",
"context.copyImage": "کپی تصویر",
"context.copyImageAddress": "کپی آدرس تصویر",
"context.copyLink": "کپی لینک",
"context.inspectElement": "بازرسی عنصر",
"context.openLink": "باز کردن لینک",
"context.saveImage": "ذخیره تصویر",
"context.saveImageAs": "ذخیره تصویر به عنوان…",
"context.searchWithGoogle": "جستجو با گوگل",
"dev.devPanel": "پنل توسعه‌دهنده",
"dev.devTools": "ابزارهای توسعه‌دهنده",
"dev.forceReload": "بارگذاری اجباری",
@ -24,6 +32,7 @@
"edit.copy": "کپی",
"edit.cut": "برش",
"edit.delete": "حذف",
"edit.lookUp": "جستجو",
"edit.paste": "چسباندن",
"edit.redo": "انجام مجدد",
"edit.selectAll": "انتخاب همه",

View file

@ -1,5 +1,13 @@
{
"common.checkUpdates": "Vérifier les mises à jour...",
"context.copyImage": "Copier l'image",
"context.copyImageAddress": "Copier l'adresse de l'image",
"context.copyLink": "Copier le lien",
"context.inspectElement": "Inspecter l'élément",
"context.openLink": "Ouvrir le lien",
"context.saveImage": "Enregistrer l'image",
"context.saveImageAs": "Enregistrer l'image sous…",
"context.searchWithGoogle": "Rechercher avec Google",
"dev.devPanel": "Panneau de développement",
"dev.devTools": "Outils de développement",
"dev.forceReload": "Recharger de force",
@ -24,6 +32,7 @@
"edit.copy": "Copier",
"edit.cut": "Couper",
"edit.delete": "Supprimer",
"edit.lookUp": "Rechercher",
"edit.paste": "Coller",
"edit.redo": "Rétablir",
"edit.selectAll": "Tout sélectionner",

View file

@ -1,5 +1,13 @@
{
"common.checkUpdates": "Controlla aggiornamenti...",
"context.copyImage": "Copia immagine",
"context.copyImageAddress": "Copia indirizzo immagine",
"context.copyLink": "Copia link",
"context.inspectElement": "Ispeziona elemento",
"context.openLink": "Apri link",
"context.saveImage": "Salva immagine",
"context.saveImageAs": "Salva immagine come…",
"context.searchWithGoogle": "Cerca con Google",
"dev.devPanel": "Pannello sviluppatore",
"dev.devTools": "Strumenti per sviluppatori",
"dev.forceReload": "Ricarica forzata",
@ -24,6 +32,7 @@
"edit.copy": "Copia",
"edit.cut": "Taglia",
"edit.delete": "Elimina",
"edit.lookUp": "Cerca",
"edit.paste": "Incolla",
"edit.redo": "Ripeti",
"edit.selectAll": "Seleziona tutto",

View file

@ -1,5 +1,13 @@
{
"common.checkUpdates": "更新を確認しています...",
"context.copyImage": "画像をコピー",
"context.copyImageAddress": "画像のアドレスをコピー",
"context.copyLink": "リンクをコピー",
"context.inspectElement": "要素を検証",
"context.openLink": "リンクを開く",
"context.saveImage": "画像を保存",
"context.saveImageAs": "名前を付けて画像を保存…",
"context.searchWithGoogle": "Googleで検索",
"dev.devPanel": "開発者パネル",
"dev.devTools": "開発者ツール",
"dev.forceReload": "強制再読み込み",
@ -24,6 +32,7 @@
"edit.copy": "コピー",
"edit.cut": "切り取り",
"edit.delete": "削除",
"edit.lookUp": "調べる",
"edit.paste": "貼り付け",
"edit.redo": "やり直し",
"edit.selectAll": "すべて選択",

View file

@ -1,5 +1,13 @@
{
"common.checkUpdates": "업데이트 확인 중...",
"context.copyImage": "이미지 복사",
"context.copyImageAddress": "이미지 주소 복사",
"context.copyLink": "링크 복사",
"context.inspectElement": "요소 검사",
"context.openLink": "링크 열기",
"context.saveImage": "이미지 저장",
"context.saveImageAs": "다른 이름으로 이미지 저장…",
"context.searchWithGoogle": "Google로 검색",
"dev.devPanel": "개발자 패널",
"dev.devTools": "개발자 도구",
"dev.forceReload": "강제 새로 고침",
@ -24,6 +32,7 @@
"edit.copy": "복사",
"edit.cut": "잘라내기",
"edit.delete": "삭제",
"edit.lookUp": "찾아보기",
"edit.paste": "붙여넣기",
"edit.redo": "다시 실행",
"edit.selectAll": "모두 선택",

View file

@ -1,5 +1,13 @@
{
"common.checkUpdates": "Updates controleren...",
"context.copyImage": "Afbeelding kopiëren",
"context.copyImageAddress": "Afbeeldingsadres kopiëren",
"context.copyLink": "Link kopiëren",
"context.inspectElement": "Element inspecteren",
"context.openLink": "Link openen",
"context.saveImage": "Afbeelding opslaan",
"context.saveImageAs": "Afbeelding opslaan als…",
"context.searchWithGoogle": "Zoeken met Google",
"dev.devPanel": "Ontwikkelaarspaneel",
"dev.devTools": "Ontwikkelaarstools",
"dev.forceReload": "Forceer herladen",
@ -24,6 +32,7 @@
"edit.copy": "Kopiëren",
"edit.cut": "Knippen",
"edit.delete": "Verwijderen",
"edit.lookUp": "Opzoeken",
"edit.paste": "Plakken",
"edit.redo": "Opnieuw doen",
"edit.selectAll": "Alles selecteren",

View file

@ -1,5 +1,13 @@
{
"common.checkUpdates": "Sprawdzanie aktualizacji...",
"context.copyImage": "Kopiuj obraz",
"context.copyImageAddress": "Kopiuj adres obrazu",
"context.copyLink": "Kopiuj link",
"context.inspectElement": "Zbadaj element",
"context.openLink": "Otwórz link",
"context.saveImage": "Zapisz obraz",
"context.saveImageAs": "Zapisz obraz jako…",
"context.searchWithGoogle": "Szukaj w Google",
"dev.devPanel": "Panel dewelopera",
"dev.devTools": "Narzędzia dewelopera",
"dev.forceReload": "Wymuś ponowne załadowanie",
@ -24,6 +32,7 @@
"edit.copy": "Kopiuj",
"edit.cut": "Wytnij",
"edit.delete": "Usuń",
"edit.lookUp": "Wyszukaj",
"edit.paste": "Wklej",
"edit.redo": "Ponów",
"edit.selectAll": "Zaznacz wszystko",

View file

@ -1,5 +1,13 @@
{
"common.checkUpdates": "Verificando atualizações...",
"context.copyImage": "Copiar Imagem",
"context.copyImageAddress": "Copiar Endereço da Imagem",
"context.copyLink": "Copiar Link",
"context.inspectElement": "Inspecionar Elemento",
"context.openLink": "Abrir Link",
"context.saveImage": "Salvar Imagem",
"context.saveImageAs": "Salvar Imagem Como…",
"context.searchWithGoogle": "Pesquisar com o Google",
"dev.devPanel": "Painel do Desenvolvedor",
"dev.devTools": "Ferramentas do Desenvolvedor",
"dev.forceReload": "Recarregar Forçadamente",
@ -24,6 +32,7 @@
"edit.copy": "Copiar",
"edit.cut": "Cortar",
"edit.delete": "Excluir",
"edit.lookUp": "Pesquisar",
"edit.paste": "Colar",
"edit.redo": "Refazer",
"edit.selectAll": "Selecionar Tudo",

View file

@ -1,5 +1,13 @@
{
"common.checkUpdates": "Проверка обновлений...",
"context.copyImage": "Копировать изображение",
"context.copyImageAddress": "Копировать адрес изображения",
"context.copyLink": "Копировать ссылку",
"context.inspectElement": "Просмотреть элемент",
"context.openLink": "Открыть ссылку",
"context.saveImage": "Сохранить изображение",
"context.saveImageAs": "Сохранить изображение как…",
"context.searchWithGoogle": "Искать с помощью Google",
"dev.devPanel": "Панель разработчика",
"dev.devTools": "Инструменты разработчика",
"dev.forceReload": "Принудительная перезагрузка",
@ -24,6 +32,7 @@
"edit.copy": "Копировать",
"edit.cut": "Вырезать",
"edit.delete": "Удалить",
"edit.lookUp": "Поиск",
"edit.paste": "Вставить",
"edit.redo": "Повторить",
"edit.selectAll": "Выбрать все",

View file

@ -1,5 +1,13 @@
{
"common.checkUpdates": "Güncellemeleri kontrol et...",
"context.copyImage": "Resmi Kopyala",
"context.copyImageAddress": "Resim Adresini Kopyala",
"context.copyLink": "Bağlantıyı Kopyala",
"context.inspectElement": "Öğeyi İncele",
"context.openLink": "Bağlantıyı Aç",
"context.saveImage": "Resmi Kaydet",
"context.saveImageAs": "Resmi Farklı Kaydet…",
"context.searchWithGoogle": "Google ile Ara",
"dev.devPanel": "Geliştirici Paneli",
"dev.devTools": "Geliştirici Araçları",
"dev.forceReload": "Zorla Yenile",
@ -24,6 +32,7 @@
"edit.copy": "Kopyala",
"edit.cut": "Kes",
"edit.delete": "Sil",
"edit.lookUp": "Ara",
"edit.paste": "Yapıştır",
"edit.redo": "Yinele",
"edit.selectAll": "Tümünü Seç",

View file

@ -1,5 +1,13 @@
{
"common.checkUpdates": "Kiểm tra cập nhật...",
"context.copyImage": "Sao chép hình ảnh",
"context.copyImageAddress": "Sao chép địa chỉ hình ảnh",
"context.copyLink": "Sao chép liên kết",
"context.inspectElement": "Kiểm tra phần tử",
"context.openLink": "Mở liên kết",
"context.saveImage": "Lưu hình ảnh",
"context.saveImageAs": "Lưu hình ảnh thành…",
"context.searchWithGoogle": "Tìm kiếm với Google",
"dev.devPanel": "Bảng điều khiển nhà phát triển",
"dev.devTools": "Công cụ phát triển",
"dev.forceReload": "Tải lại cưỡng bức",
@ -24,6 +32,7 @@
"edit.copy": "Sao chép",
"edit.cut": "Cắt",
"edit.delete": "Xóa",
"edit.lookUp": "Tra cứu",
"edit.paste": "Dán",
"edit.redo": "Làm lại",
"edit.selectAll": "Chọn tất cả",

View file

@ -1,5 +1,13 @@
{
"common.checkUpdates": "检查更新…",
"context.copyImage": "复制图片",
"context.copyImageAddress": "复制图片地址",
"context.copyLink": "复制链接",
"context.inspectElement": "检查元素",
"context.openLink": "打开链接",
"context.saveImage": "保存图片",
"context.saveImageAs": "图片另存为…",
"context.searchWithGoogle": "使用谷歌搜索",
"dev.devPanel": "开发者面板",
"dev.devTools": "开发者工具",
"dev.forceReload": "强制重新加载",
@ -24,6 +32,7 @@
"edit.copy": "复制",
"edit.cut": "剪切",
"edit.delete": "删除",
"edit.lookUp": "查找",
"edit.paste": "粘贴",
"edit.redo": "重做",
"edit.selectAll": "全选",

View file

@ -1,5 +1,13 @@
{
"common.checkUpdates": "檢查更新...",
"context.copyImage": "複製圖片",
"context.copyImageAddress": "複製圖片位址",
"context.copyLink": "複製連結",
"context.inspectElement": "檢查元素",
"context.openLink": "開啟連結",
"context.saveImage": "儲存圖片",
"context.saveImageAs": "圖片另存新檔…",
"context.searchWithGoogle": "使用 Google 搜尋",
"dev.devPanel": "開發者面板",
"dev.devTools": "開發者工具",
"dev.forceReload": "強制重新載入",
@ -24,6 +32,7 @@
"edit.copy": "複製",
"edit.cut": "剪下",
"edit.delete": "刪除",
"edit.lookUp": "查詢",
"edit.paste": "貼上",
"edit.redo": "重做",
"edit.selectAll": "全選",

View file

@ -203,12 +203,9 @@ export default class SystemController extends ControllerModule {
async updateThemeModeHandler(themeMode: ThemeMode) {
this.app.storeManager.set('themeMode', themeMode);
this.app.browserManager.broadcastToAllWindows('themeChanged', { themeMode });
// Apply visual effects to all browser windows when theme mode changes
this.app.browserManager.handleAppThemeChange();
// Set app theme mode to the system theme mode
this.setSystemThemeMode(themeMode);
this.app.browserManager.handleAppThemeChange();
}
@IpcMethod()

View file

@ -3,7 +3,6 @@ import { MainBroadcastEventKey, MainBroadcastParams } from '@lobechat/electron-c
import {
BrowserWindow,
BrowserWindowConstructorOptions,
Menu,
session as electronSession,
ipcMain,
screen,
@ -12,7 +11,7 @@ import console from 'node:console';
import { join } from 'node:path';
import { preloadDir, resourcesDir } from '@/const/dir';
import { isDev, isMac } from '@/const/env';
import { isMac } from '@/const/env';
import { ELECTRON_BE_PROTOCOL_SCHEME } from '@/const/protocol';
import RemoteServerConfigCtr from '@/controllers/RemoteServerConfigCtr';
import { backendProxyProtocolManager } from '@/core/infrastructure/BackendProxyProtocolManager';
@ -121,9 +120,7 @@ export default class Browser {
logger.info(`Creating new BrowserWindow instance: ${this.identifier}`);
logger.debug(`[${this.identifier}] Resolved window state: ${JSON.stringify(resolvedState)}`);
// Calculate traffic light position to center vertically in title bar
// Traffic light buttons are approximately 12px tall
const trafficLightY = Math.round((TITLE_BAR_HEIGHT - 12) / 2);
return new BrowserWindow({
...rest,
@ -134,7 +131,7 @@ export default class Browser {
height: resolvedState.height,
show: false,
title,
trafficLightPosition: isMac ? { x: 12, y: trafficLightY } : undefined,
vibrancy: 'sidebar',
visualEffectState: 'active',
webPreferences: {
@ -192,7 +189,7 @@ export default class Browser {
this.setupCloseListener(browserWindow);
this.setupFocusListener(browserWindow);
this.setupWillPreventUnloadListener(browserWindow);
this.setupDevContextMenu(browserWindow);
this.setupContextMenu(browserWindow);
}
private setupWillPreventUnloadListener(browserWindow: BrowserWindow): void {
@ -239,39 +236,25 @@ export default class Browser {
}
/**
* Setup context menu with "Inspect Element" option in development mode
* Setup context menu with platform-specific features
* Delegates to MenuManager for consistent platform behavior
*/
private setupDevContextMenu(browserWindow: BrowserWindow): void {
if (!isDev) return;
logger.debug(`[${this.identifier}] Setting up dev context menu.`);
private setupContextMenu(browserWindow: BrowserWindow): void {
logger.debug(`[${this.identifier}] Setting up context menu.`);
browserWindow.webContents.on('context-menu', (_event, params) => {
const { x, y } = params;
const { x, y, selectionText, linkURL, srcURL, mediaType, isEditable } = params;
const menu = Menu.buildFromTemplate([
{
click: () => {
browserWindow.webContents.inspectElement(x, y);
},
label: 'Inspect Element',
},
{ type: 'separator' },
{
click: () => {
browserWindow.webContents.openDevTools();
},
label: 'Open DevTools',
},
{
click: () => {
browserWindow.webContents.reload();
},
label: 'Reload',
},
]);
menu.popup({ window: browserWindow });
// Use the platform menu system with full context data
this.app.menuManager.showContextMenu('default', {
isEditable,
linkURL: linkURL || undefined,
mediaType: mediaType as any,
selectionText: selectionText || undefined,
srcURL: srcURL || undefined,
x,
y,
});
});
}

View file

@ -1,16 +1,16 @@
import { TITLE_BAR_HEIGHT } from '@lobechat/desktop-bridge';
import { BrowserWindow, nativeTheme } from 'electron';
import { BrowserWindow, BrowserWindowConstructorOptions, nativeTheme } from 'electron';
import { join } from 'node:path';
import { buildDir } from '@/const/dir';
import { isDev, isWindows } from '@/const/env';
import { isDev, isMac, isWindows } from '@/const/env';
import {
BACKGROUND_DARK,
BACKGROUND_LIGHT,
SYMBOL_COLOR_DARK,
SYMBOL_COLOR_LIGHT,
THEME_CHANGE_DELAY,
} from '@/const/theme';
} from '../../const/theme';
import { createLogger } from '@/utils/logger';
const logger = createLogger('core:WindowThemeManager');
@ -40,6 +40,15 @@ export class WindowThemeManager {
this.boundHandleThemeChange = this.handleThemeChange.bind(this);
}
private getWindowsTitleBarOverlay(isDarkMode: boolean): WindowsThemeConfig['titleBarOverlay'] {
return {
color: '#00000000',
// Reduce 2px to prevent blocking the container border edge
height: TITLE_BAR_HEIGHT - 2,
symbolColor: isDarkMode ? SYMBOL_COLOR_DARK : SYMBOL_COLOR_LIGHT,
};
}
// ==================== Lifecycle ====================
/**
@ -75,10 +84,18 @@ export class WindowThemeManager {
/**
* Get platform-specific theme configuration for window creation
*/
getPlatformConfig(): Partial<WindowsThemeConfig> {
getPlatformConfig(): Partial<BrowserWindowConstructorOptions> {
if (isWindows) {
return this.getWindowsConfig(this.isDarkMode);
}
if (isMac) {
// Calculate traffic light position to center vertically in title bar
// Traffic light buttons are approximately 12px tall
const trafficLightY = Math.round((TITLE_BAR_HEIGHT - 12) / 2);
return {
trafficLightPosition: { x: 12, y: trafficLightY },
};
}
return {};
}
@ -89,12 +106,7 @@ export class WindowThemeManager {
return {
backgroundColor: isDarkMode ? BACKGROUND_DARK : BACKGROUND_LIGHT,
icon: isDev ? join(buildDir, 'icon-dev.ico') : undefined,
titleBarOverlay: {
color: isDarkMode ? BACKGROUND_DARK : BACKGROUND_LIGHT,
// Reduce 2px to prevent blocking the container border edge
height: TITLE_BAR_HEIGHT - 2,
symbolColor: isDarkMode ? SYMBOL_COLOR_DARK : SYMBOL_COLOR_LIGHT,
},
titleBarOverlay: this.getWindowsTitleBarOverlay(isDarkMode),
titleBarStyle: 'hidden',
};
}
@ -123,11 +135,41 @@ export class WindowThemeManager {
logger.debug(`[${this.identifier}] App theme mode changed, reapplying visual effects.`);
setTimeout(() => {
this.applyVisualEffects();
this.applyWindowsTitleBarOverlay();
}, THEME_CHANGE_DELAY);
}
// ==================== Visual Effects ====================
private resolveWindowsIsDarkModeFromElectron(): boolean {
if (nativeTheme.themeSource === 'dark') return true;
if (nativeTheme.themeSource === 'light') return false;
return nativeTheme.shouldUseDarkColors;
}
/**
* Apply Windows title bar overlay based on Electron theme mode.
* Mirror the structure of `applyVisualEffects`, but only updates title bar overlay.
*/
private applyWindowsTitleBarOverlay(): void {
if (!this.browserWindow || this.browserWindow.isDestroyed()) return;
logger.debug(`[${this.identifier}] Applying Windows title bar overlay`);
const isDarkMode = this.resolveWindowsIsDarkModeFromElectron();
try {
if (!isWindows) return;
this.browserWindow.setTitleBarOverlay(this.getWindowsTitleBarOverlay(isDarkMode));
logger.debug(
`[${this.identifier}] Windows title bar overlay applied successfully (dark mode: ${isDarkMode})`,
);
} catch (error) {
logger.error(`[${this.identifier}] Failed to apply Windows title bar overlay:`, error);
}
}
/**
* Apply visual effects based on current theme
*/

View file

@ -102,7 +102,7 @@ vi.mock('@/const/env', () => ({
isWindows: true,
}));
vi.mock('@/const/theme', () => ({
vi.mock('../../../const/theme', () => ({
BACKGROUND_DARK: '#1a1a1a',
BACKGROUND_LIGHT: '#ffffff',
SYMBOL_COLOR_DARK: '#ffffff',
@ -332,7 +332,7 @@ describe('Browser', () => {
expect.objectContaining({
backgroundColor: '#1a1a1a',
titleBarOverlay: expect.objectContaining({
color: '#1a1a1a',
color: '#00000000',
symbolColor: '#ffffff',
}),
}),
@ -346,7 +346,7 @@ describe('Browser', () => {
expect.objectContaining({
backgroundColor: '#ffffff',
titleBarOverlay: expect.objectContaining({
color: '#ffffff',
color: '#00000000',
symbolColor: '#000000',
}),
}),

View file

@ -42,7 +42,7 @@ vi.mock('@lobechat/desktop-bridge', () => ({
TITLE_BAR_HEIGHT: 38,
}));
vi.mock('@/const/theme', () => ({
vi.mock('../../../const/theme', () => ({
BACKGROUND_DARK: '#1a1a1a',
BACKGROUND_LIGHT: '#ffffff',
SYMBOL_COLOR_DARK: '#ffffff',
@ -91,7 +91,7 @@ describe('WindowThemeManager', () => {
backgroundColor: '#1a1a1a',
icon: undefined,
titleBarOverlay: {
color: '#1a1a1a',
color: '#00000000',
height: 36,
symbolColor: '#ffffff',
},
@ -108,7 +108,7 @@ describe('WindowThemeManager', () => {
backgroundColor: '#ffffff',
icon: undefined,
titleBarOverlay: {
color: '#ffffff',
color: '#00000000',
height: 36,
symbolColor: '#000000',
},
@ -185,7 +185,7 @@ describe('WindowThemeManager', () => {
expect(mockBrowserWindow.setBackgroundColor).toHaveBeenCalledWith('#1a1a1a');
expect(mockBrowserWindow.setTitleBarOverlay).toHaveBeenCalledWith({
color: '#1a1a1a',
color: '#00000000',
height: 36,
symbolColor: '#ffffff',
});
@ -197,7 +197,7 @@ describe('WindowThemeManager', () => {
expect(mockBrowserWindow.setBackgroundColor).toHaveBeenCalledWith('#ffffff');
expect(mockBrowserWindow.setTitleBarOverlay).toHaveBeenCalledWith({
color: '#ffffff',
color: '#00000000',
height: 36,
symbolColor: '#000000',
});

View file

@ -1,3 +1,34 @@
import 'vite/client';
/**
* `node-mac-permissions` is a macOS-only native module.
*
* In Windows/Linux environments the dependency may be omitted (installed as an optional dependency),
* but we still need a module declaration so TypeScript can compile.
*/
declare module 'node-mac-permissions' {
export type AuthStatus = 'authorized' | 'denied' | 'not determined' | 'restricted';
export type AuthType =
| 'accessibility'
| 'calendar'
| 'camera'
| 'contacts'
| 'full-disk-access'
| 'input-monitoring'
| 'location'
| 'microphone'
| 'reminders'
| 'screen'
| 'speech-recognition';
export function getAuthStatus(type: AuthType): AuthStatus;
export function askForAccessibilityAccess(): void;
export function askForMicrophoneAccess(): Promise<AuthStatus>;
export function askForCameraAccess(): Promise<AuthStatus>;
export function askForScreenCaptureAccess(openPreferences?: boolean): void;
export function askForFullDiskAccess(): void;
}
export {};

View file

@ -1,5 +1,13 @@
const menu = {
'common.checkUpdates': 'Check for updates...',
'context.copyImage': 'Copy Image',
'context.copyImageAddress': 'Copy Image Address',
'context.copyLink': 'Copy Link',
'context.inspectElement': 'Inspect Element',
'context.openLink': 'Open Link',
'context.saveImage': 'Save Image',
'context.saveImageAs': 'Save Image As…',
'context.searchWithGoogle': 'Search with Google',
'dev.devPanel': 'Developer Panel',
'dev.devTools': 'Developer Tools',
'dev.forceReload': 'Force Reload',
@ -24,6 +32,7 @@ const menu = {
'edit.copy': 'Copy',
'edit.cut': 'Cut',
'edit.delete': 'Delete',
'edit.lookUp': 'Look Up',
'edit.paste': 'Paste',
'edit.redo': 'Redo',
'edit.selectAll': 'Select All',

View file

@ -175,7 +175,7 @@ describe('LinuxMenu', () => {
});
it('should pass data to context menu', () => {
const data = { selection: 'text' };
const data = { selectionText: 'test selection', x: 100, y: 200 };
linuxMenu.buildContextMenu('chat', data);
expect(Menu.buildFromTemplate).toHaveBeenCalled();

View file

@ -1,8 +1,9 @@
import { Menu, MenuItemConstructorOptions, app, dialog, shell } from 'electron';
/* eslint-disable unicorn/no-array-push-push */
import { Menu, MenuItemConstructorOptions, app, clipboard, dialog, shell } from 'electron';
import { isDev } from '@/const/env';
import type { IMenuPlatform, MenuOptions } from '../types';
import type { ContextMenuData, IMenuPlatform, MenuOptions } from '../types';
import { BaseMenuPlatform } from './BaseMenuPlatform';
export class LinuxMenu extends BaseMenuPlatform implements IMenuPlatform {
@ -16,7 +17,7 @@ export class LinuxMenu extends BaseMenuPlatform implements IMenuPlatform {
return this.appMenu;
}
buildContextMenu(type: string, data?: any): Menu {
buildContextMenu(type: string, data?: ContextMenuData): Menu {
let template: MenuItemConstructorOptions[];
switch (type) {
case 'chat': {
@ -28,7 +29,7 @@ export class LinuxMenu extends BaseMenuPlatform implements IMenuPlatform {
break;
}
default: {
template = this.getDefaultContextMenuTemplate();
template = this.getDefaultContextMenuTemplate(data);
}
}
return Menu.buildFromTemplate(template);
@ -198,35 +199,175 @@ export class LinuxMenu extends BaseMenuPlatform implements IMenuPlatform {
return template;
}
private getDefaultContextMenuTemplate(): MenuItemConstructorOptions[] {
private getDefaultContextMenuTemplate(data?: ContextMenuData): MenuItemConstructorOptions[] {
const t = this.app.i18n.ns('menu');
const hasText = Boolean(data?.selectionText?.trim());
const hasLink = Boolean(data?.linkURL);
const hasImage = data?.mediaType === 'image' && Boolean(data?.srcURL);
return [
const template: MenuItemConstructorOptions[] = [];
// Search with Google - only when text is selected
if (hasText) {
template.push({
click: () => {
const searchUrl = `https://www.google.com/search?q=${encodeURIComponent(data!.selectionText!.trim())}`;
shell.openExternal(searchUrl);
},
label: t('context.searchWithGoogle'),
});
template.push({ type: 'separator' });
}
// Link actions
if (hasLink) {
template.push({
click: () => shell.openExternal(data!.linkURL!),
label: t('context.openLink'),
});
template.push({
click: () => clipboard.writeText(data!.linkURL!),
label: t('context.copyLink'),
});
template.push({ type: 'separator' });
}
// Image actions
if (hasImage) {
template.push({
click: () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.webContents.downloadURL(data!.srcURL!);
},
label: t('context.saveImage'),
});
template.push({
click: () => {
clipboard.writeText(data!.srcURL!);
},
label: t('context.copyImageAddress'),
});
template.push({ type: 'separator' });
}
// Standard edit actions
template.push(
{ 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' },
];
);
// Inspect Element in dev mode
if (isDev && data?.x !== undefined && data?.y !== undefined) {
template.push({ type: 'separator' });
template.push({
click: () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.webContents.inspectElement(data.x!, data.y!);
},
label: t('context.inspectElement'),
});
}
return template;
}
private getChatContextMenuTemplate(data?: any): MenuItemConstructorOptions[] {
console.log(data);
private getChatContextMenuTemplate(data?: ContextMenuData): MenuItemConstructorOptions[] {
const t = this.app.i18n.ns('menu');
const hasText = Boolean(data?.selectionText?.trim());
const hasLink = Boolean(data?.linkURL);
const hasImage = data?.mediaType === 'image' && Boolean(data?.srcURL);
return [
const template: MenuItemConstructorOptions[] = [];
// Search with Google - only when text is selected
if (hasText) {
template.push({
click: () => {
const searchUrl = `https://www.google.com/search?q=${encodeURIComponent(data!.selectionText!.trim())}`;
shell.openExternal(searchUrl);
},
label: t('context.searchWithGoogle'),
});
template.push({ type: 'separator' });
}
// Link actions
if (hasLink) {
template.push({
click: () => shell.openExternal(data!.linkURL!),
label: t('context.openLink'),
});
template.push({
click: () => clipboard.writeText(data!.linkURL!),
label: t('context.copyLink'),
});
template.push({ type: 'separator' });
}
// Image actions
if (hasImage) {
template.push({
click: () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.webContents.downloadURL(data!.srcURL!);
},
label: t('context.saveImage'),
});
template.push({
click: () => {
clipboard.writeText(data!.srcURL!);
},
label: t('context.copyImageAddress'),
});
template.push({ type: 'separator' });
}
// Standard edit actions for chat
template.push(
{ accelerator: 'Ctrl+C', label: t('edit.copy'), role: 'copy' },
{ accelerator: 'Ctrl+V', label: t('edit.paste'), role: 'paste' },
{ type: 'separator' },
{ label: t('edit.selectAll'), role: 'selectAll' },
];
);
// Inspect Element in dev mode
if (isDev && data?.x !== undefined && data?.y !== undefined) {
template.push({ type: 'separator' });
template.push({
click: () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.webContents.inspectElement(data.x!, data.y!);
},
label: t('context.inspectElement'),
});
}
return template;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
private getEditorContextMenuTemplate(_data?: any): MenuItemConstructorOptions[] {
private getEditorContextMenuTemplate(data?: ContextMenuData): MenuItemConstructorOptions[] {
const t = this.app.i18n.ns('menu');
const hasText = Boolean(data?.selectionText?.trim());
return [
const template: MenuItemConstructorOptions[] = [];
// Search with Google - only when text is selected
if (hasText) {
template.push({
click: () => {
const searchUrl = `https://www.google.com/search?q=${encodeURIComponent(data!.selectionText!.trim())}`;
shell.openExternal(searchUrl);
},
label: t('context.searchWithGoogle'),
});
template.push({ type: 'separator' });
}
// Standard edit actions for editor
template.push(
{ 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' },
@ -234,7 +375,21 @@ export class LinuxMenu extends BaseMenuPlatform implements IMenuPlatform {
{ accelerator: 'Ctrl+A', label: t('edit.selectAll'), role: 'selectAll' },
{ type: 'separator' },
{ label: t('edit.delete'), role: 'delete' },
];
);
// Inspect Element in dev mode
if (isDev && data?.x !== undefined && data?.y !== undefined) {
template.push({ type: 'separator' });
template.push({
click: () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.webContents.inspectElement(data.x!, data.y!);
},
label: t('context.inspectElement'),
});
}
return template;
}
private getTrayMenuTemplate(): MenuItemConstructorOptions[] {

View file

@ -147,7 +147,7 @@ describe('MacOSMenu', () => {
});
it('should pass data to chat context menu', () => {
const data = { messageId: '123' };
const data = { selectionText: 'test selection', x: 100, y: 200 };
macOSMenu.buildContextMenu('chat', data);
expect(Menu.buildFromTemplate).toHaveBeenCalled();

View file

@ -1,11 +1,12 @@
import { Menu, MenuItemConstructorOptions, app, shell } from 'electron';
/* eslint-disable unicorn/no-array-push-push */
import { Menu, MenuItemConstructorOptions, app, clipboard, shell } from 'electron';
import * as path from 'node:path';
import { isDev } from '@/const/env';
import NotificationCtr from '@/controllers/NotificationCtr';
import SystemController from '@/controllers/SystemCtr';
import type { IMenuPlatform, MenuOptions } from '../types';
import type { ContextMenuData, IMenuPlatform, MenuOptions } from '../types';
import { BaseMenuPlatform } from './BaseMenuPlatform';
export class MacOSMenu extends BaseMenuPlatform implements IMenuPlatform {
@ -22,7 +23,7 @@ export class MacOSMenu extends BaseMenuPlatform implements IMenuPlatform {
return this.appMenu;
}
buildContextMenu(type: string, data?: any): Menu {
buildContextMenu(type: string, data?: ContextMenuData): Menu {
let template: MenuItemConstructorOptions[];
switch (type) {
case 'chat': {
@ -34,7 +35,7 @@ export class MacOSMenu extends BaseMenuPlatform implements IMenuPlatform {
break;
}
default: {
template = this.getDefaultContextMenuTemplate();
template = this.getDefaultContextMenuTemplate(data);
}
}
return Menu.buildFromTemplate(template);
@ -370,35 +371,210 @@ export class MacOSMenu extends BaseMenuPlatform implements IMenuPlatform {
return template;
}
private getDefaultContextMenuTemplate(): MenuItemConstructorOptions[] {
private getDefaultContextMenuTemplate(data?: ContextMenuData): MenuItemConstructorOptions[] {
const t = this.app.i18n.ns('menu');
const hasText = Boolean(data?.selectionText?.trim());
const hasLink = Boolean(data?.linkURL);
const hasImage = data?.mediaType === 'image' && Boolean(data?.srcURL);
return [
const template: MenuItemConstructorOptions[] = [];
// Look Up (macOS only) - only when text is selected
if (hasText) {
template.push({
click: () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.webContents.showDefinitionForSelection();
},
label: t('edit.lookUp'),
});
template.push({ type: 'separator' });
}
// Search with Google - only when text is selected
if (hasText) {
template.push({
click: () => {
const searchUrl = `https://www.google.com/search?q=${encodeURIComponent(data!.selectionText!.trim())}`;
shell.openExternal(searchUrl);
},
label: t('context.searchWithGoogle'),
});
template.push({ type: 'separator' });
}
// Link actions
if (hasLink) {
template.push({
click: () => shell.openExternal(data!.linkURL!),
label: t('context.openLink'),
});
template.push({
click: () => clipboard.writeText(data!.linkURL!),
label: t('context.copyLink'),
});
template.push({ type: 'separator' });
}
// Image actions
if (hasImage) {
template.push({
click: () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.webContents.downloadURL(data!.srcURL!);
},
label: t('context.saveImage'),
});
template.push({
click: () => {
clipboard.writeText(data!.srcURL!);
},
label: t('context.copyImageAddress'),
});
template.push({ type: 'separator' });
}
// Standard edit actions
template.push(
{ 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' },
];
);
// Inspect Element in dev mode
if (isDev && data?.x !== undefined && data?.y !== undefined) {
template.push({ type: 'separator' });
template.push({
click: () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.webContents.inspectElement(data.x!, data.y!);
},
label: t('context.inspectElement'),
});
}
return template;
}
private getChatContextMenuTemplate(data?: any): MenuItemConstructorOptions[] {
console.log(data);
private getChatContextMenuTemplate(data?: ContextMenuData): MenuItemConstructorOptions[] {
const t = this.app.i18n.ns('menu');
const hasText = Boolean(data?.selectionText?.trim());
const hasLink = Boolean(data?.linkURL);
const hasImage = data?.mediaType === 'image' && Boolean(data?.srcURL);
return [
const template: MenuItemConstructorOptions[] = [];
// Look Up (macOS only) - only when text is selected
if (hasText) {
template.push({
click: () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.webContents.showDefinitionForSelection();
},
label: t('edit.lookUp'),
});
template.push({ type: 'separator' });
}
// Search with Google - only when text is selected
if (hasText) {
template.push({
click: () => {
const searchUrl = `https://www.google.com/search?q=${encodeURIComponent(data!.selectionText!.trim())}`;
shell.openExternal(searchUrl);
},
label: t('context.searchWithGoogle'),
});
template.push({ type: 'separator' });
}
// Link actions
if (hasLink) {
template.push({
click: () => shell.openExternal(data!.linkURL!),
label: t('context.openLink'),
});
template.push({
click: () => clipboard.writeText(data!.linkURL!),
label: t('context.copyLink'),
});
template.push({ type: 'separator' });
}
// Image actions
if (hasImage) {
template.push({
click: () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.webContents.downloadURL(data!.srcURL!);
},
label: t('context.saveImage'),
});
template.push({
click: () => {
clipboard.writeText(data!.srcURL!);
},
label: t('context.copyImageAddress'),
});
template.push({ type: 'separator' });
}
// Standard edit actions for chat (copy/paste focused)
template.push(
{ accelerator: 'Command+C', label: t('edit.copy'), role: 'copy' },
{ accelerator: 'Command+V', label: t('edit.paste'), role: 'paste' },
{ type: 'separator' },
{ label: t('edit.selectAll'), role: 'selectAll' },
];
);
// Inspect Element in dev mode
if (isDev && data?.x !== undefined && data?.y !== undefined) {
template.push({ type: 'separator' });
template.push({
click: () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.webContents.inspectElement(data.x!, data.y!);
},
label: t('context.inspectElement'),
});
}
return template;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
private getEditorContextMenuTemplate(_data?: any): MenuItemConstructorOptions[] {
private getEditorContextMenuTemplate(data?: ContextMenuData): MenuItemConstructorOptions[] {
const t = this.app.i18n.ns('menu');
const hasText = Boolean(data?.selectionText?.trim());
return [
const template: MenuItemConstructorOptions[] = [];
// Look Up (macOS only) - only when text is selected
if (hasText) {
template.push({
click: () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.webContents.showDefinitionForSelection();
},
label: t('edit.lookUp'),
});
template.push({ type: 'separator' });
}
// Search with Google - only when text is selected
if (hasText) {
template.push({
click: () => {
const searchUrl = `https://www.google.com/search?q=${encodeURIComponent(data!.selectionText!.trim())}`;
shell.openExternal(searchUrl);
},
label: t('context.searchWithGoogle'),
});
template.push({ type: 'separator' });
}
// Standard edit actions for editor (full edit capabilities)
template.push(
{ 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' },
@ -406,7 +582,21 @@ export class MacOSMenu extends BaseMenuPlatform implements IMenuPlatform {
{ accelerator: 'Command+A', label: t('edit.selectAll'), role: 'selectAll' },
{ type: 'separator' },
{ label: t('edit.delete'), role: 'delete' },
];
);
// Inspect Element in dev mode
if (isDev && data?.x !== undefined && data?.y !== undefined) {
template.push({ type: 'separator' });
template.push({
click: () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.webContents.inspectElement(data.x!, data.y!);
},
label: t('context.inspectElement'),
});
}
return template;
}
private getTrayMenuTemplate(): MenuItemConstructorOptions[] {

View file

@ -150,7 +150,7 @@ describe('WindowsMenu', () => {
});
it('should pass data to context menu', () => {
const data = { text: 'selected text' };
const data = { selectionText: 'selected text', x: 100, y: 200 };
windowsMenu.buildContextMenu('editor', data);
expect(Menu.buildFromTemplate).toHaveBeenCalled();

View file

@ -1,8 +1,9 @@
import { Menu, MenuItemConstructorOptions, app, shell } from 'electron';
/* eslint-disable unicorn/no-array-push-push */
import { Menu, MenuItemConstructorOptions, app, clipboard, shell } from 'electron';
import { isDev } from '@/const/env';
import type { IMenuPlatform, MenuOptions } from '../types';
import type { ContextMenuData, IMenuPlatform, MenuOptions } from '../types';
import { BaseMenuPlatform } from './BaseMenuPlatform';
export class WindowsMenu extends BaseMenuPlatform implements IMenuPlatform {
@ -16,7 +17,7 @@ export class WindowsMenu extends BaseMenuPlatform implements IMenuPlatform {
return this.appMenu;
}
buildContextMenu(type: string, data?: any): Menu {
buildContextMenu(type: string, data?: ContextMenuData): Menu {
let template: MenuItemConstructorOptions[];
switch (type) {
case 'chat': {
@ -28,7 +29,7 @@ export class WindowsMenu extends BaseMenuPlatform implements IMenuPlatform {
break;
}
default: {
template = this.getDefaultContextMenuTemplate();
template = this.getDefaultContextMenuTemplate(data);
}
}
return Menu.buildFromTemplate(template);
@ -178,35 +179,175 @@ export class WindowsMenu extends BaseMenuPlatform implements IMenuPlatform {
return template;
}
private getDefaultContextMenuTemplate(): MenuItemConstructorOptions[] {
private getDefaultContextMenuTemplate(data?: ContextMenuData): MenuItemConstructorOptions[] {
const t = this.app.i18n.ns('menu');
const hasText = Boolean(data?.selectionText?.trim());
const hasLink = Boolean(data?.linkURL);
const hasImage = data?.mediaType === 'image' && Boolean(data?.srcURL);
return [
const template: MenuItemConstructorOptions[] = [];
// Search with Google - only when text is selected
if (hasText) {
template.push({
click: () => {
const searchUrl = `https://www.google.com/search?q=${encodeURIComponent(data!.selectionText!.trim())}`;
shell.openExternal(searchUrl);
},
label: t('context.searchWithGoogle'),
});
template.push({ type: 'separator' });
}
// Link actions
if (hasLink) {
template.push({
click: () => shell.openExternal(data!.linkURL!),
label: t('context.openLink'),
});
template.push({
click: () => clipboard.writeText(data!.linkURL!),
label: t('context.copyLink'),
});
template.push({ type: 'separator' });
}
// Image actions
if (hasImage) {
template.push({
click: () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.webContents.downloadURL(data!.srcURL!);
},
label: t('context.saveImage'),
});
template.push({
click: () => {
clipboard.writeText(data!.srcURL!);
},
label: t('context.copyImageAddress'),
});
template.push({ type: 'separator' });
}
// Standard edit actions
template.push(
{ 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' },
];
);
// Inspect Element in dev mode
if (isDev && data?.x !== undefined && data?.y !== undefined) {
template.push({ type: 'separator' });
template.push({
click: () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.webContents.inspectElement(data.x!, data.y!);
},
label: t('context.inspectElement'),
});
}
return template;
}
private getChatContextMenuTemplate(data?: any): MenuItemConstructorOptions[] {
console.log(data);
private getChatContextMenuTemplate(data?: ContextMenuData): MenuItemConstructorOptions[] {
const t = this.app.i18n.ns('menu');
const hasText = Boolean(data?.selectionText?.trim());
const hasLink = Boolean(data?.linkURL);
const hasImage = data?.mediaType === 'image' && Boolean(data?.srcURL);
return [
const template: MenuItemConstructorOptions[] = [];
// Search with Google - only when text is selected
if (hasText) {
template.push({
click: () => {
const searchUrl = `https://www.google.com/search?q=${encodeURIComponent(data!.selectionText!.trim())}`;
shell.openExternal(searchUrl);
},
label: t('context.searchWithGoogle'),
});
template.push({ type: 'separator' });
}
// Link actions
if (hasLink) {
template.push({
click: () => shell.openExternal(data!.linkURL!),
label: t('context.openLink'),
});
template.push({
click: () => clipboard.writeText(data!.linkURL!),
label: t('context.copyLink'),
});
template.push({ type: 'separator' });
}
// Image actions
if (hasImage) {
template.push({
click: () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.webContents.downloadURL(data!.srcURL!);
},
label: t('context.saveImage'),
});
template.push({
click: () => {
clipboard.writeText(data!.srcURL!);
},
label: t('context.copyImageAddress'),
});
template.push({ type: 'separator' });
}
// Standard edit actions for chat
template.push(
{ accelerator: 'Ctrl+C', label: t('edit.copy'), role: 'copy' },
{ accelerator: 'Ctrl+V', label: t('edit.paste'), role: 'paste' },
{ type: 'separator' },
{ label: t('edit.selectAll'), role: 'selectAll' },
];
);
// Inspect Element in dev mode
if (isDev && data?.x !== undefined && data?.y !== undefined) {
template.push({ type: 'separator' });
template.push({
click: () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.webContents.inspectElement(data.x!, data.y!);
},
label: t('context.inspectElement'),
});
}
return template;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
private getEditorContextMenuTemplate(_data?: any): MenuItemConstructorOptions[] {
private getEditorContextMenuTemplate(data?: ContextMenuData): MenuItemConstructorOptions[] {
const t = this.app.i18n.ns('menu');
const hasText = Boolean(data?.selectionText?.trim());
return [
const template: MenuItemConstructorOptions[] = [];
// Search with Google - only when text is selected
if (hasText) {
template.push({
click: () => {
const searchUrl = `https://www.google.com/search?q=${encodeURIComponent(data!.selectionText!.trim())}`;
shell.openExternal(searchUrl);
},
label: t('context.searchWithGoogle'),
});
template.push({ type: 'separator' });
}
// Standard edit actions for editor
template.push(
{ 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' },
@ -214,7 +355,21 @@ export class WindowsMenu extends BaseMenuPlatform implements IMenuPlatform {
{ accelerator: 'Ctrl+A', label: t('edit.selectAll'), role: 'selectAll' },
{ type: 'separator' },
{ label: t('edit.delete'), role: 'delete' },
];
);
// Inspect Element in dev mode
if (isDev && data?.x !== undefined && data?.y !== undefined) {
template.push({ type: 'separator' });
template.push({
click: () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.webContents.inspectElement(data.x!, data.y!);
},
label: t('context.inspectElement'),
});
}
return template;
}
private getTrayMenuTemplate(): MenuItemConstructorOptions[] {

View file

@ -5,6 +5,27 @@ export interface MenuOptions {
// Other possible configuration items
}
/**
* Context menu data passed from renderer process
* Based on Electron's ContextMenuParams
*/
export interface ContextMenuData {
/** Whether the context is editable (input, textarea, contenteditable) */
isEditable?: boolean;
/** URL of the link if right-clicked on a link */
linkURL?: string;
/** Media type if right-clicked on media element */
mediaType?: 'none' | 'image' | 'audio' | 'video' | 'canvas' | 'file' | 'plugin';
/** Selected text */
selectionText?: string;
/** Source URL of media element (image/video/audio src) */
srcURL?: string;
/** X coordinate of the context menu */
x?: number;
/** Y coordinate of the context menu */
y?: number;
}
export interface IMenuPlatform {
/**
* Build and set application menu
@ -14,7 +35,7 @@ export interface IMenuPlatform {
/**
* Build context menu
*/
buildContextMenu(type: string, data?: any): Menu;
buildContextMenu(type: string, data?: ContextMenuData): Menu;
/**
* Build tray menu

View file

@ -28,15 +28,18 @@ type AuthType =
type PermissionType = 'authorized' | 'denied' | 'not determined' | 'restricted';
// Lazy-loaded module cache
// @ts-ignore - node-mac-permissions is optional and only available on macOS
let macPermissionsModule: typeof import('node-mac-permissions') | null = null;
// Test injection override (set via __setMacPermissionsModule for testing)
// @ts-ignore - node-mac-permissions is optional and only available on macOS
let testModuleOverride: typeof import('node-mac-permissions') | null = null;
/**
* Lazily load the node-mac-permissions module (macOS only)
* Returns null on non-macOS platforms
*/
// @ts-ignore - node-mac-permissions is optional and only available on macOS
function getMacPermissionsModule(): typeof import('node-mac-permissions') | null {
// Allow test injection to override the module
if (testModuleOverride) {
@ -70,6 +73,7 @@ export function __resetMacPermissionsModuleCache(): void {
* @internal
*/
export function __setMacPermissionsModule(
// @ts-ignore - node-mac-permissions is optional and only available on macOS
module: typeof import('node-mac-permissions') | null,
): void {
testModuleOverride = module;

View file

@ -22,6 +22,7 @@
},
"include": [
"src/main/**/*",
"src/main/global.d.ts",
"src/preload/**/*",
"src/common/**/*",
"electron-builder.js",

View file

@ -207,7 +207,7 @@
"@lobehub/icons": "^4.0.3",
"@lobehub/market-sdk": "0.29.1",
"@lobehub/tts": "^4.0.2",
"@lobehub/ui": "^4.32.0",
"@lobehub/ui": "^4.32.1",
"@modelcontextprotocol/sdk": "^1.25.3",
"@napi-rs/canvas": "^0.1.88",
"@neondatabase/serverless": "^1.0.2",

View file

@ -30,7 +30,8 @@ const lazyFileLoaders: Record<SupportedFileType, LazyLoaderFactory> = {
globalThis.DOMPoint = canvas.DOMPoint;
globalThis.DOMRect = canvas.DOMRect;
globalThis.Path2D = canvas.Path2D;
} catch {
} catch (e) {
console.error('Error importing @napi-rs/canvas:', e);
// @napi-rs/canvas not available, pdfjs-dist may fail if DOMMatrix is needed
}
}

View file

@ -459,7 +459,14 @@ const LoginStep = memo<LoginStepProps>(({ onBack, onNext }) => {
if (!isDesktop) return;
e.preventDefault();
const { electronSystemService } = await import('@/services/electron/system');
await electronSystemService.showContextMenu('edit');
const input = e.target as HTMLInputElement;
const selectionText = input.value.slice(
input.selectionStart || 0,
input.selectionEnd || 0,
);
await electronSystemService.showContextMenu('editor', {
selectionText: selectionText || undefined,
});
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {

View file

@ -165,12 +165,10 @@ const InputEditor = memo<{ defaultRows?: number }>(({ defaultRows = 2 }) => {
e.preventDefault();
const { electronSystemService } = await import('@/services/electron/system');
const selectionValue = editor.getSelectionDocument('markdown') as unknown as string;
const hasSelection = !!selectionValue;
const selectionText = editor.getSelectionDocument('markdown') as unknown as string;
await electronSystemService.showContextMenu('editor', {
hasSelection,
value: selectionValue,
selectionText: selectionText || undefined,
});
}
}}

View file

@ -85,12 +85,17 @@ const MessageItem = memo<MessageItemProps>(
if (isDesktop) {
const { electronSystemService } = await import('@/services/electron/system');
// Get selected text for context menu features like Look Up and Search
const selection = window.getSelection();
const selectionText = selection?.toString() || '';
electronSystemService.showContextMenu('chat', {
content: message.content,
hasError: !!message.error,
messageId: id,
// For assistantGroup, we treat it as assistant for context menu purposes
role: message.role === 'assistantGroup' ? 'assistant' : message.role,
selectionText,
});
return;

View file

@ -3,25 +3,19 @@
import { useWatchBroadcast } from '@lobechat/electron-client-ipc';
import { Button, Flexbox, Icon, type ModalInstance, createModal } from '@lobehub/ui';
import { AlertCircle, LogIn } from 'lucide-react';
import { type ReactNode, memo, useCallback, useRef, useState } from 'react';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom';
import { useElectronStore } from '@/store/electron';
interface ModalUpdateOptions {
closable?: boolean;
keyboard?: boolean;
maskClosable?: boolean;
title?: ReactNode;
}
interface AuthRequiredModalContentProps {
onClose: () => void;
setModalProps: (props: ModalUpdateOptions) => void;
onSigningInChange?: (isSigningIn: boolean) => void;
}
const AuthRequiredModalContent = memo<AuthRequiredModalContentProps>(
({ onClose, setModalProps }) => {
({ onClose, onSigningInChange }) => {
const { t } = useTranslation('auth');
const [isSigningIn, setIsSigningIn] = useState(false);
const isClosingRef = useRef(false);
@ -34,18 +28,9 @@ const AuthRequiredModalContent = memo<AuthRequiredModalContentProps>(
s.clearRemoteServerSyncError,
]);
// Update modal props based on signing in state
setModalProps({
closable: !isSigningIn,
keyboard: !isSigningIn,
maskClosable: !isSigningIn,
title: (
<Flexbox align="center" gap={8} horizontal>
<Icon icon={AlertCircle} />
{t('authModal.title')}
</Flexbox>
),
});
useEffect(() => {
onSigningInChange?.(isSigningIn);
}, [isSigningIn, onSigningInChange]);
// Listen for successful authorization to close the modal
useWatchBroadcast('authorizationSuccessful', async () => {
@ -104,30 +89,41 @@ AuthRequiredModalContent.displayName = 'AuthRequiredModalContent';
* Hook to create and manage the auth required modal
*/
export const useAuthRequiredModal = () => {
const { t } = useTranslation('auth');
const instanceRef = useRef<ModalInstance | null>(null);
const open = useCallback(() => {
// Don't open multiple modals
if (instanceRef.current) return;
const setModalProps = (nextProps: ModalUpdateOptions) => {
instanceRef.current?.update?.(nextProps);
};
const handleClose = () => {
instanceRef.current?.close();
instanceRef.current = null;
};
const handleSigningInChange = (isSigningIn: boolean) => {
instanceRef.current?.update?.({
closable: !isSigningIn,
keyboard: !isSigningIn,
maskClosable: !isSigningIn,
});
};
instanceRef.current = createModal({
children: <AuthRequiredModalContent onClose={handleClose} setModalProps={setModalProps} />,
children: (
<AuthRequiredModalContent onClose={handleClose} onSigningInChange={handleSigningInChange} />
),
closable: false,
footer: null,
keyboard: false,
maskClosable: false,
title: '',
title: (
<Flexbox align="center" gap={8} horizontal>
<Icon icon={AlertCircle} />
{t('authModal.title')}
</Flexbox>
),
});
}, []);
}, [t]);
return { open };
};
@ -138,8 +134,12 @@ export const useAuthRequiredModal = () => {
const AuthRequiredModal = memo(() => {
const { open } = useAuthRequiredModal();
// Listen for IPC event to open the modal
const pathname = useLocation().pathname;
useWatchBroadcast('authorizationRequired', () => {
if (useElectronStore.getState().isConnectionDrawerOpen) return;
if (pathname.includes('/desktop-onboarding')) return;
open();
});

View file

@ -1,9 +1,11 @@
import { Center, Flexbox } from '@lobehub/ui';
import { Drawer } from 'antd';
import { createStaticStyles, cssVar } from 'antd-style';
import { useCallback, useState } from 'react';
import { Suspense, useCallback } from 'react';
import LoginStep from '@/app/[variants]/(desktop)/desktop-onboarding/features/LoginStep';
import { BrandTextLoading } from '@/components/Loading';
import { useElectronStore } from '@/store/electron';
import { isMacOS } from '@/utils/platform';
import RemoteStatus from './RemoteStatus';
@ -23,17 +25,20 @@ const styles = createStaticStyles(({ css }) => {
});
const Connection = () => {
const [isOpen, setIsOpen] = useState(false);
const [isOpen, setConnectionDrawerOpen] = useElectronStore((s) => [
s.isConnectionDrawerOpen,
s.setConnectionDrawerOpen,
]);
const handleClose = useCallback(() => {
setIsOpen(false);
}, []);
setConnectionDrawerOpen(false);
}, [setConnectionDrawerOpen]);
return (
<>
<RemoteStatus
onClick={() => {
setIsOpen(true);
setConnectionDrawerOpen(true);
}}
/>
<Drawer
@ -47,11 +52,19 @@ const Connection = () => {
}}
styles={{ body: { padding: 0 }, header: { padding: 0 } }}
>
<Center style={{ height: '100%', overflow: 'auto', padding: 24 }}>
<Flexbox style={{ maxWidth: 560, width: '100%' }}>
<LoginStep onBack={handleClose} onNext={handleClose} />
</Flexbox>
</Center>
<Suspense
fallback={
<Center style={{ height: '100%' }}>
<BrandTextLoading debugId="Connection" />
</Center>
}
>
<Center style={{ height: '100%', overflow: 'auto', padding: 24 }}>
<Flexbox style={{ maxWidth: 560, width: '100%' }}>
<LoginStep onBack={handleClose} onNext={handleClose} />
</Flexbox>
</Center>
</Suspense>
</Drawer>
</>
);

View file

@ -7,12 +7,15 @@ import { type FC } from 'react';
import { ProductLogo } from '@/components/Branding/ProductLogo';
import { electronStylish } from '@/styles/electron';
import { useWatchThemeUpdate } from '../system/useWatchThemeUpdate';
/**
* A simple, minimal TitleBar for Electron windows.
* Provides draggable area without business logic (navigation, updates, etc.)
* Use this for secondary windows like onboarding, settings, etc.
*/
const SimpleTitleBar: FC = () => {
useWatchThemeUpdate();
return (
<Flexbox
align={'center'}

View file

@ -15,6 +15,7 @@ import type { ElectronStore } from '../store';
// ======== Action Interface ======== //
export interface ElectronAppAction {
setConnectionDrawerOpen: (isOpen: boolean) => void;
updateElectronAppState: (state: ElectronAppState) => void;
/**
@ -32,6 +33,10 @@ export const createElectronAppSlice: StateCreator<
[],
ElectronAppAction
> = (set, get) => ({
setConnectionDrawerOpen: (isOpen: boolean) => {
set({ isConnectionDrawerOpen: isOpen }, false, 'setConnectionDrawerOpen');
},
updateElectronAppState: (state: ElectronAppState) => {
const prevState = get().appState;
set({ appState: merge(prevState, state) });

View file

@ -122,6 +122,7 @@ export const remoteSyncSlice: StateCreator<
set({ dataSyncConfig: data, isInitRemoteServerConfig: true });
},
suspense: false,
},
),
});

View file

@ -26,6 +26,7 @@ export interface ElectronState extends NavigationHistoryState {
desktopHotkeys: Record<string, string>;
isAppStateInit?: boolean;
isConnectingServer?: boolean;
isConnectionDrawerOpen?: boolean;
isDesktopHotkeysInit: boolean;
isInitRemoteServerConfig: boolean;
isSyncActive?: boolean;
@ -40,6 +41,7 @@ export const initialState: ElectronState = {
desktopHotkeys: {},
isAppStateInit: false,
isConnectingServer: false,
isConnectionDrawerOpen: false,
isDesktopHotkeysInit: false,
isInitRemoteServerConfig: false,
isSyncActive: false,