12
头图

背景

以前用迅雷下载大片时,只需要复制torrent种子立即唤醒迅雷应用下载,特别好用。那么web是如何唤醒桌面应用的呢?

本文将讲述WindowsMac如何唤醒Electron应用。

Electron 基础架构

Electron 是一个可以用 JavaScript、HTMLCSS 构建桌面应用程序的库。这些应用程序能打包到 Mac、WindowsLinux 系统上运行,也能上架到 MacWindowsApp Store

Electron 结合了 Chromium、Node.js 以及 操作系统本地的 API(如打开文件窗口、通知、图标等)。
image.png

ElectronChromium 在架构上很相似。Chromium运行时有一个 Browser Process,以及一个或者多个 Renderer Process

Renderer Process 顾名思义负责渲染Web页面。Browser Process 则负责管理各个 Renderer Process 以及其他部分(比如菜单栏,收藏夹等等),如下图:

image.png

Electron中,结构仍然类似,不过这里是一个 Main Process 管理多个 Renderer Process

image.png

而且在 Renderer Process 可以使用 Node.jsAPI,这就赋予来 Electron 极大的能力,以下是主进程以及渲染进程可以访问到的API

image.png

如何将 Chromium 与 Node 整合

Electron 最让人兴奋的地方在于 ChromiumNode 的整合。通俗的讲,我们可以在 Chromium 的控制台上做任何 Node 可以做的事。

能够做这个整合,首先得益于 ChromiumNode.js 都是基于 v8 引擎来执行 js 的,所以给了一种可能,他们是可以一起工作的。

但是有一个问题,ChromiumNode.js 的事件循环机制不同。我们知道,Node.js 是基于 libuv 的,Chromium 也有一套自己的事件循环方式,要让他们一起工作,就必须整合这两个事件循环机制。

image.png

如上图所示,Electron 采用了这样一种方式,它起了一个新的线程轮询 libuv 中的 backend fd,从而监听 Node.js 中的事件,一旦发现有新的事件发生,就会立即把它 postChromium 的事件循环中,唤醒主线程处理这个事件。

Electron 与 NW.js 的对比以及区别

Electron 同样出名的跨平台桌面应用开源库还有 NW.js。他们都有非常出名的应用,例如用Electron开发的有 vscode,用 NW.js 开发的有钉钉。

Electron 的原名叫 Atom ShellNW.js 的原名叫 node-webkit;他们起初是同一个作者开发,而且这个这个作者是国人,先向大佬致敬,为我们开源这么优秀的开源工具。后来种种原因分为两个产品,一个命名为 NW.js(英特尔公司提供技术支持)、 另一命名为 Electron(Github 公司提供技术支持)。

(1)两者在GitHub上的数据对比

nw.js    (36.7k star,  4051 commits, 256 releases,  748 open issues,  5862 closed)
electron (81.7k star, 23364 commits, 849 releases, 1047 open issues, 11612 closed)
可以看出 `Electron` 更加活跃。

(2)两者程序的入口不同

在 `NW.js` 中,应用的主入口是网页或者`JS`脚本。 你需要在 `package.json` 中指定一个`html`或者`js`文件,一旦应用的主窗口(在`html`作为主入口点的情况下)或脚本被执行,应用就会在浏览器窗口打开。

在 `Electron` 中,入口是一个 `JavaScript` 脚本。 不同于直接提供一个`URL`,你需要手动创建一个浏览器窗口,然后通过 `API` 加载 `HTML` 文件。 你还可以监听窗口事件,决定何时让应用退出。

`Electron` 的工作方式更像 `Node.js `运行时 ,`Electron` 的` APIs `更加底层。

(3)Node 集成

NW.js,网页中的 Node 集成需要通过给 Chromium 打补丁来实现。但在 Electron 中,我们选择了另一种方式:通过各个平台的消息循环与 libuv 的循环集成,避免了直接在 Chromium 上做改动。这就意味着 Electron 迭代的成本更低。

搭建一个最简单的 Electron

初始化项目

Electron 应用程序遵循与其他 Node.js 项目相同的结构。 首先创建一个文件夹并初始化 npm 包。

mkdir my-electron-app && cd my-electron-app
npm init

init初始化命令会提示您在项目初始化配置中设置一些值 为本教程的目的,有几条规则需要遵循:

  • entry point 应为 main.js(其他命名也可以,对应主文件即可).
  • authordescription 可为任意值,但对于应用打包是必填项

