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 仓库和官网:

相关推荐
Hi_kenyon2 小时前
快速入门VUE与JS(二)--getter函数(取值器)与setter(存值器)
前端·javascript·vue.js
海云前端12 小时前
前端面试加分技巧:文本省略 + Tooltip 优雅实现,附可直接复用代码(求职党必看)
前端
在西安放羊的牛油果2 小时前
浅谈 storeToRefs
前端·typescript·vuex
triumph_passion2 小时前
Zustand 从入门到精通:我的工程实践笔记
前端·性能优化
pusheng20252 小时前
双气联防技术在下一代储能系统安全预警中的应用
前端·安全
C_心欲无痕2 小时前
ts - 交叉类型
前端·git·typescript
彭涛3612 小时前
Blog-SSR 系统操作手册(v1.0.0)
前端
全栈前端老曹2 小时前
【前端路由】React Router 权限路由控制 - 登录验证、私有路由封装、高阶组件实现路由守卫
前端·javascript·react.js·前端框架·react-router·前端路由·权限路由
zhuà!2 小时前
uv-picker在页面初始化时,设置初始值无效
前端·javascript·uv
Amumu121382 小时前
React应用
前端·react.js·前端框架