Tauri (20)——为什么 NSPanel 窗口不能用官方 API 全屏?

在基于 Electron 或 Tauri 开发 macOS 桌面应用时,我们经常会遇到一种特殊的窗口类型:NSPanel。它通常用于 spotlight 搜索栏、悬浮工具条等场景。然而,当我们想给这种"小窗口"加上全屏能力(比如玩游戏、看大图)时,往往会撞上一堵墙:官方的全屏 API 对 NSPanel 并不友好,甚至直接失效。

项目背景:Coco AI

我们在构建 Coco AI ------ 这款集成了统一搜索、协作与 AI 助手的跨平台桌面生产力工具时,遇到了一个有趣的技术挑战。

Coco AI 强大的插件系统Extensions)允许用户在应用内直接运行各种工具(如小游戏、可视化图表、Web 应用等)。为了保持轻量和随手即用的体验,Coco 默认使用类似于 Spotlight 的悬浮窗(NSPanel)展示。但当用户想要沉浸式地使用插件(比如玩个小游戏)时,默认的小窗口就显得局促了。

我们希望实现的效果是:平时召之即来挥之即去,需要时一键变身全屏工作台。

然而,官方的窗口 API 在 NSPanel 上却频频"翻车"。今天就来复盘一下我们是如何在 Coco App 中解决这个问题的。

应用场景:一个嵌入式小游戏窗口

假设我们在开发一个类似于 Spotlight 的启动器,平时它是一个悬浮在屏幕中央的小框。但我们允许用户通过插件系统加载一个网页(比如 HTML5 游戏)。

需求很直接:

  1. 默认窗口大小固定(如 1200x900)。
  2. 用户点击"全屏"按钮,窗口瞬间铺满当前屏幕。
  3. 再次点击,恢复原状。

技术栈:

  • Tauri (Rust + WebView)
  • Frontend: React + TypeScript
  • Window Type : NSPanel (macOS)

遇到的坑:NSPanel 与 setFullscreen 的爱恨情仇

在普通的 NSWindow 中,调用 Tauri 的 window.setFullscreen(true) 或 Electron 的 setFullScreen(true),系统会自动创建一个新的 Space,把窗口平铺进去,非常优雅。

但 Coco AI 为了追求"极致的快速启动与无感交互",使用了 NSPanel 并设置了较高的窗口层级。当我们试图对它调用标准全屏 API 时:

  1. 系统动画冲突:由于没有标准标题栏,系统全屏动画可能会导致窗口消失、闪烁甚至错位。
  2. 多屏支持噩梦:用户在副屏唤起 Coco AI,点击全屏,结果窗口直接飞回了主屏。
  3. 状态不可逆:退出全屏后,窗口焦点和层级可能回不到原来的状态,打断了用户的心流。

简单来说,官方 API 是给 "标准应用窗口" 设计的,并不适配我们这种 "灵动挂件"

解决方案:手动接管窗口布局

既然系统 API 不懂我们的心,我们就自己动手,"伪造"一个全屏效果。通过 "手动计算 + 逻辑坐标转换" 来优雅解决这个问题。

核心原理

  1. 精准定位:获取当前鼠标所在的显示器(Monitor),确保"在哪里唤起,就在哪里全屏"。
  2. 坐标系转换 :macOS 使用逻辑像素(Logical Pixel),而底层屏幕信息往往是物理像素(Physical Pixel),必须通过 scaleFactor 进行转换,否则窗口会巨大无比或只有四分之一大。
  3. 暴力美学 :直接修改窗口的 x, y, width, height,使其完美覆盖目标屏幕的 Bounds。
  4. 状态快照:在变身前,记住原来的位置和大小,以便随时缩回那个熟悉的"小框框"。

Coco AI 的实现代码

以下是我们在 ViewExtension.tsx 中的核心实现逻辑。

