likes
comments
collection
share

详解 Electron 应用内协议

作者站长头像
站长
· 阅读数 29

Electron 中的 protocol 模块提供了两大类与 协议 相关的方法:

  • 协议拦截

    • interceptBufferProtocol:拦截协议并响应 buffer
    • interceptFileProtocol:拦截协议并响应 file
    • interceptHttpProtocol:拦截协议并响应 http/https 请求
    • interceptStreamProtocol:拦截协议并响应 stream
    • interceptStringProtocol:拦截协议并响应 string
    • isProtocolIntercepted:查询协议是否被拦截
    • uninterceptProtocol:取消协议拦截
  • 协议注册

    • registerSchemesAsPrivileged:注册特权协议
    • registerBufferProtocol:注册协议并响应 buffer
    • registerFileProtocol:注册协议并响应 file
    • registerHttpProtocol:注册协议并响应 http/https 请求
    • registerStreamProtocol:注册协议并响应 stream
    • registerStringProtocol:注册协议并响应 string
    • isProtocolRegistered:查询协议是否注册
    • unregisterProtocol:取消协议注册

关于这些 API,很多初学者不知所云,其实是 Electron 的函数名起的不好,新手容易被误导,所谓的 interceptXXXProtocol(yyy) 并不是字面上的拦截 xxx 协议的意思,而是拦截 yyy 这种形式的 scheme,并且以 xxx 协议的形式返回结果。

什么是协议?

Protocol 的中文翻译是「协议」,例如 HTTP Protocol 就是 HTTP 协议,FTP Protocol 就是 FTP 协议:

详解 Electron 应用内协议

似乎比较容易理解,但这里还需要了解与之相关的另外一个概念,那就是 scheme,这个单词非常容易跟 protocol 搞混淆,但特别重要,接下来要讲的应用内协议跟它关系很大,如果搞不懂 scheme 的话后面的概念更难理解。

要解释清楚 scheme 是什么,需要从统一资源标识符(URI)开始讲起,维基百科的原文是:

A Uniform Resource Identifier (URI) is a unique sequence of characters that identifies a logical or physical resource used by web technologies.

即 URI 是一串唯一的字符序列,用于标识网络中的逻辑或物理资源。但实际上,URI 可以表示任何事物:

URIs may be used to identify anything, including real-world objects, such as people and places, concepts, or information resources such as web pages and books.

URI 的语法规则如下:

详解 Electron 应用内协议

常见的 URI 有:

  • https://www.baidu.com
  • telnet://192.0.2.16:80/
  • mailto:John.Doe@example.com
  • tel:+1-816-555-1212

scheme 就是从 URI 中最开始位置到第一个冒号(:)之间的字符,常见的有:

  • https
  • ftp
  • git
  • mailto
  • ……

schemeprotocol 之间有比较微妙的区别。举个例子,以 example.com 为例,其中 schemehttps,用于告诉浏览器资源 example.com 需要通过 HTTPS 协议获取。因此可以这么理解:scheme 是 protocol 的标识符。

在此之前,我们已经知晓 Electron 的另一个 API :

app.setAsDefaultProtocolClient('xxx')

作用是设置当前 Electron 应用为默认的 xxx 协议客户端,后面就可以通过 xxx:// 唤起这个应用了,这是对外而言的(即外部应用通过 xxx 唤起自己),而 protocol 模块则提供了应用内协议,允许用户在应用内部自定义协议,或者对已有协议进行拦截。

协议拦截

协议拦截可以做很多事情,举个例子:拦截应用中的所有 http 请求,直接返回一个设置好的 JSON 字符串数据:

protocol.interceptStringProtocol('http', (request, callback) => {
  callback({
    mimeType: 'application/json',
    data: JSON.stringify({ name: 'keliq' }),
  })
})

这样的话所有请求都返回 {"name":"keliq"} 了,这个场景非常适合做本地数据 mock。很多人可能会觉得 interceptHttpProtocol 才是用于拦截 http 或 https 请求的,其实拦截什么协议是方法的第一个参数(即 scheme 字符串)决定的,而不是方法名称( interceptXXXProtocol)里面写的,所以:

  • interceptStringProtocol 方法能拦截 http 请求,通过返回字符串来响应
  • interceptHttpProtocol 方法也能拦截 http 请求,通过发起另外一个 http 请求来响应

为了演示 interceptHttpProtocol 的使用,首先创建一个窗口加载百度网站:

