diff --git a/README.md b/README.md index ed1e91a5..db0195a4 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,27 @@ pnpm build:linux:arm64 # Linux (按 arm64 架构打包) 2. 输入应用名称或命令进行搜索 3. 按 `↑` `↓` `←` `→` 选择,`Enter` 确认,`Esc` 退出 +### 命令行唤起 + +在终端执行 `ztools` 可以唤起已运行的 ZTools 实例并显示主窗口;如果应用尚未运行,则会先启动应用再显示窗口。命令会立即返回,不阻塞终端。 + +```bash +# Linux(deb 安装包)与 macOS 手动安装后可直接使用 +ztools + +# 开发环境中运行(先执行 pnpm build 构建主程序) +pnpm ztools + +# 通过环境变量指定可执行文件 +ZTOOLS_BIN=/path/to/ZTools ztools +``` + +不同安装方式下的可用性: + +- **Linux deb**:安装包会自动创建 `/usr/bin/ztools` 命令,卸载时自动移除 +- **macOS dmg**:首次使用需手动建立软链接:`sudo ln -s "/Applications/ZTools.app/Contents/Resources/ztools/ztools-launcher.sh" /usr/local/bin/ztools` +- **Windows**:仅覆盖开发环境(`pnpm ztools`) + ## 🧩 插件开发 ZTools 是一个强大、可扩展的插件平台,使用自定义插件提升您的生产力。通过简单的配置、丰富的 API 以及跨平台支持,您可以轻松开发出功能强大的插件。 @@ -222,6 +243,9 @@ pnpm build:linux # 打包 Linux 应用 (当前主机架构) pnpm build:linux:x64 # 打包 Linux 应用 (amd64/x64) pnpm build:linux:arm64 # 打包 Linux 应用 (arm64) pnpm build:unpack # 打包但不生成安装包(调试用) + +# 命令行唤起 +pnpm ztools # 唤起已运行的实例(或启动应用) ``` ### 调试 diff --git a/README_EN.md b/README_EN.md index adc27aa3..3ba1086c 100644 --- a/README_EN.md +++ b/README_EN.md @@ -124,6 +124,27 @@ pnpm build:linux:arm64 # Linux (arm64) 2. Enter application name or command to search 3. Use `↑` `↓` `←` `→` to navigate, `Enter` to confirm, `Esc` to exit +### Wake Up from Command Line + +Run `ztools` in a terminal to bring up an already running ZTools instance and show its main window; if the app is not running yet, it will be started first and then shown. The command returns immediately and does not block the terminal. + +```bash +# Linux (deb package) and macOS (after manual install) can use it directly +ztools + +# In the development environment (run pnpm build first) +pnpm ztools + +# Point to a specific executable with an environment variable +ZTOOLS_BIN=/path/to/ZTools ztools +``` + +Availability by installation method: + +- **Linux deb**: the package creates `/usr/bin/ztools` automatically and removes it on uninstall +- **macOS dmg**: create a symlink manually on first use: `sudo ln -s "/Applications/ZTools.app/Contents/Resources/ztools/ztools-launcher.sh" /usr/local/bin/ztools` +- **Windows**: development environment only (`pnpm ztools`) + ## 🧩 Plugin Development ZTools is a powerful and extensible plugin platform that enhances your productivity with custom plugins. With simple configuration, rich APIs, and cross-platform support, you can easily develop powerful plugins. @@ -210,6 +231,9 @@ pnpm build:linux # Package Linux app (Default Arch) pnpm build:linux:x64 # Package Linux app (amd64/x64) pnpm build:linux:arm64 # Package Linux app (arm64) pnpm build:unpack # Package without installer (for debugging) + +# Command line wake-up +pnpm ztools # Wake up a running instance (or start the app) ``` ### Debugging diff --git a/build/deb-after-install.sh b/build/deb-after-install.sh new file mode 100755 index 00000000..4728e606 --- /dev/null +++ b/build/deb-after-install.sh @@ -0,0 +1,57 @@ +#!/bin/bash + +# ZTools deb 安装后脚本:在保留默认后置处理的基础上, +# 将 /usr/bin/ztools 符号链接指向命令行唤起启动器。 + +if type update-alternatives 2>/dev/null >&1; then + # Remove previous link if it doesn't use update-alternatives + if [ -L '/usr/bin/${executable}' -a -e '/usr/bin/${executable}' -a "`readlink '/usr/bin/${executable}'`" != '/etc/alternatives/${executable}' ]; then + rm -f '/usr/bin/${executable}' + fi + update-alternatives --install '/usr/bin/${executable}' '${executable}' '/opt/${sanitizedProductName}/${executable}' 100 || ln -sf '/opt/${sanitizedProductName}/${executable}' '/usr/bin/${executable}' +else + ln -sf '/opt/${sanitizedProductName}/${executable}' '/usr/bin/${executable}' +fi + +# Check if user namespaces are supported by the kernel and working with a quick test: +if ! { [[ -L /proc/self/ns/user ]] && unshare --user true; }; then + # Use SUID chrome-sandbox only on systems without user namespaces: + chmod 4755 '/opt/${sanitizedProductName}/chrome-sandbox' || true +else + chmod 0755 '/opt/${sanitizedProductName}/chrome-sandbox' || true +fi + +if hash update-mime-database 2>/dev/null; then + update-mime-database /usr/share/mime || true +fi + +if hash update-desktop-database 2>/dev/null; then + update-desktop-database /usr/share/applications || true +fi + +# 刷新 hicolor 图标缓存,缺失命令时静默跳过。 +if hash gtk-update-icon-cache 2>/dev/null; then + gtk-update-icon-cache -f /usr/share/icons/hicolor || true +fi + +# 安装 apparmor profile(Ubuntu 24+),与默认模板保持一致。 +if apparmor_status --enabled > /dev/null 2>&1; then + APPARMOR_PROFILE_SOURCE='/opt/${sanitizedProductName}/resources/apparmor-profile' + APPARMOR_PROFILE_TARGET='/etc/apparmor.d/${executable}' + if apparmor_parser --skip-kernel-load --debug "$APPARMOR_PROFILE_SOURCE" > /dev/null 2>&1; then + cp -f "$APPARMOR_PROFILE_SOURCE" "$APPARMOR_PROFILE_TARGET" + + if ! { [ -x '/usr/bin/ischroot' ] && /usr/bin/ischroot; } && hash apparmor_parser 2>/dev/null; then + apparmor_parser --replace --write-cache --skip-read-cache "$APPARMOR_PROFILE_TARGET" + fi + else + echo "Skipping the installation of the AppArmor profile as this version of AppArmor does not seem to support the bundled profile" + fi +fi + +# 命令行唤起启动器:/usr/bin/ztools -> /opt//resources/ztools/ztools-launcher.sh +ztools_launcher='/opt/${sanitizedProductName}/resources/ztools/ztools-launcher.sh' +if [ -f "$ztools_launcher" ]; then + chmod +x "$ztools_launcher" + ln -sf "$ztools_launcher" '/usr/bin/ztools' +fi diff --git a/build/deb-after-remove.sh b/build/deb-after-remove.sh new file mode 100755 index 00000000..115ec2d7 --- /dev/null +++ b/build/deb-after-remove.sh @@ -0,0 +1,28 @@ +#!/bin/bash + +# ZTools deb 卸载后脚本:移除命令行唤起命令,并保留默认清理逻辑。 + +# 只移除指向 ZTools 启动器的 /usr/bin/ztools,避免误删用户自建的同名命令。 +ztools_launcher='/opt/${sanitizedProductName}/resources/ztools/ztools-launcher.sh' +if [ -L '/usr/bin/ztools' ] && [ "$(readlink '/usr/bin/ztools')" = "$ztools_launcher" ]; then + rm -f '/usr/bin/ztools' +fi + +# Delete the link to the binary +if type update-alternatives >/dev/null 2>&1; then + update-alternatives --remove '${executable}' '/usr/bin/${executable}' +else + rm -f '/usr/bin/${executable}' +fi + +APPARMOR_PROFILE_DEST='/etc/apparmor.d/${executable}' + +# Remove apparmor profile. +if [ -f "$APPARMOR_PROFILE_DEST" ]; then + rm -f "$APPARMOR_PROFILE_DEST" +fi + +# 刷新 hicolor 图标缓存,缺失命令时静默跳过。 +if hash gtk-update-icon-cache 2>/dev/null; then + gtk-update-icon-cache -f /usr/share/icons/hicolor || true +fi diff --git a/build/icons/128x128.png b/build/icons/128x128.png new file mode 100644 index 00000000..1db1c63b Binary files /dev/null and b/build/icons/128x128.png differ diff --git a/build/icons/16x16.png b/build/icons/16x16.png new file mode 100644 index 00000000..aa501e82 Binary files /dev/null and b/build/icons/16x16.png differ diff --git a/build/icons/24x24.png b/build/icons/24x24.png new file mode 100644 index 00000000..c47ce007 Binary files /dev/null and b/build/icons/24x24.png differ diff --git a/build/icons/256x256.png b/build/icons/256x256.png new file mode 100644 index 00000000..03fade25 Binary files /dev/null and b/build/icons/256x256.png differ diff --git a/build/icons/32x32.png b/build/icons/32x32.png new file mode 100644 index 00000000..b24f401d Binary files /dev/null and b/build/icons/32x32.png differ diff --git a/build/icons/48x48.png b/build/icons/48x48.png new file mode 100644 index 00000000..2ee1fcec Binary files /dev/null and b/build/icons/48x48.png differ diff --git a/build/icons/512x512.png b/build/icons/512x512.png new file mode 100644 index 00000000..d9089695 Binary files /dev/null and b/build/icons/512x512.png differ diff --git a/build/icons/64x64.png b/build/icons/64x64.png new file mode 100644 index 00000000..8b331322 Binary files /dev/null and b/build/icons/64x64.png differ diff --git a/build/pacman-after-install.sh b/build/pacman-after-install.sh new file mode 100755 index 00000000..aa7ddd38 --- /dev/null +++ b/build/pacman-after-install.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +# ZTools pacman 安装后脚本:创建命令行唤起启动器符号链接, +# 并刷新桌面数据库与图标缓存,确保应用菜单显示 ZTools 图标。 + +# 命令行唤起启动器:/usr/bin/ztools -> /opt//resources/ztools/ztools-launcher.sh +ztools_launcher='/opt/${sanitizedProductName}/resources/ztools/ztools-launcher.sh' +if [ -f "$ztools_launcher" ]; then + chmod +x "$ztools_launcher" + ln -sf "$ztools_launcher" '/usr/bin/ztools' +fi + +# 刷新桌面入口数据库,缺失命令时静默跳过。 +if hash update-desktop-database 2>/dev/null; then + update-desktop-database /usr/share/applications || true +fi + +# 刷新 hicolor 图标缓存,缺失命令时静默跳过。 +if hash gtk-update-icon-cache 2>/dev/null; then + gtk-update-icon-cache -f /usr/share/icons/hicolor || true +fi diff --git a/build/pacman-after-remove.sh b/build/pacman-after-remove.sh new file mode 100755 index 00000000..7dd8d283 --- /dev/null +++ b/build/pacman-after-remove.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +# ZTools pacman 卸载后脚本:移除命令行唤起启动器符号链接, +# 并刷新桌面数据库与图标缓存。 + +# 只移除指向 ZTools 启动器的 /usr/bin/ztools,避免误删用户自建的同名命令。 +ztools_launcher='/opt/${sanitizedProductName}/resources/ztools/ztools-launcher.sh' +if [ -L '/usr/bin/ztools' ] && [ "$(readlink '/usr/bin/ztools')" = "$ztools_launcher" ]; then + rm -f '/usr/bin/ztools' +fi + +# 刷新桌面入口数据库,缺失命令时静默跳过。 +if hash update-desktop-database 2>/dev/null; then + update-desktop-database /usr/share/applications || true +fi + +# 刷新 hicolor 图标缓存,缺失命令时静默跳过。 +if hash gtk-update-icon-cache 2>/dev/null; then + gtk-update-icon-cache -f /usr/share/icons/hicolor || true +fi diff --git a/build/ztools-launcher.sh b/build/ztools-launcher.sh new file mode 100755 index 00000000..83a9c9be --- /dev/null +++ b/build/ztools-launcher.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env sh + +# ZTools 命令行唤起启动器。 +# 打包时被 electron-builder 放入应用 resources/ztools/ 目录, +# 安装器会将 /usr/bin/ztools 符号链接指向本脚本。 + +# 脚本实际所在目录(resources/ztools)。通过符号链接调用时先解析真实路径。 +# macOS 自带的 readlink 不支持 -f,这里逐层解析相对/绝对目标并限制深度, +# 避免死链或循环链导致脚本卡死。 +SCRIPT_PATH=$0 +SCRIPT_DEPTH=0 +while [ -L "$SCRIPT_PATH" ]; do + SCRIPT_LINK=$(readlink "$SCRIPT_PATH") + case "$SCRIPT_LINK" in + /*) SCRIPT_PATH=$SCRIPT_LINK ;; + *) SCRIPT_PATH=$(dirname -- "$SCRIPT_PATH")/$SCRIPT_LINK ;; + esac + SCRIPT_DEPTH=$((SCRIPT_DEPTH + 1)) + if [ "$SCRIPT_DEPTH" -gt 40 ]; then + echo "符号链接解析深度超限: $0" >&2 + exit 1 + fi +done +# pwd -P 输出物理路径,确保目录部分也不残留符号链接。 +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$SCRIPT_PATH")" && pwd -P) + +# 依次探测 Linux 与 macOS 打包布局中的真实可执行文件。 +# 只接受普通文件(-f),避免像旧版那样把 resources/ztools 目录当作可执行文件 exec。 +for BIN in \ + "$SCRIPT_DIR/../../MacOS/ZTools" \ + "$SCRIPT_DIR/../../ztools" \ + "$SCRIPT_DIR/../../ZTools" +do + if [ -f "$BIN" ] && [ -x "$BIN" ]; then + exec "$BIN" --ztools-wake + fi +done + +echo "未找到 ZTools 可执行文件(已探测 $SCRIPT_DIR/../../MacOS/ZTools、$SCRIPT_DIR/../../ztools、$SCRIPT_DIR/../../ZTools)" >&2 +exit 1 diff --git a/docs/examples/provider-example/README.md b/docs/examples/provider-example/README.md index bfa4e6b6..e55027b7 100644 --- a/docs/examples/provider-example/README.md +++ b/docs/examples/provider-example/README.md @@ -21,8 +21,12 @@ ``` ```js -ztools.registerProvider('baidu', async (input) => { /* ... */ }) -ztools.registerProvider('google', async (input) => { /* ... */ }) +ztools.registerProvider('baidu', async (input) => { + /* ... */ +}) +ztools.registerProvider('google', async (input) => { + /* ... */ +}) ``` 详见主程序 `docs/provider-development-guide.md`。 diff --git a/docs/provider-development-guide.md b/docs/provider-development-guide.md index d1508d36..65edfa4c 100644 --- a/docs/provider-development-guide.md +++ b/docs/provider-development-guide.md @@ -50,7 +50,7 @@ ZTools 把「翻译」「OCR」等能力抽象为 **Provider(提供商)**。 ```json { "providers": { - "baidu": { "type": "translation", "label": "百度翻译" }, + "baidu": { "type": "translation", "label": "百度翻译" }, "google": { "type": "translation", "label": "谷歌翻译" } } } @@ -58,8 +58,12 @@ ZTools 把「翻译」「OCR」等能力抽象为 **Provider(提供商)**。 ```js // preload.js -ztools.registerProvider('baidu', async (input) => { /* 调用百度 */ }) -ztools.registerProvider('google', async (input) => { /* 调用谷歌 */ }) +ztools.registerProvider('baidu', async (input) => { + /* 调用百度 */ +}) +ztools.registerProvider('google', async (input) => { + /* 调用谷歌 */ +}) ``` 两条都会出现在「设置 → 提供商 → 翻译」,用户可分别启用并选其中一个为默认。 diff --git a/electron-builder.yml b/electron-builder.yml index 4f0e46fd..4a3a74be 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -80,13 +80,31 @@ mac: dmg: artifactName: ${name}-${version}-${os}-${arch}.${ext} linux: + # 显式指向多尺寸图标集 build/icons/(16x16…512x512), + # 避免自动发现优先命中 build/icon.png 而只向 hicolor 安装单尺寸图标。 + icon: build/icons target: - AppImage - deb + - pacman maintainer: electronjs.org category: Utility appImage: artifactName: ${name}-${version}-${os}-${arch}.${ext} npmRebuild: false +extraResources: + - from: build/ztools-launcher.sh + to: ztools/ztools-launcher.sh + filter: + - '**/*' + +deb: + afterInstall: build/deb-after-install.sh + afterRemove: build/deb-after-remove.sh + +pacman: + afterInstall: build/pacman-after-install.sh + afterRemove: build/pacman-after-remove.sh + afterPack: ./build/afterPack.js diff --git a/internal-plugins/setting/src/env.d.ts b/internal-plugins/setting/src/env.d.ts index 4382d245..31a23af4 100644 --- a/internal-plugins/setting/src/env.d.ts +++ b/internal-plugins/setting/src/env.d.ts @@ -400,6 +400,7 @@ declare global { enabled: boolean ) => Promise<{ success: boolean; error?: string }> updateWindowPositionStrategy: (strategy: string) => Promise + updateCssAppRegionDrag: (enabled: boolean) => Promise updateShowRecentInSearch: (showRecentInSearch: boolean) => Promise updateMatchRecommendation: (showMatchRecommendation: boolean) => Promise updateLocalAppSearch: (enabled: boolean) => Promise @@ -431,6 +432,7 @@ declare global { getAppName: () => Promise getSystemVersions: () => Promise getPlatform: () => NodeJS.Platform + getLinuxSession: () => { isWayland: boolean } isWindows11: () => Promise // 软件更新 diff --git a/internal-plugins/setting/src/views/GeneralSetting/GeneralSetting.vue b/internal-plugins/setting/src/views/GeneralSetting/GeneralSetting.vue index 25ffd1e8..6cab68c1 100644 --- a/internal-plugins/setting/src/views/GeneralSetting/GeneralSetting.vue +++ b/internal-plugins/setting/src/views/GeneralSetting/GeneralSetting.vue @@ -115,6 +115,8 @@ const superPanelMouseButtonOptions = [ // 当前平台(与 window.ztools.getPlatform 返回类型保持一致) const platform = ref<'darwin' | 'win32' | 'linux'>('darwin') +// 当前会话是否为原生 Wayland(仅 Linux 下用于悬浮球置顶限制说明) +const isWayland = ref(false) // 终端打开设置 const terminal = ref('default') @@ -180,6 +182,8 @@ const autoClear = ref('immediately') const autoBackToSearch = ref('never') const hideMainWindowOnPluginEsc = ref(false) const windowPositionStrategy = ref('remember') +// Wayland 下由系统接管搜索框区域拖拽(仅 Linux 平台显示) +const useCssAppRegionDrag = ref(false) const showRecentInSearch = ref(true) const showMatchRecommendation = ref(true) const localAppSearch = ref(true) @@ -633,6 +637,21 @@ async function handleWindowPositionStrategyChange(): Promise { } } +/** + * 处理 CSS app-region 拖拽开关变化:先持久化,再通知主渲染进程切换拖拽方式。 + * @returns 保存和通知完成后结束的 Promise + */ +async function handleUseCssAppRegionDragChange(): Promise { + try { + await saveSettings() + // 通知主渲染进程实时切换拖拽方式 + await window.ztools.internal.updateCssAppRegionDrag(useCssAppRegionDrag.value) + console.log('CSS app-region 拖拽配置已更新:', useCssAppRegionDrag.value) + } catch (error) { + console.error('保存 CSS app-region 拖拽配置失败:', error) + } +} + // 处理主题变化 async function handleThemeChange(): Promise { try { @@ -1324,13 +1343,18 @@ function isValidProxyUrl(url: string): boolean { // ==================== 自定义颜色相关辅助函数 ==================== -// 获取平台信息(用于快捷键文案) +/** + * 获取平台信息并探测 Wayland 会话(用于快捷键文案与悬浮球置顶限制说明)。 + * @returns 平台与会话信息获取完成后结束的 Promise + */ async function getPlatformInfo(): Promise { try { const pf = await window.ztools.internal.getPlatform() if (pf === 'darwin' || pf === 'win32' || pf === 'linux') { platform.value = pf } + const session = window.ztools.internal.getLinuxSession() + isWayland.value = session?.isWayland === true } catch (error) { console.error('获取平台信息失败:', error) } @@ -1363,6 +1387,7 @@ async function loadSettings(): Promise { ? 'immediately' : (data.autoBackToSearch ?? 'never') windowPositionStrategy.value = data.windowPositionStrategy ?? 'remember' + useCssAppRegionDrag.value = data.useCssAppRegionDrag ?? false showRecentInSearch.value = data.showRecentInSearch ?? true showMatchRecommendation.value = data.showMatchRecommendation ?? true localAppSearch.value = data.localAppSearch ?? true @@ -1461,6 +1486,7 @@ async function saveSettings(): Promise { autoBackToSearch: autoBackToSearch.value, hideMainWindowOnPluginEsc: hideMainWindowOnPluginEsc.value, windowPositionStrategy: windowPositionStrategy.value, + useCssAppRegionDrag: useCssAppRegionDrag.value, showRecentInSearch: showRecentInSearch.value, showMatchRecommendation: showMatchRecommendation.value, localAppSearch: localAppSearch.value, @@ -2219,6 +2245,25 @@ onUnmounted(() => { +
+
+ 使用 CSS -webkit-app-region 拖拽 + Wayland 下自定义拖拽可能失效,开启后由系统接管搜索框区域拖拽窗口 +
+
+ +
+
+
插件默认高度 @@ -2420,6 +2465,19 @@ onUnmounted(() => {
+
+
+ Wayland 置顶说明 + 原生 Wayland 协议没有置顶接口,悬浮球无法保持在其它窗口之上(X11 会话不受影响)。KDE + Plasma:系统设置 → 窗口管理 → 窗口规则 → 新建规则,检测窗口属性选中「ZTools + 悬浮球」,将「在其他窗口上方」设为「强制」。GNOME:系统未内置置顶功能,可安装第三方「始终置顶」类扩展,或改用 + X11/XWayland 会话运行 ZTools。Hyprland / Sway 等 wlroots + 系合成器:多数不支持置顶协议,建议使用 X11/XWayland 会话。 +
+
+
悬浮球文字 diff --git a/package.json b/package.json index 4ecb03f1..973b8b4e 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "build:linux:x64": "cross-env ZTOOLS_TARGET_PLATFORM=linux pnpm build && electron-builder --linux --x64", "build:linux:arm64": "cross-env ZTOOLS_TARGET_PLATFORM=linux pnpm build && electron-builder --linux --arm64", "updater": "node scripts/updater.mjs", + "ztools": "node scripts/ztools-cli.mjs", "test": "vitest run", "test:e2e": "pnpm exec electron-vite build && playwright test --config=playwright.e2e.config.ts", "test:windows-scanner": "electron scripts/compare-windows-scanner.cjs", @@ -47,6 +48,9 @@ "hooks:install": "git config core.hooksPath .githooks", "postinstall": "pnpm run hooks:install || true" }, + "bin": { + "ztools": "scripts/ztools-cli.mjs" + }, "dependencies": { "@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/utils": "^4.0.0", diff --git a/resources/floatingBall.html b/resources/floatingBall.html index 2dc5a5dd..489746bb 100644 --- a/resources/floatingBall.html +++ b/resources/floatingBall.html @@ -127,6 +127,17 @@ animation: border-spin 2s linear infinite; } + /* 移动模式:整球作为合成器拖拽区域(Wayland CSS app-region) */ + .floating-ball.move-mode { + -webkit-app-region: drag; + cursor: move; + } + + /* 移动模式内圈高亮,提示当前处于系统拖拽状态 */ + .floating-ball.move-mode .floating-ball-inner { + background: rgba(80, 130, 255, 0.4); + } + /* 文件拖入悬停效果 */ .floating-ball.file-hover::before { animation: border-spin 1s linear infinite; @@ -153,6 +164,7 @@ let isDragging = false let startScreenX = 0 let startScreenY = 0 + let moveMode = false // 移动模式:整球交给合成器拖拽,跳过手动拖拽与点击逻辑 const DRAG_THRESHOLD = 5 // 双击检测 @@ -160,7 +172,7 @@ const DOUBLE_CLICK_DELAY = 300 // 双击间隔 300ms ball.addEventListener('mousedown', (e) => { - if (e.button !== 0) return + if (e.button !== 0 || moveMode) return isMouseDown = true isDragging = false startScreenX = e.screenX @@ -170,7 +182,7 @@ }) document.addEventListener('mousemove', (e) => { - if (!isMouseDown) return + if (!isMouseDown || moveMode) return const dx = e.screenX - startScreenX const dy = e.screenY - startScreenY if (!isDragging && Math.sqrt(dx * dx + dy * dy) >= DRAG_THRESHOLD) { @@ -198,6 +210,9 @@ if (isDragging) { isDragging = false ipcRenderer.send('floating-ball-drag-end') + } else if (moveMode) { + // 移动模式下不派发单击/双击,等待主进程退出移动模式 + return } else { // 检测双击 const now = Date.now() @@ -219,6 +234,17 @@ ipcRenderer.send('floating-ball-contextmenu') }) + // ==================== 移动模式(Wayland 合成器拖拽) ==================== + ipcRenderer.on('floating-ball-enter-move-mode', () => { + moveMode = true + ball.classList.add('move-mode') + }) + + ipcRenderer.on('floating-ball-exit-move-mode', () => { + moveMode = false + ball.classList.remove('move-mode') + }) + // ==================== 接收文字配置 ==================== const letterEl = document.querySelector('.floating-ball-letter') ipcRenderer.on('floating-ball-set-letter', (_event, letter) => { diff --git a/resources/preload.js b/resources/preload.js index 4b64d957..2b595d2e 100644 --- a/resources/preload.js +++ b/resources/preload.js @@ -1172,6 +1172,11 @@ window.ztools = { getAppVersion: async () => await electron.ipcRenderer.invoke('get-app-version'), getSystemVersions: async () => await electron.ipcRenderer.invoke('get-system-versions'), getPlatform: () => electron.ipcRenderer.sendSync('internal:get-platform'), + /** + * 返回当前 Linux 会话是否为 Wayland。 + * @returns {{ isWayland: boolean }} Wayland 会话判定结果 + */ + getLinuxSession: () => electron.ipcRenderer.sendSync('internal:get-linux-session'), // 通知主渲染进程更新搜索框提示文字 updatePlaceholder: async (placeholder) => @@ -1197,6 +1202,9 @@ window.ztools = { await electron.ipcRenderer.invoke('internal:update-hide-main-window-on-plugin-esc', enabled), updateWindowPositionStrategy: async (strategy) => await electron.ipcRenderer.invoke('internal:update-window-position-strategy', strategy), + // 通知主渲染进程切换 CSS app-region 拖拽 + updateCssAppRegionDrag: async (enabled) => + await electron.ipcRenderer.invoke('internal:update-css-app-region-drag', enabled), // 通知主渲染进程更新显示最近使用配置 updateShowRecentInSearch: async (showRecentInSearch) => await electron.ipcRenderer.invoke( diff --git a/scripts/ztools-cli.mjs b/scripts/ztools-cli.mjs new file mode 100755 index 00000000..06980243 --- /dev/null +++ b/scripts/ztools-cli.mjs @@ -0,0 +1,87 @@ +#!/usr/bin/env node + +/* eslint-disable @typescript-eslint/explicit-function-return-type */ + +import { spawn } from 'node:child_process' +import fs from 'node:fs' +import { createRequire } from 'node:module' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const WAKE_ARG = '--ztools-wake' +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +// ESM 模块没有全局 require,用 createRequire 桥接以解析本地 electron 依赖。 +const require = createRequire(import.meta.url) + +/** + * 定位 ZTools 可执行文件。 + * + * 优先使用 ZTOOLS_BIN 环境变量;其次探测常见打包安装位置; + * 最后回退到开发环境中的 electron 二进制。 + * + * @returns 可执行文件绝对路径;找不到时返回 null。 + */ +function resolveZToolsBinary() { + if (process.env.ZTOOLS_BIN) return process.env.ZTOOLS_BIN + + const candidates = [] + if (process.platform === 'darwin') { + candidates.push('/Applications/ZTools.app/Contents/MacOS/ZTools') + } else if (process.platform === 'linux') { + candidates.push('/opt/ZTools/ztools') + candidates.push('/opt/ZTools/ZTools') + candidates.push('/usr/lib/ztools/ztools') + } else if (process.platform === 'win32') { + candidates.push(path.join(projectRoot, 'dist', 'win-unpacked', 'ZTools.exe')) + } + + for (const candidate of candidates) { + if (fs.existsSync(candidate)) return candidate + } + + // 开发环境:使用项目本地的 electron 二进制(需要先构建 out/main)。 + try { + return require.resolve('electron') + } catch { + return null + } +} + +function printHelp() { + console.log(`用法: ztools [选项] + +唤起已运行的 ZTools 实例并显示主窗口;如果应用尚未运行则先启动。 + +选项: + --help 显示本帮助信息 + +环境变量: + ZTOOLS_BIN 指定 ZTools 可执行文件路径(打包后的应用或 electron 二进制)`) +} + +const args = process.argv.slice(2) +if (args.includes('--help') || args.includes('-h')) { + printHelp() + process.exit(0) +} + +const binary = resolveZToolsBinary() +if (!binary) { + console.error( + '未找到 ZTools 可执行文件。请先执行 pnpm build 构建应用,或通过 ZTOOLS_BIN 指定二进制路径。' + ) + process.exit(1) +} + +// 开发环境使用本地 electron,需要把项目目录作为应用入口传入; +// 打包后的二进制则直接携带唤醒参数启动。 +const launchArgs = binary.includes('electron') ? [projectRoot, WAKE_ARG] : [WAKE_ARG] + +console.log(`[ztools] 启动唤醒进程: ${binary}`) +const child = spawn(binary, launchArgs, { + detached: true, + stdio: 'ignore', + windowsHide: true +}) +child.unref() +process.exit(0) diff --git a/src/main/api/plugin/internal.ts b/src/main/api/plugin/internal.ts index 02fcbed8..943506ec 100644 --- a/src/main/api/plugin/internal.ts +++ b/src/main/api/plugin/internal.ts @@ -1086,6 +1086,18 @@ export class InternalPluginAPI { return { success: true } }) + // 通知主渲染进程切换 CSS app-region 拖拽(Wayland 下由系统接管窗口移动) + ipcMain.handle('internal:update-css-app-region-drag', async (event, enabled: boolean) => { + if (!requireInternalPlugin(this.pluginManager, event)) { + throw new PermissionDeniedError('internal:update-css-app-region-drag') + } + // 广播到主渲染进程 + this.mainWindow?.webContents.send('update-css-app-region-drag', enabled) + // 同步主进程状态,供 Linux blur 自动隐藏决策使用 + windowAPI.setCssAppRegionDrag(enabled) + return { success: true } + }) + // 通知主渲染进程更新显示最近使用配置 ipcMain.handle( 'internal:update-show-recent-in-search', @@ -1248,6 +1260,19 @@ export class InternalPluginAPI { event.returnValue = process.platform }) + /** + * 返回当前会话是否为原生 Wayland(供设置页判断悬浮球置顶限制)。 + * @param event IPC 事件对象 + * @returns 无返回值,结果通过 event.returnValue 同步返回 + */ + ipcMain.on('internal:get-linux-session', (event) => { + if (!requireInternalPlugin(this.pluginManager, event)) { + event.returnValue = null + return + } + event.returnValue = { isWayland: !!process.env.WAYLAND_DISPLAY } + }) + // ==================== 应用更新 API ==================== ipcMain.handle('internal:updater-check-update', async (event) => { if (!requireInternalPlugin(this.pluginManager, event)) { diff --git a/src/main/api/renderer/settings.ts b/src/main/api/renderer/settings.ts index 375ef9fc..97def2c9 100644 --- a/src/main/api/renderer/settings.ts +++ b/src/main/api/renderer/settings.ts @@ -11,6 +11,7 @@ import { getCurrentShortcut, updateShortcut } from '../../appMain.js' import dndManager from '../../core/dndManager.js' import doubleTapManager from '../../core/doubleTapManager.js' +import { getLinuxLaunchAtLogin, setLinuxLaunchAtLogin } from '../../core/linuxAutoStart.js' import proxyManager from '../../managers/proxyManager.js' import windowManager from '../../managers/windowManager.js' import { primeScreenCaptureFrame } from '../../core/screenCapture' @@ -393,6 +394,17 @@ export class SettingsAPI { // 设置开机启动 public setLaunchAtLogin(enable: boolean): void { + // Linux 打包版通过 XDG autostart 条目控制自启,开发模式不写入用户配置。 + if (process.platform === 'linux') { + if (app.isPackaged) { + setLinuxLaunchAtLogin(enable) + console.log('[Settings] 设置开机启动:', enable) + } else { + console.log('[Settings] Linux 开发模式跳过开机自启设置:', enable) + } + return + } + app.setLoginItemSettings({ openAtLogin: enable, openAsHidden: true @@ -402,6 +414,15 @@ export class SettingsAPI { // 获取开机启动状态 public getLaunchAtLogin(): boolean { + // Linux 打包版读取 XDG autostart 条目,开发模式不读取用户配置。 + if (process.platform === 'linux') { + if (app.isPackaged) { + return getLinuxLaunchAtLogin() + } + console.log('[Settings] Linux 开发模式跳过开机自启读取') + return false + } + const settings = app.getLoginItemSettings() return settings.openAtLogin } diff --git a/src/main/api/renderer/window.ts b/src/main/api/renderer/window.ts index c1492878..a6304547 100644 --- a/src/main/api/renderer/window.ts +++ b/src/main/api/renderer/window.ts @@ -182,6 +182,16 @@ export class WindowAPI { await windowManager.updateWindowPositionStrategy(strategy) console.log('[WindowAPI] 更新窗口呼出位置策略:', strategy) } + + /** + * 更新 Linux CSS app-region 拖拽开关状态。 + * + * @param enabled 是否启用 CSS app-region 系统拖拽。 + * @returns 无返回值。 + */ + public setCssAppRegionDrag(enabled: boolean): void { + windowManager.setCssAppRegionDragEnabled(enabled) + } } export default new WindowAPI() diff --git a/src/main/appMain.ts b/src/main/appMain.ts index 83a7343d..ba282ef1 100644 --- a/src/main/appMain.ts +++ b/src/main/appMain.ts @@ -23,6 +23,7 @@ import httpServer from './core/httpServer' import mcpServer from './core/mcpServer' import { registerIconProtocolForSession, registerIconScheme } from './core/iconProtocol' import { getLogsPath } from './core/appData/appDataPaths' +import { isZToolsCliWake } from './core/cliWake' import { loadInternalPlugins } from './core/internalPluginLoader' import pluginManager from './managers/pluginManager' import windowManager from './managers/windowManager' @@ -39,6 +40,10 @@ if (platform.isMacOS) { } app.on('second-instance', (_event, argv) => { + if (isZToolsCliWake(argv)) { + console.log('[Main] 第二实例携带命令行唤起参数 --ztools-wake') + } + // Windows: 检查命令行参数中是否有 .zpx 文件路径 const zpxPath = argv.find((arg) => arg.endsWith('.zpx')) if (zpxPath) { @@ -148,6 +153,9 @@ app.whenReady().then(async () => { if (isE2ETest) { // 测试模式直接展示窗口,让自动化驱动无需依赖全局快捷键。 mainWindow.once('ready-to-show', () => windowManager.showWindow()) + } else if (isZToolsCliWake(process.argv)) { + // 命令行唤起启动:应用未运行时由 CLI 直接启动,就绪后自动显示窗口。 + mainWindow.once('ready-to-show', () => windowManager.showWindow()) } // 初始化 API 和插件管理器 diff --git a/src/main/core/cliWake.ts b/src/main/core/cliWake.ts new file mode 100644 index 00000000..fb4dab5e --- /dev/null +++ b/src/main/core/cliWake.ts @@ -0,0 +1,9 @@ +/** + * 判断进程启动参数是否携带命令行唤起标记。 + * + * @param argv 进程启动参数列表(通常为 process.argv)。 + * @returns 参数列表中存在 --ztools-wake 时返回 true。 + */ +export function isZToolsCliWake(argv: string[]): boolean { + return argv.includes('--ztools-wake') +} diff --git a/src/main/core/floatingBallDragMode.ts b/src/main/core/floatingBallDragMode.ts new file mode 100644 index 00000000..e38e8f41 --- /dev/null +++ b/src/main/core/floatingBallDragMode.ts @@ -0,0 +1,18 @@ +/** + * 判断悬浮球右键菜单是否需要显示“移动悬浮球”入口。 + * + * 仅当 Linux 平台、Wayland 会话且已开启 CSS app-region 系统拖拽时, + * 悬浮球才能交给合成器进行原生拖拽。 + * + * @param isLinux 是否为 Linux 平台。 + * @param isWayland 是否为 Wayland 会话(沿用仓库 WAYLAND_DISPLAY 环境变量约定)。 + * @param cssAppRegionDragEnabled 是否已在设置中开启 CSS app-region 系统拖拽。 + * @returns 是否满足显示“移动悬浮球”菜单项的条件。 + */ +export function shouldShowFloatingBallMoveMenu( + isLinux: boolean, + isWayland: boolean, + cssAppRegionDragEnabled: boolean +): boolean { + return isLinux && isWayland && cssAppRegionDragEnabled +} diff --git a/src/main/core/floatingBallManager.ts b/src/main/core/floatingBallManager.ts index f3bcc9c4..9686bd83 100644 --- a/src/main/core/floatingBallManager.ts +++ b/src/main/core/floatingBallManager.ts @@ -1,7 +1,9 @@ +import { platform } from '@electron-toolkit/utils' import { BrowserWindow, ipcMain, Menu, screen } from 'electron' import floatingBallHtml from '../../../resources/floatingBall.html?asset' import databaseAPI from '../api/shared/database' import windowManager from '../managers/windowManager' +import { shouldShowFloatingBallMoveMenu } from './floatingBallDragMode' // 悬浮球尺寸 const BALL_SIZE = 48 @@ -18,6 +20,10 @@ class FloatingBallManager { // 拖拽状态:记录拖拽开始时鼠标相对窗口左上角的偏移 private dragOffsetX = 0 private dragOffsetY = 0 + // 移动模式:Wayland 下整球交给合成器拖拽的状态标记 + private moveMode = false + // 移动模式退出防抖定时器:move 事件停止 300ms 后自动退出并保存位置 + private moveModeExitTimer: ReturnType | null = null /** * 初始化悬浮球管理器 @@ -163,6 +169,8 @@ class FloatingBallManager { x, y, frame: false, + // 固定窗口标题,便于 KDE 等桌面按「窗口规则」检测悬浮球 + title: 'ZTools 悬浮球', transparent: true, alwaysOnTop: true, resizable: false, @@ -179,6 +187,22 @@ class FloatingBallManager { } }) + // 移动模式监听:moved 触发立即退出,move 停止 300ms 后退出(覆盖不同合成器事件差异) + this.ballWindow.on('moved', () => { + if (this.moveMode) { + this.exitMoveMode() + } + }) + this.ballWindow.on('move', () => { + if (!this.moveMode) return + // 拖拽仍在进行,重置防抖定时器 + this.clearMoveModeExitTimer() + this.moveModeExitTimer = setTimeout(() => { + this.moveModeExitTimer = null + this.exitMoveMode() + }, 300) + }) + // macOS 上设置窗口层级为浮动面板(高于普通窗口) this.ballWindow.setAlwaysOnTop(true, 'floating') // 所有工作空间都可见 @@ -286,9 +310,47 @@ class FloatingBallManager { /** * 显示右键菜单 + * 菜单项按当前平台、Wayland 会话与 CSS 拖拽开关动态构建; + * 移动模式下提供“退出移动模式”入口。 + * + * @returns 无返回值。 */ private showContextMenu(): void { - if (!this.ballWindow) return + if (!this.ballWindow || this.ballWindow.isDestroyed()) return + + // 读取当前会话与 CSS app-region 拖拽开关,决定是否显示移动入口 + const isLinux = platform.isLinux + const isWayland = !!process.env.WAYLAND_DISPLAY + const cssAppRegionDragEnabled = this.isCssAppRegionDragEnabled() + const showMoveEntry = shouldShowFloatingBallMoveMenu( + isLinux, + isWayland, + cssAppRegionDragEnabled + ) + + const moveItems: Electron.MenuItemConstructorOptions[] = [] + if (this.moveMode) { + // 移动模式中始终提供退出入口 + moveItems.push({ + label: '退出移动模式', + click: () => { + this.exitMoveMode() + } + }) + } else if (showMoveEntry) { + moveItems.push({ + label: '移动悬浮球', + click: () => { + this.enterMoveMode() + } + }) + } else if (isLinux && isWayland) { + // Wayland 但未开启 CSS 拖拽时给出禁用提示,引导用户到设置中开启 + moveItems.push({ + label: '需在设置中开启 CSS 拖拽后使用', + enabled: false + }) + } const menu = Menu.buildFromTemplate([ { @@ -297,6 +359,7 @@ class FloatingBallManager { this.handleBallClick() } }, + ...(moveItems.length > 0 ? [{ type: 'separator' as const }, ...moveItems] : []), { type: 'separator' }, { label: '隐藏悬浮球', @@ -309,6 +372,69 @@ class FloatingBallManager { menu.popup({ window: this.ballWindow }) } + /** + * 进入移动模式 + * 发送 IPC 通知悬浮球页面切换为合成器拖拽区域,等待拖拽结束后自动退出。 + * + * @returns 无返回值。 + */ + private enterMoveMode(): void { + if (this.moveMode || !this.ballWindow || this.ballWindow.isDestroyed()) return + + // 清理可能残留的退出定时器,避免旧定时器提前退出 + this.clearMoveModeExitTimer() + this.moveMode = true + this.ballWindow.webContents.send('floating-ball-enter-move-mode') + console.log('[FloatingBall] 已进入移动模式') + } + + /** + * 退出移动模式 + * 清理防抖定时器、通知页面恢复交互并保存当前位置。 + * + * @returns 无返回值。 + */ + private exitMoveMode(): void { + if (!this.moveMode) return + + this.clearMoveModeExitTimer() + this.moveMode = false + if (this.ballWindow && !this.ballWindow.isDestroyed()) { + this.ballWindow.webContents.send('floating-ball-exit-move-mode') + } + // 拖拽结束后保存合成器决定的最终位置 + this.savePosition() + console.log('[FloatingBall] 已退出移动模式') + } + + /** + * 清理移动模式退出防抖定时器 + * + * @returns 无返回值。 + */ + private clearMoveModeExitTimer(): void { + if (this.moveModeExitTimer) { + clearTimeout(this.moveModeExitTimer) + this.moveModeExitTimer = null + } + } + + /** + * 读取 CSS app-region 系统拖拽开关 + * 与设置插件持久化的 settings-general.useCssAppRegionDrag 保持一致。 + * + * @returns 是否已开启 CSS app-region 系统拖拽。 + */ + private isCssAppRegionDragEnabled(): boolean { + try { + const data = databaseAPI.dbGet('settings-general') + return Boolean(data?.useCssAppRegionDrag) + } catch (error) { + console.error('[FloatingBall] 读取 CSS app-region 拖拽配置失败:', error) + return false + } + } + /** * 设置悬浮球启用/禁用 */ @@ -379,13 +505,22 @@ class FloatingBallManager { /** * 销毁悬浮球窗口 + * + * @returns 无返回值。 */ private destroyBallWindow(): void { - if (this.ballWindow && !this.ballWindow.isDestroyed()) { - this.enabled = false // 先标记为禁用,避免 close 事件 preventDefault - // 移除所有事件监听器,防止干扰销毁过程 - this.ballWindow.removeAllListeners('close') - this.ballWindow.destroy() + if (this.ballWindow) { + if (!this.ballWindow.isDestroyed()) { + this.enabled = false // 先标记为禁用,避免 close 事件 preventDefault + // 移除所有事件监听器,防止干扰销毁过程 + this.ballWindow.removeAllListeners('close') + this.ballWindow.removeAllListeners('moved') + this.ballWindow.removeAllListeners('move') + this.ballWindow.destroy() + } + // 复位移动模式并清理退出定时器,避免残留定时器在窗口销毁后触发 + this.moveMode = false + this.clearMoveModeExitTimer() this.ballWindow = null console.log('[FloatingBall] 悬浮球窗口已销毁') } diff --git a/src/main/core/linuxAutoStart.ts b/src/main/core/linuxAutoStart.ts new file mode 100644 index 00000000..3a222f98 --- /dev/null +++ b/src/main/core/linuxAutoStart.ts @@ -0,0 +1,91 @@ +import { app } from 'electron' +import fs from 'fs' +import path from 'path' + +/** XDG autostart 目录相对家目录的路径 */ +const AUTOSTART_DIR_RELATIVE_PATH = path.join('.config', 'autostart') + +/** ZTools 管理的自启桌面条目文件名 */ +const AUTOSTART_FILE_NAME = 'ztools.desktop' + +/** GNOME 启动应用程序面板会把关闭态写成 X-GNOME-Autostart-enabled=false */ +const GNOME_DISABLED_MARKER = 'X-GNOME-Autostart-enabled=false' + +/** 桌面环境通用的禁用标记 */ +const HIDDEN_MARKER = 'Hidden=true' + +/** + * 计算自启桌面条目完整路径。 + * @returns XDG autostart 目录下的 ztools.desktop 绝对路径 + */ +function getAutostartDesktopFile(): string { + return path.join(app.getPath('home'), AUTOSTART_DIR_RELATIVE_PATH, AUTOSTART_FILE_NAME) +} + +/** + * 计算自启桌面条目的 Exec 目标。 + * AppImage 运行时使用 APPIMAGE 环境变量指向的实际镜像;其它打包形式使用可执行文件路径。 + * @returns 加引号后的可执行文件绝对路径 + */ +function getAutostartExecTarget(): string { + const target = process.env.APPIMAGE || app.getPath('exe') + return `"${target}"` +} + +/** + * 生成本次应写入的自启桌面条目内容。 + * @returns UTF-8 桌面条目文本,使用 \n 行尾 + */ +function buildDesktopEntryContent(): string { + return [ + '[Desktop Entry]', + 'Type=Application', + 'Name=ZTools', + `Exec=${getAutostartExecTarget()}`, + 'Terminal=false', + 'StartupNotify=false', + 'X-GNOME-Autostart-enabled=true' + ].join('\n') +} + +/** + * 设置 Linux 开机自启(XDG autostart)。 + * 启用时递归创建 ~/.config/autostart 并写入 ztools.desktop,关闭时删除该文件。 + * @param enable 是否启用开机自启 + * @returns 无返回值 + * @throws 写入或删除自启文件失败时抛出错误,由调用方决定是否回滚开关状态 + */ +export function setLinuxLaunchAtLogin(enable: boolean): void { + const desktopFile = getAutostartDesktopFile() + if (!enable) { + // 关闭时仅删除自启条目,force 使文件不存在时成为无操作。 + fs.rmSync(desktopFile, { force: true }) + return + } + + // 确保 autostart 目录存在,桌面环境只读取该目录下的一级 .desktop 文件。 + fs.mkdirSync(path.dirname(desktopFile), { recursive: true }) + fs.writeFileSync(desktopFile, buildDesktopEntryContent(), { encoding: 'utf8' }) +} + +/** + * 读取 Linux 开机自启状态。 + * 文件缺失视为关闭;存在时被 GNOME 面板关闭或带 Hidden 标记也视为关闭。 + * @returns 自启条目存在且未被禁用时返回 true + * @throws 自启文件存在但读取失败(非 ENOENT)时抛出错误 + */ +export function getLinuxLaunchAtLogin(): boolean { + const desktopFile = getAutostartDesktopFile() + let content: string + try { + content = fs.readFileSync(desktopFile, 'utf8') + } catch (error) { + // 文件未创建过按关闭态处理,其它错误如实上抛以便排查。 + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return false + } + throw error + } + + return !content.includes(GNOME_DISABLED_MARKER) && !content.includes(HIDDEN_MARKER) +} diff --git a/src/main/core/shortcutFallback.ts b/src/main/core/shortcutFallback.ts new file mode 100644 index 00000000..41011847 --- /dev/null +++ b/src/main/core/shortcutFallback.ts @@ -0,0 +1,371 @@ +import { UiohookKey, type UiohookKeyboardEvent } from 'uiohook-napi' +import globalInputManager from './globalInputManager.js' + +const INPUT_CONSUMER = 'shortcut-fallback' + +/** + * Electron accelerator modifier name → uiohook keycode. + */ +const MODIFIER_NAME_TO_KEYCODE: Record = { + Alt: UiohookKey.Alt, + Option: UiohookKey.Alt, // macOS alias + Ctrl: UiohookKey.Ctrl, + Control: UiohookKey.Ctrl, // alias + Shift: UiohookKey.Shift, + Super: UiohookKey.Meta, + Command: UiohookKey.Meta, // alias + Meta: UiohookKey.Meta // alias +} + +/** + * Right-side modifier keycodes → left-side equivalents. + * uiohook-napi emits different keycodes for left and right modifier keys. + * We normalize right-side variants to left-side for matching purposes, + * matching the behavior of doubleTapManager.ts MODIFIER_KEYCODES. + */ +const RIGHT_TO_LEFT_MODIFIER: Record = { + [UiohookKey.AltRight]: UiohookKey.Alt, + [UiohookKey.CtrlRight]: UiohookKey.Ctrl, + [UiohookKey.ShiftRight]: UiohookKey.Shift, + [UiohookKey.MetaRight]: UiohookKey.Meta +} + +/** All modifier keycodes (both left and right) for strict matching check. */ +const ALL_MODIFIER_KEYCODES = new Set([ + ...Object.values(MODIFIER_NAME_TO_KEYCODE), + ...Object.keys(RIGHT_TO_LEFT_MODIFIER).map(Number) +]) + +/** + * Electron accelerator main key name → uiohook keycode. + * Covers letters, digits, function keys, and common special keys. + */ +const KEY_NAME_TO_KEYCODE: Record = { + // Letters + A: UiohookKey.A, + B: UiohookKey.B, + C: UiohookKey.C, + D: UiohookKey.D, + E: UiohookKey.E, + F: UiohookKey.F, + G: UiohookKey.G, + H: UiohookKey.H, + I: UiohookKey.I, + J: UiohookKey.J, + K: UiohookKey.K, + L: UiohookKey.L, + M: UiohookKey.M, + N: UiohookKey.N, + O: UiohookKey.O, + P: UiohookKey.P, + Q: UiohookKey.Q, + R: UiohookKey.R, + S: UiohookKey.S, + T: UiohookKey.T, + U: UiohookKey.U, + V: UiohookKey.V, + W: UiohookKey.W, + X: UiohookKey.X, + Y: UiohookKey.Y, + Z: UiohookKey.Z, + // Digits (top row) + '0': UiohookKey['0'], + '1': UiohookKey['1'], + '2': UiohookKey['2'], + '3': UiohookKey['3'], + '4': UiohookKey['4'], + '5': UiohookKey['5'], + '6': UiohookKey['6'], + '7': UiohookKey['7'], + '8': UiohookKey['8'], + '9': UiohookKey['9'], + // Function keys + F1: UiohookKey.F1, + F2: UiohookKey.F2, + F3: UiohookKey.F3, + F4: UiohookKey.F4, + F5: UiohookKey.F5, + F6: UiohookKey.F6, + F7: UiohookKey.F7, + F8: UiohookKey.F8, + F9: UiohookKey.F9, + F10: UiohookKey.F10, + F11: UiohookKey.F11, + F12: UiohookKey.F12, + // Special keys + Space: UiohookKey.Space, + Tab: UiohookKey.Tab, + Escape: UiohookKey.Escape, + Esc: UiohookKey.Escape, // alias + Backspace: UiohookKey.Backspace, + Enter: UiohookKey.Enter, + Return: UiohookKey.Enter, // alias + Delete: UiohookKey.Delete, + Insert: UiohookKey.Insert, + Home: UiohookKey.Home, + End: UiohookKey.End, + PageUp: UiohookKey.PageUp, + PageDown: UiohookKey.PageDown, + // Arrow keys + Up: UiohookKey.ArrowUp, + Down: UiohookKey.ArrowDown, + Left: UiohookKey.ArrowLeft, + Right: UiohookKey.ArrowRight, + // Symbols + Comma: UiohookKey.Comma, + Period: UiohookKey.Period, + Slash: UiohookKey.Slash, + Semicolon: UiohookKey.Semicolon, + Equal: UiohookKey.Equal, + Minus: UiohookKey.Minus, + Backquote: UiohookKey.Backquote, + BracketLeft: UiohookKey.BracketLeft, + BracketRight: UiohookKey.BracketRight, + Backslash: UiohookKey.Backslash, + Quote: UiohookKey.Quote, + // Numpad + num0: UiohookKey.Numpad0, + num1: UiohookKey.Numpad1, + num2: UiohookKey.Numpad2, + num3: UiohookKey.Numpad3, + num4: UiohookKey.Numpad4, + num5: UiohookKey.Numpad5, + num6: UiohookKey.Numpad6, + num7: UiohookKey.Numpad7, + num8: UiohookKey.Numpad8, + num9: UiohookKey.Numpad9, + numdec: UiohookKey.NumpadDecimal, + numadd: UiohookKey.NumpadAdd, + numsub: UiohookKey.NumpadSubtract, + nummult: UiohookKey.NumpadMultiply, + numdiv: UiohookKey.NumpadDivide, + // PrintScreen + PrintScreen: UiohookKey.PrintScreen +} + +interface ParsedShortcut { + /** Keycodes of required modifiers (must all be pressed) */ + modifierKeycodes: number[] + /** Keycode of the main (non-modifier) key */ + mainKeycode: number +} + +interface ShortcutEntry { + parsed: ParsedShortcut + callback: () => void +} + +/** + * Linux uiohook-based fallback for Electron's globalShortcut. + * + * When globalShortcut.register() fails on Linux (common on X11 due to + * uiohook/XRecord conflicts, or on Wayland where globalShortcut is + * unsupported), this module uses the existing uiohook-napi infrastructure + * to detect key chords and fire callbacks. + * + * Uses the same GlobalInputManager consumer pattern as DoubleTapManager. + */ +class ShortcutFallbackDetector { + /** Registered fallback shortcuts, keyed by accelerator string. */ + private shortcuts = new Map() + + /** Currently pressed keycodes (tracked across keydown/keyup). */ + private pressedKeys = new Set() + + /** Whether listeners have been registered on globalInputManager. */ + private listenersRegistered = false + + // Bound handlers for stable listener references + private boundHandleKeyDown = this.handleKeyDown.bind(this) + private boundHandleKeyUp = this.handleKeyUp.bind(this) + + /** + * Register a fallback shortcut. Returns true on success. + * The accelerator string uses Electron format (e.g. "Alt+Space", "Ctrl+Shift+K"). + */ + register(accelerator: string, callback: () => void): boolean { + const parsed = this.parseAccelerator(accelerator) + if (!parsed) { + console.error(`[ShortcutFallback] 无法解析快捷键: ${accelerator}`) + return false + } + + this.shortcuts.set(accelerator, { parsed, callback }) + this.ensureStarted() + + console.log(`[ShortcutFallback] 注册快捷键: ${accelerator}`) + return true + } + + /** + * Unregister a fallback shortcut. + */ + unregister(accelerator: string): void { + this.shortcuts.delete(accelerator) + this.maybeStop() + } + + /** + * Unregister all fallback shortcuts. + */ + unregisterAll(): void { + this.shortcuts.clear() + this.maybeStop() + } + + /** + * Parse an Electron accelerator string into uiohook modifier + main keycodes. + * Returns null if parsing fails. + * + * Examples: + * "Alt+Space" → { modifierKeycodes: [Alt], mainKeycode: Space } + * "Ctrl+Shift+K" → { modifierKeycodes: [Ctrl, Shift], mainKeycode: K } + * "Super+Escape" → { modifierKeycodes: [Meta], mainKeycode: Escape } + */ + private parseAccelerator(accelerator: string): ParsedShortcut | null { + const parts = accelerator.split('+') + if (parts.length < 2) { + // Single key shortcuts are not supported as global shortcuts + return null + } + + const modifierKeycodes: number[] = [] + let mainKeycode: number | null = null + + for (let i = 0; i < parts.length; i++) { + const part = parts[i] + const isLast = i === parts.length - 1 + + if (!isLast) { + // Treat as modifier + const modKeycode = MODIFIER_NAME_TO_KEYCODE[part] + if (modKeycode === undefined) { + console.error(`[ShortcutFallback] 未知修饰键: ${part} (accelerator: ${accelerator})`) + return null + } + modifierKeycodes.push(modKeycode) + } else { + // Last part is the main key + const keycode = KEY_NAME_TO_KEYCODE[part] + if (keycode === undefined) { + console.error(`[ShortcutFallback] 未知按键: ${part} (accelerator: ${accelerator})`) + return null + } + mainKeycode = keycode + } + } + + if (mainKeycode === null) { + return null + } + + return { modifierKeycodes, mainKeycode } + } + + /** + * Check if a parsed shortcut matches the current set of pressed keys. + * Requires: + * - All required modifier keys are pressed + * - The main key is pressed + * - No extra modifier keys are pressed (strict matching) + */ + private matchShortcut(parsed: ParsedShortcut, pressed: Set): boolean { + // Normalize right-side modifier keys to left-side equivalents + // (e.g., AltRight → Alt, CtrlRight → Ctrl) + const normalized = new Set() + for (const kc of pressed) { + normalized.add(RIGHT_TO_LEFT_MODIFIER[kc] ?? kc) + } + + // Main key must be pressed + if (!normalized.has(parsed.mainKeycode)) { + return false + } + + // All required modifiers must be pressed + for (const modKeycode of parsed.modifierKeycodes) { + if (!normalized.has(modKeycode)) { + return false + } + } + + // No extra modifier keys may be pressed (strict matching) + for (const pressedKeycode of normalized) { + if (pressedKeycode === parsed.mainKeycode) continue + if (parsed.modifierKeycodes.includes(pressedKeycode)) continue + if (ALL_MODIFIER_KEYCODES.has(pressedKeycode)) { + // An extra modifier is held — don't match (e.g., Ctrl held during Alt+Space) + return false + } + } + + return true + } + + private handleKeyDown(e: UiohookKeyboardEvent): void { + this.pressedKeys.add(e.keycode) + + // Debug: log key events periodically to confirm the listener is working + if (this.shortcuts.size > 0) { + const now = Date.now() + if (!this._lastDebugLogTime || now - this._lastDebugLogTime > 3000) { + this._lastDebugLogTime = now + const pressedStr = [...this.pressedKeys].map((k) => `0x${k.toString(16)}`).join(',') + console.log( + `[ShortcutFallback] 监听中,已注册 ${this.shortcuts.size} 个快捷键:`, + [...this.shortcuts.keys()].join(', '), + `当前按下: [${pressedStr}]` + ) + } + } + + // Check all registered shortcuts + for (const [accelerator, entry] of this.shortcuts) { + if (this.matchShortcut(entry.parsed, this.pressedKeys)) { + console.log(`[ShortcutFallback] ✅ 快捷键匹配成功: ${accelerator}`) + // Defer callback to avoid uiohook event-handler reentrancy + // (same pattern as doubleTapManager.fireHandlers) + const cb = entry.callback + setTimeout(() => { + try { + cb() + } catch (error) { + console.error(`[ShortcutFallback] 回调执行失败 (${accelerator}):`, error) + } + }, 0) + } + } + } + + private _lastDebugLogTime: number | undefined + + private handleKeyUp(e: UiohookKeyboardEvent): void { + this.pressedKeys.delete(e.keycode) + } + + private ensureStarted(): void { + if (this.listenersRegistered) return + + this.listenersRegistered = true + globalInputManager.on(INPUT_CONSUMER, 'keydown', this.boundHandleKeyDown) + globalInputManager.on(INPUT_CONSUMER, 'keyup', this.boundHandleKeyUp) + + if (globalInputManager.acquire(INPUT_CONSUMER)) { + console.log('[ShortcutFallback] 全局键盘监听已启动') + } else { + this.listenersRegistered = false + console.error('[ShortcutFallback] 启动全局键盘监听失败') + } + } + + private maybeStop(): void { + if (this.shortcuts.size > 0) return + + globalInputManager.release(INPUT_CONSUMER) + this.listenersRegistered = false + this.pressedKeys.clear() + console.log('[ShortcutFallback] 全局键盘监听已停止(无快捷键注册)') + } +} + +export default new ShortcutFallbackDetector() diff --git a/src/main/managers/windowManager.ts b/src/main/managers/windowManager.ts index bce026ae..2aa427dd 100644 --- a/src/main/managers/windowManager.ts +++ b/src/main/managers/windowManager.ts @@ -36,6 +36,11 @@ import { type WindowMaterial = 'mica' | 'acrylic' | 'none' const WINDOW_BLUR_DRAG_INPUT_CONSUMER = 'window-blur-drag' const DEFAULT_MODAL_DIALOG_BLUR_HIDE_RELEASE_DELAY_MS = 500 +// Wayland CSS 拖拽 blur 分类使用的光标轮询与判定阈值 +const CURSOR_POLL_INTERVAL_MS = 100 +const POINTER_MOVING_THRESHOLD_MS = 200 +const LINUX_BLUR_HIDE_CONFIRM_MS = 500 +const DRAG_MOVE_DEFER_MS = 1200 /** * 应用快捷键触发时携带的文件输入 @@ -99,12 +104,17 @@ class WindowManager { private lastFocusTarget: 'mainWindow' | 'plugin' | null = null // 窗口隐藏前的焦点状态 private isRestoringFocus: boolean = false // 是否正在恢复焦点状态(防止 focus 事件监听器干扰) private suppressBlurHide: boolean = false // 临时抑制 blur 事件隐藏窗口(文件关联打开等场景) + private cssAppRegionDragEnabled: boolean = false // Linux 是否启用了 CSS app-region 系统拖拽 // 原生模态对话框关闭前后可能发出排队的 blur/mouseup 事件。 private modalDialogBlurHideSuppressed: boolean = false private modalDialogBlurHideReleaseTimer: ReturnType | null = null private modalDialogBlurHideSuppressionDepth: number = 0 private lastBlurHideTime: number = 0 // blur 导致隐藏窗口的时间戳(用于解决托盘点击竞态) - private blurHideTimer: ReturnType | null = null // Linux blur 延迟隐藏定时器 + private blurHideTimer: ReturnType | null = null // Linux blur 确认隐藏定时器 + private deferredBlurHideTimer: ReturnType | null = null // Linux blur 拖拽移动复核定时器 + private cursorPollTimer: ReturnType | null = null // 窗口可见期间的光标轮询定时器 + private lastCursorPoint: { x: number; y: number } | null = null // 最近一次采样到的全局光标位置 + private lastCursorMoveAt: number = 0 // 最近一次检测到光标移动的时间戳 // Double-tap 唤醒窗口时,Windows 可能紧跟一个短暂 blur;这两个 timer 用于跳过误关闭并补一次焦点。 private doubleTapFocusTimer: ReturnType | null = null private windowsHotkeyFocusTimer: ReturnType | null = null @@ -182,6 +192,167 @@ class WindowManager { } } + /** + * 清除 Linux blur 相关的确认/复核定时器。 + * + * @returns 无返回值。 + */ + private clearLinuxBlurHideTimers(): void { + if (this.blurHideTimer) { + clearTimeout(this.blurHideTimer) + this.blurHideTimer = null + } + if (this.deferredBlurHideTimer) { + clearTimeout(this.deferredBlurHideTimer) + this.deferredBlurHideTimer = null + } + } + + /** + * 更新最近一次光标位置与移动时间。 + * + * 窗口可见期间由轮询定时器调用;blur 分类前也会主动调用一次, + * 避免基于过期位置做判断。 + * + * @returns 无返回值。 + */ + private updateCursorPoint(): void { + const point = screen.getCursorScreenPoint() + if ( + this.lastCursorPoint && + (point.x !== this.lastCursorPoint.x || point.y !== this.lastCursorPoint.y) + ) { + // 检测到位置变化时记录移动时间,供 blur 瞬间判断光标是否正在移动。 + this.lastCursorMoveAt = Date.now() + } + this.lastCursorPoint = point + } + + /** + * 开始轮询全局光标位置。 + * + * 仅在窗口可见期间轮询,窗口隐藏后无需继续采样。 + * + * @returns 无返回值。 + */ + private startCursorPolling(): void { + if (this.cursorPollTimer) return + + // 重置历史位置后立即采样一次,保证 blur 分类有可用的光标基线。 + this.lastCursorPoint = null + this.lastCursorMoveAt = 0 + this.updateCursorPoint() + this.cursorPollTimer = setInterval(() => { + this.updateCursorPoint() + }, CURSOR_POLL_INTERVAL_MS) + } + + /** + * 停止轮询全局光标位置并清理相关定时器。 + * + * 窗口隐藏或销毁时调用,避免隐藏后定时器继续触发。 + * + * @returns 无返回值。 + */ + private stopCursorPolling(): void { + if (this.cursorPollTimer) { + clearInterval(this.cursorPollTimer) + this.cursorPollTimer = null + } + this.lastCursorPoint = null + this.lastCursorMoveAt = 0 + this.clearLinuxBlurHideTimers() + } + + /** + * 判断光标是否在阈值时间内发生过移动。 + * + * @returns 光标在 POINTER_MOVING_THRESHOLD_MS 内有移动时返回 true。 + */ + private isPointerMoving(): boolean { + if (!this.lastCursorPoint) return false + return Date.now() - this.lastCursorMoveAt <= POINTER_MOVING_THRESHOLD_MS + } + + /** + * 启动 Linux blur 确认隐藏定时器。 + * + * 用于“光标在窗口外且静止”的常规外部点击场景,确认窗口确实失焦后再隐藏。 + * + * @returns 无返回值。 + */ + private scheduleLinuxBlurHideConfirm(): void { + this.clearLinuxBlurHideTimers() + this.blurHideTimer = setTimeout(() => { + this.blurHideTimer = null + if (this.isBlurHideSuppressed()) return + // 主窗口重新获焦 → 不隐藏 + if (this.mainWindow?.isFocused()) return + // 插件视图持有焦点(应用内部切换)→ 不隐藏 + if (pluginManager.isPluginViewFocused()) return + // 确认是点击了其他窗口,隐藏 + this.lastBlurHideTime = Date.now() + this.hideWindow(false) + }, LINUX_BLUR_HIDE_CONFIRM_MS) + } + + /** + * 启动/顺延 Linux blur 拖拽移动复核定时器。 + * + * 光标在窗口外且仍在移动时使用;复核时重新分类,避免误隐藏正在进行的系统拖拽。 + * + * @returns 无返回值。 + */ + private scheduleLinuxBlurHideRecheck(): void { + this.clearLinuxBlurHideTimers() + this.deferredBlurHideTimer = setTimeout(() => { + this.deferredBlurHideTimer = null + this.classifyLinuxBlurHide(true) + }, DRAG_MOVE_DEFER_MS) + } + + /** + * 分类处理 Linux 下窗口 blur 是内部场景还是点击外部。 + * + * 拖拽起步的 blur 发生时指针仍停在按下点(窗口内),点击其他窗口时指针必然在窗口外, + * 因此以 blur/复核瞬间的光标位置与移动状态区分两者。 + * + * @param isDeferredRecheck 是否由复核定时器触发;复核确认静止后直接隐藏。 + * @returns 无返回值。 + */ + private classifyLinuxBlurHide(isDeferredRecheck: boolean): void { + if (this.isBlurHideSuppressed()) return + + // 先刷新光标快照,避免窗口移动后基于过期 bounds 判断。 + this.updateCursorPoint() + + if (this.lastCursorPoint && this.isPointInsideMainWindow(this.lastCursorPoint)) { + // 指针仍在窗口内:拖拽起步、插件视图获焦等内部场景,不启动隐藏定时器。 + console.log('[Window] Linux blur:光标位于窗口内(疑似 CSS 拖拽起步/插件获焦),跳过隐藏') + this.clearLinuxBlurHideTimers() + return + } + + if (this.isPointerMoving()) { + // 指针在窗口外且仍在移动:疑似拖拽进行中,延后到移动停止后再判定。 + console.log('[Window] Linux blur:光标在窗口外且正在移动(疑似拖拽进行中),延后复核') + this.scheduleLinuxBlurHideRecheck() + return + } + + console.log('[Window] Linux blur:光标在窗口外且静止(疑似点击其他窗口),准备隐藏') + if (isDeferredRecheck) { + // 复核时指针已静止且仍失焦,直接隐藏,不再额外等待确认定时器。 + if (this.mainWindow?.isFocused()) return + if (pluginManager.isPluginViewFocused()) return + this.lastBlurHideTime = Date.now() + this.hideWindow(false) + return + } + + this.scheduleLinuxBlurHideConfirm() + } + /** * 判断当前是否应阻止失焦自动隐藏,E2E 模式始终保持窗口可见。 * @@ -513,6 +684,18 @@ class WindowManager { this.mainWindow.on('blur', () => { if (this.isBlurHideSuppressed()) return + // 插件视图激活且开启 Wayland 原生 CSS 拖拽时,blur 无法与“点击外部”可靠区分 + // (拖拽停顿期间 bounds 可能已失效),此时跳过 blur 自动隐藏,避免拖动插件窗口被误关。 + // 主搜索界面不使用 CSS 拖拽,仍按光标位置分类外部点击并隐藏窗口。 + if ( + platform.isLinux && + this.cssAppRegionDragEnabled && + pluginManager.getCurrentPluginPath() !== null + ) { + console.log('[Window] Linux 已开启 CSS app-region 拖拽且插件视图激活,跳过 blur 自动隐藏') + return + } + // 左键仍按下时可能是从外部拖文件进窗口,先等 mouseup 再决定是否隐藏。 if (this.leftMouseDown) { this.deferBlurHideUntilMouseUp() @@ -520,23 +703,9 @@ class WindowManager { } if (platform.isLinux) { - // Linux 上去掉了 type:'panel',现在 blur 只会在真正点击其他窗口时触发。 - // 但插件 WebContentsView 获焦仍会触发 blur,需延迟排除。 - if (this.blurHideTimer) { - clearTimeout(this.blurHideTimer) - this.blurHideTimer = null - } - this.blurHideTimer = setTimeout(() => { - this.blurHideTimer = null - if (this.isBlurHideSuppressed()) return - // 主窗口重新获焦 → 不隐藏 - if (this.mainWindow?.isFocused()) return - // 插件视图持有焦点(应用内部切换)→ 不隐藏 - if (pluginManager.isPluginViewFocused()) return - // 确认是点击了其他窗口,隐藏 - this.lastBlurHideTime = Date.now() - this.hideWindow(false) - }, 150) + // Linux(Wayland/X11 拖拽)在 blur 触发瞬间用光标位置与移动状态分类, + // 不再依赖延迟后可能已失效的窗口 bounds。 + this.classifyLinuxBlurHide(false) } else { // macOS / Windows:原有行为不变 this.lastBlurHideTime = Date.now() @@ -550,6 +719,9 @@ class WindowManager { } this.mainWindow.on('show', () => { + // 窗口显示期间持续追踪光标位置,供 blur 触发瞬间判断拖拽/点击外部。 + this.startCursorPolling() + // 开始恢复焦点流程,防止 focus 事件监听器修改 lastFocusTarget this.isRestoringFocus = true const savedFocusTarget = this.lastFocusTarget @@ -571,6 +743,15 @@ class WindowManager { this.isRestoringFocus = false }) + this.mainWindow.on('hide', () => { + // 窗口隐藏后停止光标轮询,避免隐藏期间继续采样并残留 blur 定时器。 + this.stopCursorPolling() + }) + + this.mainWindow.on('closed', () => { + this.stopCursorPolling() + }) + // 阻止窗口被销毁(Command+W 时隐藏而不是关闭) this.mainWindow.on('close', (event) => { if (process.env.ZTOOLS_E2E !== '1' && !this.isQuitting) { @@ -596,6 +777,7 @@ class WindowManager { // 从数据库加载唤醒黑名单 const initSettings = databaseAPI.dbGet('settings-general') + this.cssAppRegionDragEnabled = Boolean(initSettings?.useCssAppRegionDrag) if (initSettings?.wakeupBlacklist) { this.wakeupBlacklist = initSettings.wakeupBlacklist } @@ -1204,6 +1386,21 @@ class WindowManager { console.log('[Window] 更新窗口呼出位置策略:', strategy) } + /** + * 更新 Linux CSS app-region 拖拽开关状态。 + * + * @param enabled 是否启用 CSS app-region 系统拖拽。 + * @returns 无返回值。 + */ + public setCssAppRegionDragEnabled(enabled: boolean): void { + this.cssAppRegionDragEnabled = enabled + // 启用 CSS 系统拖拽后不再使用 blur 自动隐藏,清掉可能残留的确认/复核定时器。 + if (enabled) { + this.clearLinuxBlurHideTimers() + } + console.log('[Window] CSS app-region 拖拽:', enabled ? '已启用' : '已禁用') + } + /** * 获取打开窗口前激活的窗口 */ diff --git a/src/preload/index.ts b/src/preload/index.ts index a56c24a1..2cee8043 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -318,6 +318,18 @@ const api = { ) => { ipcRenderer.on('update-acrylic-opacity', (_event, data) => callback(data)) }, + /** + * 监听 CSS app-region 拖拽开关的实时更新。 + * @param callback 接收开关状态(true 表示由系统接管搜索框区域拖拽)的回调函数 + * @returns 用于移除监听器的清理函数 + */ + onUpdateCssAppRegionDrag: (callback: (enabled: boolean) => void): (() => void) => { + const handler = (_event: Electron.IpcRendererEvent, enabled: boolean): void => callback(enabled) + ipcRenderer.on('update-css-app-region-drag', handler) + return (): void => { + ipcRenderer.removeListener('update-css-app-region-drag', handler) + } + }, /** * 监听主窗口顶部栏密度的实时变化。 * @param callback 接收紧凑模式开关的回调函数。 diff --git a/src/renderer/src/components/search/SearchBox.vue b/src/renderer/src/components/search/SearchBox.vue index 1d4d5395..c00da682 100644 --- a/src/renderer/src/components/search/SearchBox.vue +++ b/src/renderer/src/components/search/SearchBox.vue @@ -2,7 +2,10 @@