首页 / Wails 入门教程 / 浏览器打开与屏幕信息

Wails 入门教程

浏览器打开与屏幕信息

本教程共 42 篇 · 第 27 篇 · 更新于 2026-08-03

Wails桌面开发RuntimeBrowserOpenURLScreen

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

不要把未经检查的字符串直接丢给 BrowserOpenURLfile:// 能打开本地文件,某些自定义协议还可能唤起别的程序。协议白名单是必须的。

补充一个版本细节:从 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 给的信息不多,够用来做窗口自适应。用的时候记住两点:现用现读,别缓存;字段以实际打印结果为准,别照抄别人的代码。

下一章开始进入系统集成的另一块内容:让你的应用能被双击文件直接唤起。