在基于 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 游戏)。

需求很直接:
- 默认窗口大小固定(如 1200x900)。
- 用户点击"全屏"按钮,窗口瞬间铺满当前屏幕。
- 再次点击,恢复原状。
技术栈:
- 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 时:
- 系统动画冲突:由于没有标准标题栏,系统全屏动画可能会导致窗口消失、闪烁甚至错位。
- 多屏支持噩梦:用户在副屏唤起 Coco AI,点击全屏,结果窗口直接飞回了主屏。
- 状态不可逆:退出全屏后,窗口焦点和层级可能回不到原来的状态,打断了用户的心流。
简单来说,官方 API 是给 "标准应用窗口" 设计的,并不适配我们这种 "灵动挂件"。
解决方案:手动接管窗口布局
既然系统 API 不懂我们的心,我们就自己动手,"伪造"一个全屏效果。通过 "手动计算 + 逻辑坐标转换" 来优雅解决这个问题。
核心原理
- 精准定位:获取当前鼠标所在的显示器(Monitor),确保"在哪里唤起,就在哪里全屏"。
- 坐标系转换 :macOS 使用逻辑像素(Logical Pixel),而底层屏幕信息往往是物理像素(Physical Pixel),必须通过
scaleFactor进行转换,否则窗口会巨大无比或只有四分之一大。 - 暴力美学 :直接修改窗口的
x, y, width, height,使其完美覆盖目标屏幕的 Bounds。 - 状态快照:在变身前,记住原来的位置和大小,以便随时缩回那个熟悉的"小框框"。
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 仓库和官网:
- GitHub : github.com/infinilabs/...
- Website : coco.rs/en