浏览器打开与屏幕信息
本教程共 42 篇 · 第 27 篇 · 更新于 2026-08-03
27. 浏览器打开与屏幕信息
本节目标
- 理解为什么桌面应用里的外链不能直接在窗口内跳转
- 会用
BrowserOpenURL在 Go 侧和前端打开系统默认浏览器 - 会在 React 里做一个全局的外链拦截器,一劳永逸
- 会用
ScreenGetAll读取显示器列表,并看懂返回的字段 - 知道多显示器和高分屏环境下有哪些坑
27-1 为什么外链要交给系统浏览器
先看一个新手很容易犯的错。在页面里写一个普通链接:
<a href="https://wails.io">官网</a>
点下去会发生什么?WebView 直接导航过去了。你的应用窗口变成了一个没有地址栏、没有前进后退、没有刷新按钮的残废浏览器。用户点进去就出不来,只能杀进程。
更麻烦的是安全问题。窗口里加载的页面能访问 window.go,也就是你绑定给前端的所有 Go 方法。让一个外部网站跑在这个上下文里,等于把后门送出去。
正确做法是把外链交给系统默认浏览器。Wails 运行时提供了一个方法专门干这事:
BrowserOpenURL(ctx context.Context, url string)
BrowserOpenURL(url: string): void
注意它没有返回值。调用之后应用不知道浏览器有没有真的打开,也拿不到打开结果。它就是「扔出去」的语义。
27-2 两侧调用方式
Go 侧的写法和上一章一样,先在 OnStartup 里存好 ctx:
package main
import (
"context"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
type App struct {
ctx context.Context
}
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
}
// OpenDocs 打开官方文档
func (a *App) OpenDocs() {
runtime.BrowserOpenURL(a.ctx, "https://wails.io/docs/introduction")
}
前端侧更直接:
window.runtime.BrowserOpenURL("https://wails.io");
TypeScript 项目里同样要补类型声明,把这一条加进上一章建的 wails-runtime.d.ts:
export {};
declare global {
interface Window {
runtime: {
ClipboardGetText(): Promise<string>;
ClipboardSetText(text: string): Promise<boolean>;
BrowserOpenURL(url: string): void;
ScreenGetAll(): Promise<ScreenInfo[]>;
};
}
}
export interface ScreenInfo {
isCurrent: boolean;
isPrimary: boolean;
width: number;
height: number;
}
一个可复用的 React 组件长这样:
type Props = {
href: string;
children: React.ReactNode;
};
export function ExternalLink({ href, children }: Props) {
return (
<a
href={href}
onClick={(e) => {
e.preventDefault();
window.runtime.BrowserOpenURL(href);
}}
>
{children}
</a>
);
}
e.preventDefault() 这行不能省。不阻止默认行为的话,WebView 照样会导航,你等于同时打开了两个地方。
27-3 一次性拦截所有外链
组件封装解决不了 Markdown 渲染、富文本编辑器这类动态生成的链接。更省心的做法是在应用根部挂一个全局点击监听。
import { useEffect } from "react";
export function useExternalLinks() {
useEffect(() => {
function onClick(e: MouseEvent) {
// 从点击目标往上找最近的 <a>
const anchor = (e.target as HTMLElement).closest("a");
if (!anchor) return;
const href = anchor.getAttribute("href");
if (!href) return;
// 只拦截 http/https,站内路由和锚点放行
if (!/^https?:\/\//i.test(href)) return;
e.preventDefault();
window.runtime.BrowserOpenURL(href);
}
document.addEventListener("click", onClick);
return () => document.removeEventListener("click", onClick);
}, []);
}
在 App.tsx 顶层调一次 useExternalLinks() 就生效了。用 closest("a") 而不是直接判断 e.target,是因为链接里常常套着 <span>、<img>,点到的是子元素。
Tip判断条件写成只匹配
http://和https://,这样 React Router 的站内跳转、#anchor锚点都不会被误伤。
27-4 URL 一定要校验
BrowserOpenURL 把字符串交给操作系统去解释。传什么协议它就试着唤起什么处理程序。如果这个字符串来自用户输入、来自服务端返回、来自剪贴板,就有被利用的空间。
在 Go 侧加一层白名单是最稳妥的:
import (
"fmt"
"net/url"
)
// OpenExternal 只允许 http/https 出去
func (a *App) OpenExternal(raw string) error {
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("非法链接: %w", err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("不支持的协议: %s", u.Scheme)
}
runtime.BrowserOpenURL(a.ctx, u.String())
return nil
}
Warning不要把未经检查的字符串直接丢给
BrowserOpenURL。file://能打开本地文件,某些自定义协议还可能唤起别的程序。协议白名单是必须的。补充一个版本细节:从 v2.11.0 起,Wails 内部给
BrowserOpenURL加了一层 URL 校验,v2.13.0 自然也有。但它走的是黑名单思路——拦掉明确危险的写法,而不是只放行 http/https,因为各平台支持的协议本来就不一样。所以这层内置校验只能算兜底,替代不了你自己按业务需要写的白名单。
mailto: 这类协议如果确实要支持,就显式加进白名单,不要图省事全放行。
27-5 读取屏幕信息
另一个和系统打交道的运行时方法是 ScreenGetAll,它返回当前接入的所有显示器。
ScreenGetAll(ctx context.Context) ([]Screen, error)
ScreenGetAll(): Promise<Screen[]>
Warning官方文档站上这个方法的签名写的是
ScreenGetAll(ctx context.Context) []screen,少了返回的error。以 v2.13.0 的源码为准:pkg/runtime里它返回的是([]Screen, error),Go 侧一定要接第二个返回值,否则编译不过。这是文档滞后于代码的典型例子,遇到对不上的时候查一眼pkg.go.dev上的包文档更可靠。
官方文档里给出的 Screen 结构是这样:
type Screen struct {
IsCurrent bool
IsPrimary bool
Width int
Height int
}
对应的 TypeScript 接口:
interface Screen {
isCurrent: boolean;
isPrimary: boolean;
width: number;
height: number;
}
四个字段的含义很直白。IsCurrent 表示应用窗口当前在这块屏上,IsPrimary 表示这是系统的主显示器,剩下两个是分辨率。v2.13.0 的 Screen 就这四个字段,别指望从它这里拿到显示器坐标、缩放比例或者设备名称,这些信息 v2 的运行时不提供。
Note前面提到的签名对不上,是个提醒:文档站和源码之间可能存在版本差。接手项目时先把
ScreenGetAll的结果原样打印一遍,看看你这个版本实际返回什么,比只翻文档靠谱。TypeScript 那边还要注意别把接口直接命名成Screen——浏览器 DOM 里已经有一个全局的Screen类型,重名会打架,所以本章的类型声明用的是ScreenInfo。
Go 侧用它做点实际的事,比如按屏幕大小决定窗口尺寸:
func (a *App) fitWindowToScreen() {
screens, err := runtime.ScreenGetAll(a.ctx)
if err != nil {
return
}
for _, s := range screens {
if !s.IsCurrent {
continue
}
// 取屏幕的 70% 作为窗口尺寸,并居中
w := int(float64(s.Width) * 0.7)
h := int(float64(s.Height) * 0.7)
runtime.WindowSetSize(a.ctx, w, h)
runtime.WindowCenter(a.ctx)
break
}
}
这段放在 OnStartup 里调用比较合适。用户从 27 寸 4K 屏切到 13 寸笔记本时,窗口不至于超出屏幕范围。
前端拿屏幕信息通常是为了展示或者做判断:
import { useEffect, useState } from "react";
export function ScreenList() {
const [screens, setScreens] = useState<ScreenInfo[]>([]);
useEffect(() => {
window.runtime.ScreenGetAll().then(setScreens);
}, []);
return (
<ul>
{screens.map((s, i) => (
<li key={i}>
屏幕 {i + 1}:{s.width}×{s.height}
{s.isPrimary && " · 主屏"}
{s.isCurrent && " · 当前"}
</li>
))}
</ul>
);
}
27-6 多屏和高分屏的注意事项
屏幕会变。 用户随时可能插拔外接显示器,或者改系统缩放比例。启动时读一次然后缓存一辈子,迟早出问题。窗口尺寸这类计算,在需要用到的时候现读一次。
返回值单位是逻辑像素还是物理像素,取决于平台。 Windows 上系统缩放设成 150% 时,一块 3840×2160 的屏在不同实现里可能报 3840 也可能报 2560。别写死假设,实测一下。
排列顺序不代表物理位置。 数组里第一块屏不一定就是左边那块。ScreenGetAll 给的是列表,不是坐标图。要做「把窗口移到第二块屏」这种精细控制,光靠这四个字段是不够的。
常见误区
误区一:用 window.open() 代替 BrowserOpenURL。 WebView 对 window.open 的处理各平台不一致,有的直接忽略,有的在窗口内导航。用运行时方法才是确定行为。
误区二:给外链加 target="_blank" 就以为安全了。 桌面 WebView 里没有「新标签页」这个概念,target 属性基本无效。
误区三:拦截器写在某个页面组件里。 路由切走之后监听就卸载了,其他页面的链接又会漏出去。挂在应用根组件上。
误区四:把 ScreenGetAll 的结果当成窗口坐标系。 它只告诉你有几块屏、各自多大,不告诉你窗口在哪。窗口位置要用窗口运行时的相关方法去查。
小结
BrowserOpenURL 这个方法本身简单到不需要记,但它背后的原则很重要:应用窗口只跑自己的页面,外部内容一律交给系统浏览器。这既是体验问题,也是安全问题。落地时用一个全局点击拦截器兜底,比逐个链接改省事。
ScreenGetAll 给的信息不多,够用来做窗口自适应。用的时候记住两点:现用现读,别缓存;字段以实际打印结果为准,别照抄别人的代码。
下一章开始进入系统集成的另一块内容:让你的应用能被双击文件直接唤起。