typescript 复制代码
const applyFullscreen = useCallback(
  async (next: boolean) => {
    if (next) {
      // 1. 状态快照:保存当前位置、大小、是否可调整
      const size = await platformAdapter.getWindowSize();
      const resizable = await platformAdapter.isWindowResizable();
      const pos = await platformAdapter.getWindowPosition();
      
      fullscreenPrevRef.current = {
        width: size.width,
        height: size.height,
        resizable,
        x: pos.x,
        y: pos.y,
      };

      // 2. 针对 macOS + Tauri (NSPanel) 的特殊处理
      if (isMac && isTauri) {
        // 关键步:获取鼠标所在的屏幕,实现"原位全屏"
        const monitor = await platformAdapter.getMonitorFromCursor();

        if (!monitor) return;
        const window = await platformAdapter.getCurrentWebviewWindow();
        const factor = await window.scaleFactor();

        // 3. 坐标转换:物理像素 -> 逻辑像素
        const { size, position } = monitor;
        const { width, height } = size.toLogical(factor);
        const { x, y } = position.toLogical(factor);

        // 4. 手动铺满屏幕
        await platformAdapter.setWindowSize(width, height);
        await platformAdapter.setWindowPosition(x, y);
        await platformAdapter.setWindowResizable(true); // 全屏模式下通常允许调整
        await recomputeScale(); // 调整内部 Web 内容的缩放比例
      } else {
        // 其他平台使用标准 API 即可
        await platformAdapter.setWindowFullscreen(true);
        await recomputeScale();
      }
    } else {
      // 5. 退出全屏:恢复如初
      if (!isMac) {
        await platformAdapter.setWindowFullscreen(false);
      }
      // 从配置或默认值恢复大小
      const nextWidth = ui?.width ?? DEFAULT_VIEW_WIDTH;
      const nextHeight = ui?.height ?? DEFAULT_VIEW_HEIGHT;
      
      await platformAdapter.setWindowSize(nextWidth, nextHeight);
      await platformAdapter.setWindowResizable(ui?.resizable ?? true);
      
      // 关键步:居中回原来的屏幕
      await platformAdapter.centerOnCurrentMonitor();
      await recomputeScale();
      
      // 6. 焦点修复(防止操作中断)
      setTimeout(() => {
        iframeRef.current?.focus();
      }, 0);
    }
  },
  [ui, recomputeScale]
);

为什么这样做体验更好?

  • 瞬时响应:没有了系统全屏动画的拖泥带水,点击即全屏。
  • 多屏友好:完美支持多显示器环境,不再发生"窗口瞬移"的灵异事件。
  • UI 自由度:保留了我们自定义的 UI 控件,不受系统标题栏的干扰。

小结

在开发 Coco AI 的过程中,我们始终坚持 "不因为技术限制而妥协用户体验" 。虽然手动管理窗口状态比调用一个 API 麻烦得多,但为了让用户在使用插件时能有丝滑的体验,这一切都是值得的。

如果你对我们的技术栈(Rust + Tauri + React)感兴趣,或者想体验一下这个"既能小巧悬浮,又能全屏沉浸"的生产力工具,欢迎访问我们的 GitHub 仓库和官网:

相关推荐
bug总结2 小时前
前端开发中为什么要使用 URL().origin 提取接口根地址
开发语言·前端·javascript·vue.js·html
一招定胜负3 小时前
网络爬虫(第三部)
前端·javascript·爬虫
Shaneyxs3 小时前
从 0 到 1 实现CloudBase云开发 + 低代码全栈开发活动管理小程序(13)
前端
半山烟雨半山青3 小时前
微信内容emoji表情包编辑器 + vue3 + ts + WrchatEmogi Editor
前端·javascript·vue.js
码途潇潇3 小时前
Vue 事件机制全面解析:原生事件、自定义事件与 DOM 冒泡完全讲透
前端·javascript
zmirror3 小时前
Monorepo 在 Docker 中的构建方案方案
前端
用户47949283569153 小时前
node_modules 太胖?用 Node.js 原生功能给依赖做一次大扫除
前端·后端·node.js
_Kayo_4 小时前
TypeScript 学习笔记2
前端·javascript·typescript
海纳百川本尊760644 小时前
Flutter框架核心原理深度解析
前端