你的 package.json 文件应该像这样:

{
  "name": "my-electron-app",
  "version": "1.0.0",
  "description": "Hello World!",
  "main": "main.js",
  "author": "Jane Doe",
  "license": "MIT"
}

然后,将 electron 包安装到应用的开发依赖中。

最后,您希望能够执行 Electron 如下所示,在您的 package.json配置文件中的scripts字段下增加一条start命令:

{
  "scripts": {
    "start": "electron ."
  }
}

start命令能让您在开发模式下打开您的应用

注意:此脚本命令将告诉 Electron 在您项目根目录运行,如果目录不对您的应用将立即抛出一个错误提示您它无法找到要运行的应用

任何 Electron 应用程序的入口都是 main 文件。 这个文件控制了主进程,它运行在一个完整的Node.js环境中,负责控制您应用的生命周期,显示原生界面,执行特殊操作并管理渲染器进程

执行期间,Electron 将依据应用中 package.json配置下main字段中配置的值查找此文件

要初始化这个main文件,需要在您项目的根目录下创建一个名为main.js的空文件。

创建页面

在可以为我们的应用创建窗口前,我们需要先创建加载进该窗口的内容。 在 Electron 中,每个窗口中无论是本地的HTML文件还是远程URL都可以被加载显示。

此处将采用本地HTML的方式。 在您的项目根目录下创建一个名为index.html的文件:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
    <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'">
    <meta http-equiv="X-Content-Security-Policy" content="default-src 'self'; script-src 'self'">
    <title>Hello World!</title>
  </head>
  <body>
    <h1>Hello World!</h1>
    We are using Node.js <span id="node-version"></span>,
    Chromium <span id="chrome-version"></span>,
    and Electron <span id="electron-version"></span>.
  </body>
</html>

主文件创建界面

现在您有了一个页面,将它加载进应用窗口中。 要做到这一点,你需要两个Electron模块:

(1)app 模块,它控制应用程序的事件生命周期。
(2)BrowserWindow 模块,它创建和管理应用程序 窗口。
因为主进程运行着Node.js,您可以在文件头部将他们导入作为公共JS模块:

const { app, BrowserWindow } = require('electron')

然后,添加一个createWindow()方法来将index.html加载进一个新的BrowserWindow实例。

function createWindow () {
  const win = new BrowserWindow({
    width: 800,
    height: 600
  })

  win.loadFile('index.html')
}

接着,调用createWindow()函数来打开您的窗口。

Electron 中,只有在 app 模块的 ready 事件被激发后才能创建浏览器窗口。 您可以通过使用 app.whenReady() API来监听此事件。 在whenReady()成功后调用createWindow()

app.whenReady().then(() => {
  createWindow()
})

此时,您的电子应用程序应当成功 打开显示您页面的窗口!

管理窗口的生命周期

虽然你现在可以打开一个浏览器窗口,但你还需要一些额外的模板代码使其看起来更像是各平台原生的。 应用程序窗口在每个OS下有不同的行为,Electron将在app中实现这些约定的责任交给开发者们。

一般而言,你可以使用 进程 全局的 platform 属性来专门为某些操作系统运行代码。

关闭所有窗口时退出应用 (Windows & Linux)
WindowsLinux上,关闭所有窗口通常会完全退出一个应用程序。

为了实现这一点,监听 app 模块的 'window-all-closed' 事件,并在用户不是在 macOS (darwin) 上运行时调用 app.quit()

app.on('window-all-closed', function () {
  if (process.platform !== 'darwin') app.quit()
})

如果没有窗口打开则打开一个窗口 (macOS)
Linux Windows 应用在没有窗口打开时退出了,macOS 应用通常即使在没有打开任何窗口的情况下也继续运行,并且在没有窗口可用的情况下激活应用时会打开新的窗口。

为了实现这一特性,监听 app 模块的 activate 事件,并在没有浏览器窗口打开的情况下调用你仅存的 createWindow() 方法。

因为窗口无法在 ready 事件前创建,你应当在你的应用初始化后仅监听 activate 事件。 通过在您现有的 whenReady() 回调中附上您的事件监听器来完成这个操作。

app.whenReady().then(() => {
  createWindow()

  app.on('activate', function () {
    if (BrowserWindow.getAllWindows().length === 0) createWindow()
  })
})

