Electron包管理技术探索

IPC

Webview内主动触发

异步消息通知

// preload.js - 在畫面轉譯處理序中 (網頁)。
const { ipcRenderer } = require('electron')
ipcRenderer.on('need-clean-reply', (event, arg) => {
  console.log(arg) // 印出 "貓咪肚子餓"
})
ipcRenderer.send('take-cat-home-message', '帶小貓回家')

// main.js - 在主處理序裡。
const { ipcMain } = require('electron')
ipcMain.on('take-cat-home-message', (event, arg) => {
  console.log(arg) // prints "帶小貓回家"
  event.reply('need-clean-reply', '貓咪肚子餓')
})

// preload.js - 在畫面轉譯處理序中 (網頁)。
const { ipcRenderer } = require('electron')
ipcRenderer.invoke('take-cat-home-handle', '帶小貓回家')
           // then 回傳前可以做其他事,例如打掃家裡
           .then(msg => console.log(msg)) // prints "小貓肚子餓,喵喵叫"

// main.js - 在主處理序裡。
const { ipcMain } = require('electron')
ipcMain.handle('take-cat-home-handle', async (event, arg) => {
  console.log(arg) // prints "帶小貓回家"
  return '小貓肚子餓,喵喵叫'
})

同步消息通知

// preload.js - 在畫面轉譯處理序中 (網頁)。
const { ipcRenderer } = require('electron')
const message = ipcRenderer.sendSync('take-cat-home-message', '帶小貓回家')
console.log(message) // prints "小貓肚子餓"

// main.js - 在主處理序裡。
const { ipcMain } = require('electron')
ipcMain.on('take-cat-home-message', (event, arg) => {
  console.log(arg) // prints "帶小貓回家"
  // event 回傳前你一直關注著小貓
  event.returnValue = '小貓肚子餓'
})

主线程通知

// main.js - 在主處理序裡。
mainWindow.webContents.send('switch-cat', number);

// preload.js - 在畫面轉譯處理序中 (網頁)。
const { ipcRenderer } = require('electron')
ipcRenderer.on('switch-cat', (event, args) => switchCat(args));

获取webContents的方式

主线程

ipcMain.on('notify:new-msg', (event, chat) => {

    const mainWindow = BrowserWindow.fromWebContents(event.sender); // 利用 event.sender 取得 currentWindow
    const isFocused = mainWindow.isFocused(); // 確認 mainWindow 是否在最上面

    const myNoti = new Notification({
        title: `${chat.name}有新的對話`,
        subtitle: chat.msg
    });

    myNoti.on('click', () => mainWindow.show()); // 將 mainWindow 帶到最上面
    myNoti.on('close', () => mainWindow.show()); // 將 mainWindow 帶到最上面
    myNoti.show();

    if (!isFocused) {

        // 工作列按鈕閃爍
        mainWindow.flashFrame(true);
    }
});

const mainWindow = BrowserWindow.fromWebContents(event.sender); // 利用 event.sender 取得 currentWindow

BrowserWindow.fromId(this.winId).webContents.send('update-badge', this.badgeCount);

渲染线程

// preload.js  https://www.npmjs.com/package/@electron/remote
import { remote } from "electron"
remote.getCurrentWindow()
remote.getCurrentWebContents()

Clipboard

// @/utils/clipboardUtils.js
const read = clipboard => {

    const aFormats = clipboard.availableFormats();

    const isImageFormat = aFormats.find(f => f.includes('image'));
    const isHtmlFormat = aFormats.find(f => f.includes('text/html'));
    const isTextFormat = aFormats.find(f => f.includes('text/plain'));
    const isRtfFormat = aFormats.find(f => f.includes('text/rtf'));

    if (isImageFormat) {

        const nativeImage = clipboard.readImage(); // 取得 clipboard 中的圖片
        return nativeImage.toDataURL(); // data:image/png;

    }

    // 取得 clipboard 中的文字
    else if (isTextFormat) return clipboard.readText(); 

    // 取得 clipboard 中的 html 文字
    else if (isHtmlFormat) return clipboard.readHTML(); 

    // 取得 clipboard 中的 rtf 文字
    else if (isRtfFormat) return clipboard.readRTF(); 
    else return null;
}

module.exports = {
    read,
}

下载

Electron内置模块实现

在 electron 中的下载行为,都会触发 session 的 will-download 事件。在该事件里面可以获取到 downloadItem 对象,通过 downloadItem 对象实现一个简单的文件下载管理器

拿到 downloadItem 后,暂停、恢复和取消分别调用 pauseresumecancel 方法。当我们要删除列表中正在下载的项,需要先调用 cancel 方法取消下载。

downloadItem 中监听 updated 事件,可以实时获取到已下载的字节数据,来计算下载进度和每秒下载的速度。