const win = new BrowserWindow({ width: 600, height: 600 })
win.loadURL('https://www.baidu.com')
protocol.interceptHttpProtocol('https', (request, callback) => {
  console.log('interceptHttpProtocol', request)
  callback({
    url: request.url,
    method: request.method,
  })
})

发现页面白屏了,而且控制台会一直打印:

interceptHttpProtocol {
  method: 'GET',
  url: 'https://www.baidu.com/',
  referrer: '',
  headers: {
    'Upgrade-Insecure-Requests': '1',
    'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) electron-desktop/1.0.0 Chrome/106.0.5249.168 Electron/21.2.3 Safari/537.36',
    Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9'
  }
}

结原因很简单:拦截到请求之后,通过 callback 返回的新请求(还是 www.baidu.com)又被拦截了,形成了死循环。因为文档中对 interceptHttpProtocol 方法是这么解释的:

Intercepts scheme protocol and uses handler as the protocol's new handler which sends a new HTTP request as a response.

想想也是这个道理,拦截了 https 请求,然后又发起了新的 https 请求,自然也会被拦截掉,从而发生死循环。那如果把 https 请求拦截并转发到 http 呢?为了验证效果 ,首先启动一个本地 http 服务:

require('http')
  .createServer((req, res) => res.end('interceptHttpProtocol'))
  .listen(3030)

然后把 url 改成本地 http 地址:

const success = protocol.interceptHttpProtocol('https', (request, callback) => {
  console.log('interceptHttpProtocol', request)
  callback({ url: 'http://127.0.0.1:3030', method: request.method })
})

结果竟然直接弹出了一个下载框!

详解 Electron 应用内协议

保存一看,就是一个纯文本,内容是 interceptHttpProtocol,说明已经拦截成功了,但由于没有设置 content-type 头,浏览器无法识别类型导致的,因为文档对 callback 中 url 参数是这么解释的:

Download the url and pipe the result as response body.

当没有 content-type 头的时候,会把响应视为字节流(application/octet-stream),而浏览器处理字节流的默认方式就是下载。好吧,那就指定 content-typetext/html 再试试:

require('http')
  .createServer((req, res) => {
    res.setHeader('content-type', 'text/html')
    res.end('interceptHttpProtocol')
  })
  .listen(3030)

修改之后页面显示正常了,发往百度的请求被拦截到了本地:

详解 Electron 应用内协议

通过上面的案例,大家可能马上会想到:项目中遇到需要代理的场景,例如将 http://localhost:3030 请求转发到 http://localhost:4040,就可以利用网络拦截来做呀!

没错,接下来就重点演示一下这个功能。首先写个 http 服务监听 3030 和 4040 端口:

const http = require('http')
const URL = require('url')

function listen(port) {
  http
    .createServer((req, res) => {
      const { url, method, headers } = req
      const { query, pathname } = URL.parse(url, true)
      res.setHeader('Content-Type', 'application/json')
      res.end(JSON.stringify({ method, pathname, query, headers, port }, null, 2))
    })
    .listen(port)
}

listen(3030)
listen(4040)

然后用下面的方式进行拦截:

protocol.interceptHttpProtocol('http', (request, callback) => {
  if (request.url === 'http://localhost:3030/') {
    callback({
      url: 'http://localhost:4040/', // 将 3030 的请求转发到 4040 端口
      method: request.method,
      session: null,
    })
  } else {
    callback({
      url: request.url,
      method: request.method,
      session: null,
    })
  }
})

这里比较关键的点是:一定要在 callback 里面设置 session 为 null,否则就会造成死循环,无法获取请求结果。因为 protocol 是注册到 Electron 的特定 session 对象上的,如果没有指定 session 对象,那么就会注册到默认 session 对象上。

这也意味着,如果某个 browserWindow 的 webPreferences 中定义了 partition或者 session,那么自定义协议在该窗口下就不生效了,除非用当前窗口的 webContents 的 session 的 protocol 对象来注册协议。

除此之外,interceptHttpProtocol 的另一个比较常典型的应用场景是:对所有网络请求进行拦截,然后增加 query 参数:

protocol.interceptHttpProtocol('http', (request, callback) => {
  const url = new URL(request.url)
  url.searchParams.append('name', 'keliq') // 所有请求都会增加此参数
  callback({
    url: url.toString(),
    method: request.method,
    session: null,
  })
})

最终效果是请求 http://localhost:3030变成了请求 http://localhost:3030?name=keliq

详解 Electron 应用内协议

除了拦截 http 之外,项目中还会遇到拦截文件的场景,这个时候就需要用 interceptFileProtocol 这个 API 了,举个例子,HTML 结构为:

<body>
  <img src="lib/img/apple.png" />
  <iframe src="./embed.html?lang=zh"></iframe>
</body>

加载进来,添加拦截:

const win = new BrowserWindow({ width: 600, height: 600 })
win.loadFile(path.join(__dirname, '../renderer/index.html'))
protocol.interceptFileProtocol('file', (request, callback) => {
    console.log(request)
})

控制台打印出被拦截的文件信息:

{
  method: 'GET',
  url: 'file:///Users/keliq/electron-desktop/src-apis/protocol/renderer/index.html',
}

不过此时页面是白屏状态,HTML 并没有渲染出来,那是因为拦截后没有给响应,只需要 callback 给一个本地文件地址即可:

protocol.interceptFileProtocol('file', (request, callback) => {
    console.log('protocol.interceptFileProtocol', request)
    const filePath = url.fileURLToPath(request.url)
    callback(filePath)
})

注意这里用了 fileURLToPath 函数来处理 file://xxx 类型的路径,例如:

  • file:///lib/img/apple.png 转换成 /lib/img/apple.png

注意不能简单用 request.url.slice(7)file:// 前缀去掉传给 callback,因为 query 参数还在里面:

  • callback('/renderer/embed.html?lang=zh') 会找不到文件 ❌
  • callback('/renderer/embed.html') 才是文件的真实路径 ✅

interceptFileProtocol 的一个典型应用场景是做网络缓存:先看本地文件是否存在,如果没有则下载到本地,以备下次使用。代码实现如下:

/**
 * 处理 <img src="./img/bananas.jpg" /> 中的本地资源
 * 提取路径,映射到 https://img.zlib.cn/bananas.jpg
 * 文件存在则使用,否则下载到本地
 */
const fileProtocol = protocol.interceptFileProtocol('file', (request, callback) => {
  if (request.url.includes('img/')) {
    const filename = request.url.split('/').pop()
    const filePath = path.join(__dirname, 'img', filename)
    if (fs.existsSync(filePath)) return callback(filePath)
    const file = fs.createWriteStream(filePath)
    const fileURL = 'https://img.zlib.cn/' + filename
    https.get(fileURL, (res) => {
      res.pipe(file)
      file.on('finish', () => callback(filePath))
    })
  } else {
    const filePath = url.fileURLToPath(request.url)
    callback(filePath)
  }
})

效果如下:

详解 Electron 应用内协议

协议注册

我们可以通过注册自定义协议,例如 cached 来实现上面缓存拦截的效果,函数内的逻辑是一样的,只不过调用了 registerFileProtocol 方法注册了自己的 scheme 字符串:

protocol.registerFileProtocol('cached', (request, callback) => {
  if (request.url.includes('img/')) {
    const filename = request.url.split('/').pop()
    const filePath = path.join(__dirname, 'img', filename)
    if (fs.existsSync(filePath)) return callback(filePath)
    const file = fs.createWriteStream(filePath)
    const fileURL = 'https://img.zlib.cn/' + filename
    https.get(fileURL, (res) => {
      res.pipe(file)
      file.on('finish', () => callback(filePath))
    })
  } else {
    const filePath = url.fileURLToPath(request.url)
    callback(filePath)
  }
})

在 HTML 中要这么使用:

<body>
  <img src="cached:img/bananas.jpg" />
</body>

效果如下:

详解 Electron 应用内协议

协议拦截和协议注册很多场景下是互通的,都能实现定制化的诉求。但是注册协议的时候可能会有坑,比如现在注册了一个 hello 协议:

protocol.registerFileProtocol('hello', (request, callback) => {
  console.log('hello', request)
  const relativePath = request.url.replace(/^hello:\/*?/, '').replace(/\/?\?.*/, '')
  const filePath = path.join(__dirname, '../renderer', relativePath)
  callback(filePath)
})

在 HTML 里面加载两个 iframe,一个通过正常的文件加载,一个通过自定义协议加载:

<body>
  <iframe src="./embed.html?lang=zh"></iframe>
  <iframe id="hello" src="hello:embed.html?lang=zh"></iframe>
</body>

其中 embed.html 文件中的代码为:

<div id="embed">
  <p>来自 embed.html</p>
  <img src="../lib/img/apple.png" />
</div>

其实效果是一样的,因为自定义协议也定位到了 embed.html,但是里面的图片却无法展示出来,中文也乱码了:

详解 Electron 应用内协议

再看控制台打印,发现竟然打印了两次:

hello {
  method: 'GET',
  url: 'hello:embed.html?lang=zh',
}