此时,您的窗口控件应功能齐全!

通过预加载脚本从渲染器访问Node.js
现在,最后要做的是输出Electron的版本号和它的依赖项到你的web页面上。

在主进程通过Node的全局 process 对象访问这个信息是微不足道的。 然而,你不能直接在主进程中编辑DOM,因为它无法访问渲染器 文档 上下文。 它们存在于完全不同的进程!

这是将 预加载 脚本连接到渲染器时派上用场的地方。 预加载脚本在渲染器进程加载之前加载,并有权访问两个 渲染器全局 (例如 window document) 和 Node.js 环境。

创建一个名为 preload.js 的新脚本如下:

window.addEventListener('DOMContentLoaded', () => {
  const replaceText = (selector, text) => {
    const element = document.getElementById(selector)
    if (element) element.innerText = text
  }

  for (const dependency of ['chrome', 'node', 'electron']) {
    replaceText(`${dependency}-version`, process.versions[dependency])
  }
})

上面的代码访问 Node.js process.versions 对象,并运行一个基本的 replaceText 辅助函数将版本号插入到 HTML 文档中。

要将此脚本附加到渲染器流程,请在你现有的 BrowserWindow 构造器中将路径中的预加载脚本传入 webPreferences.preload 选项。

// 在文件头部引入 Node.js 中的 path 模块
const path = require('path')

// 修改现有的 createWindow() 函数
function createWindow () {
  const win = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js')
    }
  })

  win.loadFile('index.html')
}
// ...

额外:将功能添加到您的网页内容
此刻,您可能想知道如何为您的应用程序添加更多功能。

对于与您的网页内容的任何交互,您想要将脚本添加到您的渲染器进程中。 由于渲染器运行在正常的 Web 环境中,因此您可以在 index.html 文件关闭 </body> 标签之前添加一个 <script> 标签,来包括您想要的任意脚本:

<script src="./renderer.js"></script>

renderer.js 中包含的代码接下来可以使用与前端开发相同的 JavaScript API 和工具,例如使用 webpack 打包并最小化您的代码或 React 来管理您的用户界面。

打包
1、添加Electron Forge作为应用程序的开发依赖项,并使用其import命令设置Forge的脚手架:

npm install --save-dev @electron-forge/cli
npx electron-forge import

✔ Checking your system
✔ Initializing Git Repository
✔ Writing modified package.json file
✔ Installing dependencies
✔ Writing modified package.json file
✔ Fixing .gitignore

We have ATTEMPTED to convert your app to be in a format that electron-forge understands.

Thanks for using "electron-forge"!!!

2、使用Forgepackage命令打包

npm run package

$ electron-forge package
✔ Checking your system
✔ Preparing to Package Application for arch: x64
✔ Preparing native dependencies
✔ Packaging Application
Done in 37.24s.

Electron Forge创建包所在的out文件夹:

// Example for Windows
out/
├── ...
└── out/my-electron-app-win32-x64/my-electron-app.exe

完成上述步骤后,您应该有一个功能齐全的应用程序,如下所示:

image.png

注册伪协议

在浏览器调起 electron 应用是基于自定义的伪协议(URL Scheme 协议),URL Scheme 协议可以理解为用于实现在浏览器跳转应用的协议。Windows系统与Mac系统注册伪协议方式不尽相同

Windows

windows 下当注册协议的时候, 会将应用程序的协议写入到 windows 中的注册表中,往注册表的 HKCR (HKEY_CALSSES_ROOT) 目录下添加一条记录来完成该协议的注册。

示例:在注册表的HKEY_CALSSES_ROOT目录下,添加一个electron字段
image.png

image.png

通过这种方法写入的协议, 需要在打包才能正常唤醒, 在开发环境下不能调起本地应用。因为开发环境下调用 app.setAsDefaultProtocolClient 注册协议时应用路径会指向当前项目的node_modules\electron\dist\xxx.exe

image.png

Mac

Mac 上面,我们是要将协议写入到mac 应用中的 info.plist 文件中,每个打包好的app文件中都会存在一个info.plist文件,Info.plist 文件的位置:

image.png

finder 中右键图标, 点击 显示包内容 在出现的 Contents 文件夹中:

image.png

electron-builder配置注册协议

方式一
如果你是用electron-builder打包,可在package.json配置中照如下写入:

image.png