// 计算下载进度

const progress = item.getReceivedBytes() / item.getTotalBytes()
// 下载速度:
已接收字节数 / 耗时

Notes

  • Electron自身的下载模块忽略headers信息,无法满足断点续下载,需要调研request模块自身实现下载。

Request模块实现

https://github.com/request/request#requestoptions-callback Request模块请求配置

import request, { Headers } from "request";
interface DownLoadFileConfiguration {
  remoteUrl: string;
  localDir: string;
  name: string;
  onStart: (headers: Headers) => void;
  onProgress: (received: number, total: number) => void;
  onSuccess: (filePath: string) => void;
  onFailed: () => void;
}
function downloadFile(configuration: DownLoadFileConfiguration) {
  let received_bytes = 0;
  let total_bytes = 0;
  const {
    remoteUrl,
    localDir,
    name,
    onStart = noop,
    onFailed = noop,
    onSuccess = noop,
    onProgress = noop,
  } = configuration;
  const req = request({
    method: "GET",
    uri: remoteUrl,
    headers: {
      "Content-Type": "application/octet-stream",
      "Cache-Control": "no-cache",
      Connection: "keep-alive",
      Pragma: "no-cache",
      Range: `bytes=${0}-`,
    },
  });
  function abort(this: any, filepath: string) {
    this.abort();
    // 文件操作https://ourcodeworld.com/articles/read/106/how-to-choose-read-save-delete-or-create-a-file-with-electron-framework
    removeFile(filepath);
  }
  const absolutePath = path.resolve(localDir, name);
  const out = fs.createWriteStream(absolutePath);
  req.pipe(out);

  req.on("response", function (data) {
    total_bytes = parseInt(data.headers["content-length"] || "");
    const id = uuidv4(); // ⇨ '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d'
    onStart(id, data.headers);
  });

  if (Object.prototype.hasOwnProperty.call(configuration, "onProgress")) {
    req.on("data", function (chunk) {
      received_bytes += chunk.length;

      onProgress(received_bytes, total_bytes);
    });
  } else {
    req.on("data", function (chunk) {
      received_bytes += chunk.length;
    });
  }

  req.on("end", function () {
    onSuccess(absolutePath);
  });
  req.on("error", function (err) {
    onFailed();
  });
  return {
    abort: abort.bind(req, absolutePath),
    pause: req.pause.bind(req),
    resume: req.resume.bind(req),
  }
}

Notes:

  • Mac环境下,针对大文件,该包默认只能下载8s左右,猜测是timeout超时时间问题,使用keep-alive: true就没有问题
  • request模块的pauseresumeabort方法在文档中没有声明,分别对应暂停、继续、取消操作。

断点续下载

前置条件

  • 需要服务端支持

Accept-Ranges:bytes可标识当前资源是支持范围请求的。

下载的细节处理

  1. 请求Headers加入Range
fetch(
  "http://nginx.org/download/nginx-1.15.8.zip", 
  { 
      method: "GET", //请求方式 
      mode: 'cors', 
      headers: { //请求头 
        "Cache-Control": "no-cache", 
         Connection: "keep-alive", 
         Pragma: "no-cache", 
         Range: "bytes=0-1"  // Range设置https://www.cnblogs.com/nextl/p/13885225.html
      } 
   }
)

  1. 文件合并
  • fs.createWriteStream创建文件写入流
    • flags:如果是追加文件而不是覆盖它,则 flags 模式需为 a 模式而不是默认的 w 模式。
  const absolutePath = path.resolve(localDir, name);
  const out = fs.createWriteStream(absolutePath, {
    flags: resume ? "a" : "w",
  });
  req.pipe(out);

https://www.jianshu.com/p/934d3e8d371e

分片并发下载

安装第三方应用

exe安装包不像msi安装包有安装规范,且安装后会自动启动应用程序,而不是安装、启动分为两步。

https://learn.microsoft.com/en-us/troubleshoot/windows-client/deployment/command-switches-supported-by-self-extractor-package

https://www.pdq.com/blog/install-silent-finding-silent-parameters/

"Notion Setup 2.0.34.exe" -s /S

Notes: -s /S是防止安装完,自动启动应用程序。

获取第三方应用启动路径

通过查找注册表实现

function getExecutablePath(packageName: string): Promise<string> {
  return new Promise((resolve, reject) => {
    exec(
      `REG QUERY HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Run /s /f ${packageName}`,
      function (err, stdout: string, _stderr) {
        if (err) {
          reject("failed");
        } else {
          const executablePath = stdout
            .split(" ")
            .filter((item) => item.trim())[3];
          resolve(executablePath);
        }
      }
    );
  });
}

exec vs. spawn

启动三方应用