hello {
  method: 'GET',
  url: 'hello:embed.html?lang=zh',
}

其中第一次是 index.html 加载 iframe 的时候,拦截了 src 属性(可通过控制台按 cmd + R 刷新验证);第二次是拦截了 iframe 自己的 location 刷新(可通过在控制台输入 hello.src += ''验证)。

然而,你会发现自定义协议加载的 embed.html 里面的图片资源没有被加载。但如果我们在 app ready 前添加了下面的代码,情况就不同了:

protocol.registerSchemesAsPrivileged([
  {
    scheme: 'hello',
    privileges: { standard: true },
  },
])

再看控制台输出:

hello {
  method: 'GET',
  url: 'hello://embed.html/?lang=zh',
}

hello {
  method: 'GET',
  url: 'hello://embed.html/lib/img/apple.png',
}

hello {
  method: 'GET',
  url: 'hello://embed.html/?lang=zh',
}

发现了两个区别:

  • urlhello:变成了 hello://格式
  • 多了一次拦截请求 hello://embed.html/lib/img/apple.png

这其实是因为在 registerSchemesAsPrivileged 方法中设置 privileges 为标准协议导致的,registerSchemesAsPrivileged 方法的字面含义是注册特权协议,那都有哪些特权呢?官方给的结构是:

interface Privileges {
  standard?: boolean;
  secure?: boolean;
  bypassCSP?: boolean;
  allowServiceWorkers?: boolean;
  supportFetchAPI?: boolean;
  corsEnabled?: boolean;
  stream?: boolean;
}

其中比较难理解的是 standard 属性,为了讲清楚这个概念,需要再回顾一下本文最开始介绍的 URI 的语法:

scheme:[//authority]path[?query][#fragment]

其中包含 5 个部分:

  • 必选的非空的 scheme 字符串,后面跟着一个冒号 :
  • 可选的 authority 部分,如果有,则以 // 为前缀
    • 可选authority 部分,如果有,结构为 [userinfo "@"] host [":" port]
  • 必选的路径部分,以 / 为分隔符
  • 可选的 query 部分,如果有,则以 ? 为前缀
  • 可选的 fragment 部分,如果有,则以 # 为前缀

选项有很多,但只有两项是必选,其他都是可选,因此约束比较弱,而 URL 的约束则更强一点,它与 URI 的区别在于:

URI contains components like a scheme, authority, path, and query. URL has similar components to a URI, but its authority consists of a domain name and port.

可以看到 URL 是 URI 的特殊类型,且包含了 authority 部分,而 Electron 文档中对 standard scheme的描述如下:

A standard scheme adheres to what RFC 3986 calls generic URI syntax. For example http and https are standard schemes, while file is not.

可以理解所谓的标准协议就是形如 http 格式的协议,把协议注册成标准协议有以下好处:

  • 能够解析绝对和相对路径
  • 能够访问 FileSystem API
  • 支持 web storage API,例如 localstorage、cookie 等

再回来解释上面的两个行为:首先拦截器拦截到的 urlhello: 变成了 hello://格式,是因为标准协议必须包含 authority 部分,该部分是以 // 开头的,于是解析器给自动补上了。其次多了一次拦截请求,是因为标准协议支持解析相对或绝对资源路径:

Registering a scheme as standard allows relative and absolute resources to be resolved correctly when served. Otherwise the scheme will behave like the file protocol, but without the ability to resolve relative URLs.

虽然能够请求图片了,但 hello://embed.html/lib/img/apple.png 这个请求路径是有问题的,这是因为解析器把 embed.html 当成了 host,而不是 pathname,为了把 foo.html 当做 pathname 而不是 host,需要在 html 中这么写:

<iframe id="hello" src="hello:/embed.html?lang=zh"></iframe>

这样 host 就会被置空,再看拦截请求变成了:

hello {
  method: 'GET',
  url: 'hello:/embed.html?lang=zh',
}

hello {
  method: 'GET',
  url: 'hello:/lib/img/apple.png',
}

hello {
  method: 'GET',
  url: 'hello:/embed.html?lang=zh',
}

图片也已经顺利展示出来了:

详解 Electron 应用内协议

你可能会问:那中文乱码的问题怎么解决呢?其实这根协议注册关系不大,只需要修改 embed.html,将其 charset 设置为 UTF-8 即可:

<head>
  <meta charset="UTF-8">
</head>
<div id="embed">
  <p>来自 embed.html</p>
  <img src="./lib/img/apple.png" />
</div>