通过 extendInfo 中添加数组, 数组中的 值将会被写入 Info.plist 文件中:

image.png

方式二

"build": {
    "win": {
      "target": [
        "nsis" // 打包成一个独立的 exe 安装程序
      ],
      "signingHashAlgorithms": [
        "sha1",
        "sha256"
      ],
    },
    "nsis": {
      "oneClick": false, // 是否一键安装
      "perMachine": true, // 安装的时候是否为所有用户安装
      "allowToChangeInstallationDirectory": true, // 是否允许用户改变安装目录, 默认为false
      "deleteAppDataOnUninstall": true, // 是否在卸载时删除应用程序数据
      // 指定要包含 nsis 的脚本,基于内置的nsis脚本进一步扩展
      // for win - 将协议写入主机的脚本
      "include": "script/urlProtoco.nsh" 
    },
    "protocols": [ // for macOS - 用于在主机注册指定协议
      {
        "name": "your-protocol-name",
        "schemes": [
          "your-protocol-name"
        ]
      }
    ]
  },

for win - 将协议写入主机的脚本

// urlProtoco.nsh
!macro customInstall
  DetailPrint "Register your-protocol-name URI Handler"
  DeleteRegKey HKCR "your-protocol-name"
  WriteRegStr HKCR "your-protocol-name" "" "URL:your-protocol-name"
  WriteRegStr HKCR "your-protocol-name" "URL Protocol" ""
  WriteRegStr HKCR "your-protocol-name\shell" "" ""
  WriteRegStr HKCR "your-protocol-name\shell\Open" "" ""
  WriteRegStr HKCR "your-protocol-name\shell\Open\command" "" "$INSTDIR\${APP_EXECUTABLE_FILENAME} %1"
!macroend

setAsDefaultProtocolClient

app.setAsDefaultProtocolClient(protocol[, path, args])

  • protocol String - 协议的名称, 不包含 ://。 例如,如果你希望应用处理 electron:// 链接,请以electron 为参数调用此方法。
  • path String (可选) (Windows) - Electron可执行文件路径。 默认为 process.execPath
  • args String (Windows) - 传递给可执行文件的参数。 默认为空数组。
    返回 Boolean - 是否成功调用。

将当前可执行文件的设置为协议(也就是 URI scheme) 的默认处理程序。 该方法允许你将应用更深入地集成到操作系统中。 一旦注册了,所有 your-protocol:// 开头的链接将使用当前可执行文件打开。 整个链接,包括协议部分,将作为参数传递给你的应用程序。

注意:macOS 上,您只能注册已添加到应用程序的 info.plist 中的协议,这个列表在运行时不能修改。 然而,你可以在构建时通过 Electron Forge, Electron Packager, 或通过文本编辑器编辑info.plist文件的方式修改。 有关详细信息,请参阅 Apple's documentation

注意:Windows Store 环境下(当打包为 appx),此 API 对所有调用都返回 true,但它设置的注册表项将无法通过其他应用程序访问。 为了注册你的 Windows Store 应用程序作为默认的协议处理程序,你必须 在你的清单中声明协议。

我们在main.js中加入如下代码:

const PROTOCOL = 'electron';
app.setAsDefaultProtocolClient(PROTOCOL) // 注册协议

网页唤醒electron应用

web 端来唤醒本地Electron应用思路:

(1)安装electron桌面应用,会触发app.setAsDefaultProtocolClient注册协议,例如 protocol
(2)macOS 系统通过监听 open-url 事件获取协议链接;windows 通过监听 second-instance 事件获取协议链接
(3)web 端来唤醒本地Electron应用,主线程获取到唤醒参数后传递给渲染线程

单实例运行

首先,每次打开一个协议 URL,系统都会启动一个新的应用。这就需要应用自己去判断,把 URL 当做参数传给已有的应用,还是自己直接处理。

Electron 提供了一个简单的方法,来获取一个锁,只有第一个调用的实例才能获取成功,后面的其他实例则把参数传过去,然后退出就可以了。

const { app } = require('electron');

// 获取单实例锁
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
  // 如果获取失败,说明已经有实例在运行了,直接退出
  app.quit();
}

Mac系统获取唤醒参数

macOS 系统上面, 通过监听 open-url 方法可以获取到我们打开应用程序上面的链接

app.on('open-url', (event, url) => {
  // ...
});

windows系统获取唤醒参数