本质是通过子进程,调用不同系统环境的命令行唤起应用。

// /src/classes/App.cls.ts
import { spawn } from "child_process";
class App {
  process: any;
  pid: number;
  constructor(process: any) {
    this.process = process;
    this.pid = process.pid;
  }
  private killApp(pid: number) {
    // 检测程序是否启动
    // wmic process where caption=”XXXX.exe” get caption,commandline /value
    // 根据进程名杀死进程
    // taskkill /F /IM XXX.exe
    spawn("cmd", ["/c", "taskkill", "/pid", "/IM", pid.toString()], {
      shell: true, // 隐式调用cmd
      stdio: "inherit",
    });
  }
  on(eventType: "error" | "close" | "exit", listener: (...args: any) => void) {
    this.process.on(eventType, (code: number) => {
      console.log(`spawn error: ${code}`);
      listener(code);
    });
  }
  close() {
    this.killApp(this.pid);
  }
}
export default App;

// /src/preload/index.ts
import { spawn } from "child_process";
import App from "../classes/App.cls";
function openApp(path: string): any {
  const childProcess = spawn("cmd", ["/c", "start", path], {
    shell: true, // 隐式调用cmd
    stdio: "inherit",
  });
  console.log("--------childProcess------", childProcess);
  const app = new App(childProcess);
  /**
  * Notes: 这里没有返回app,而是重新组装了对象 —— preload中无法返回复杂类型
  * https://stackoverflow.com/questions/69203193/i-passed-a-class-to-my-front-end-project-through-electrons-preload-but-i-could
  * https://www.electronjs.org/docs/latest/api/context-bridge#parameter--error--return-type-support
  */
  return {
    on: app.on.bind(app),
    close: app.close.bind(app)
  };
}
contextBridge.exposeInMainWorld("JSBridge", {
  openApp,
});

// index.vue
const app = window.JSBridge.openApp(absolutePath);
app.on("error", (code: number) => {
  console.log("---------spawn error----------", code, absolutePath);
});
app.on("close", (code: number) => {
  console.log("---------spawn close----------", code, absolutePath);
});
app.on("exit", (code: number) => {
  console.log("---------spawn exit----------", code, absolutePath);
});
setTimeout(() => {
  app.close();
}, 10000);

window命令cmd

Window命令行安装软件 —— winget需要安装

打包

通用方案

electron打包工具有两个:electron-builderelectron-packager,官方还提到electron-forge,其实它不是一个打包工具,而是一个类似于cli的工具集,目的是简化开发到打包的一整套流程,内部打包工具依然是electron-packager

electron-builderelectron-packager相比各有优劣,electron-builder配置项较多,更加灵活,打包体积相对较小,同时上手难度大;而electron-packger配置简单易上手,但是打出来的应用程序包体积相对较大。

打包优化分为打包时间优化和体积优化

双package.json结构

增加打包配置:安装路径选择,icon等信息

平台

在 Windows 平台上可以打包成 NSIS 應用與 Portal 應用兩種類型:

类型 功能特点 更新方式
Portal 绿色程序,启动exe即可使用 下載新執行檔案 , 關閉並刪除舊執行檔案 => 更新完成
NSIS 安装程序,安装后才能使用 下載新安裝檔 , 安裝後重開應用程式 => 更新完成
  • Portal 版本
    如果你是 Portal 版的程式 , 只要下載新的 exe , 並覆蓋掉舊的應用程式 , 就算完成更新了 !

  • NSIS 版本
    當你用 electron-builder 生成一個 windows 安裝檔時 , 那個安裝檔就是 NSIS 版本

使用 electron-builder 產出的 NSIS 安裝檔 , 他具體的安裝步驟如下

  • 執行安裝檔

  • 安裝檔比對 Windows 內有沒有 appId 相同的 Electron 程式

  • 如果有 appId 相同的 Electron 程式 , 執行 old-uninstaller.exe 將舊版解安裝

  • 安裝目前版本的 Electron 程式 , 並將其開啟

因此如果你要更新應用程式 , 你只要拿到新版的 NSIS 安裝檔並執行它 , 安裝完成後你就可以享用更新後的應用程式了 !

https://ithelp.ithome.com.tw/articles/10254919

Q&A

  • Q:dialog模块undefined
    • A:import { remote } from "electron";通过remote.dialog调用
  • Q:An Object could not be cloned?
    • A:检测IPC通信中是否包含响应式数据
  • Q:下载过程中,定义对象,可以持续更新么?
    • A:尝试过,可以。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,236评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,867评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,715评论 0 340
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,899评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,895评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,733评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,085评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,722评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,025评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,696评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,816评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,447评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,057评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,009评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,254评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,204评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,561评论 2 343

推荐阅读更多精彩内容