window 下通过URL Schemes协议调起应用时分两种情况

  • 应用处于打开状态,会触发 second-instance 事件并接收这个 URL
  • 应用处于未打开状态,在网页端通过浏览器调起应用之后不会触发 second-instance 事件;这个时候需要主动判断应用是否是从网页端调起,并主动触发 second-instance 事件;
window 里面判断是否是从网页端的标准:如果是通过url schema启动,其启动参数会超过1个

以下为在应用 ready 之后,判断是否是在 window 下主动调起客户端,并主动触发 second-instance 事件的实现逻辑;

// 当应用启动完成后,主动判断应用是否是从网页中调起
const _handleAfterReady = () => {
  // windows如果是通过url schema启动则发出时间处理
  // 启动参数超过1个才可能是通过url schema启动
  if (process.argv.length > 1) {
    if (!app.isReady()) {
      app.once("browser-window-created", () => {
        // app 未打开时,通过 open-url打开 app,此时可能还没 ready,需要延迟发送事件
        // 此段ready延迟无法触发 service/app/ open-url 处理,因为saga初始化需要时间
        app.emit("second-instance", null, process.argv);
      });
    } else {
      app.emit("second-instance", null, process.argv);
    }
  }
};

触发 second-instance 事件之后的具体逻辑与 mac 类似,不过针对获取到的参数的处理方式还是存在一些差异

app.on('second-instance', async (event, argv) => {
  // Windows 下通过协议URL启动时,URL会作为参数,所以需要在这个事件里处理
  if (process.platform === 'win32') {
    // ...
  }
});

web唤醒应用

const src = `ksxm://conversation/route/${storeId || ''}?paramsId=${inputParamsId || paramsId}&code=${code}`;
const iframe = document.createElement('iframe');
iframe.src = src;
iframe.style.display = 'none';
document.body.appendChild(iframe);

// 或者
window.location.href = `ksxm://conversation/route/${storeId || ''}?paramsId=${inputParamsId || paramsId}&code=${code}`;

渲染进程获取唤醒参数

唤醒electron应用时可能会携带一些参数,如何在渲染进程中获取这些参数呢?这里利用主线程与渲染线程的通讯方式解决

通过BrowserWindow实例中的webContents向渲染进程发送消息,渲染进程监听获取消息

发送消息

// windows
app.on('second-instance', async (event, argv) => {
  // Windows 下通过协议URL启动时,URL会作为参数,所以需要在这个事件里处理
  if (process.platform === 'win32') {
    /** 向渲染进程传递唤醒参数 */
    win.webContents.send('awaken', { awakenArgs: decodeURI(argv) })
  }
});

// Mac
app.on('open-url', (event, url) => {
  /** 向渲染进程传递唤醒参数 */
  win.webContents.send('awaken', { awakenArgs: decodeURI(url) })
});

收到消息

// 如果是原生js写法,在预加载脚本获取参数,不写在html是因为浏览器require,当然你写构建脚本处理
// preload.js
const { ipcRenderer } = require('electron')

ipcRenderer.on('awaken', (event, params) => {
  console.log('params', params)
})

// 如果是集成React
const { ipcRenderer } = require('electron')

const Demo = () => {
  useEffect(() => {
    ipcRenderer.on('awaken', (event, params) => {
      console.log('params', params)
    })
  })
  
  return <></>
}

image.png

附上项目地址:https://github.com/Revelation...

唤醒electron应用免登录流

假设有这么一种场景,用户A在电商平台上看准了商家B挂的货物,但希望在价格方面跟商家B沟通一下,现不考虑手机、微信以及其他的一些通讯方式,用户A只能通过电商平台提供的通讯桌面应用跟商家B沟通,现在的问题是用户A触发唤醒通讯桌面应用跟商家B沟通时需要登录,这个登录环节特别的多余,使人体验不好。

其实可以这样,用户A在触发唤醒时向网关服务授权层请求授权,拿到临时凭证code,将code作为参数加入到唤醒协议链接中,桌面应用客户端收到code便向服务端请求token,拿到登录状态token后就可以免登录,请求受保护资源

大致流程如下:
image.png

参考文章
electron架构、集成react搭建
注册协议-知乎
注册协议-CSDN


记得要微笑
1.9k 声望4.5k 粉丝

知不足而奋进,望远山而前行,卯足劲,不减热爱。