electron踩坑系列之一

發表於2021-04-06

前言

以electron作為基礎框架,已經開發兩個專案了。第一個專案,我主要負責用react寫頁面,第二專案既負責electron部分+UI部分。

做專案,就是踩坑, 一路做專案,一路踩坑,坑多不可怕,就怕忘記坑。

坑前準備

專案模板

開發,當然就需要搭建專案,搭建專案github上有不少模板。

你可以去 awesome-electronboilerplates部分看到比較流行的模板。
比如:
electron-react-boilerplate
electron-vue
electron-quick-start
electron-boilerplate

用模板相當於上高速,嗖嗖的飛起,不錯的選擇。
這些模板基本都是把靜態頁面和electron部分的開發,整合到一個專案裡面,有利有弊。

我們專案採用的是分離式的:
electron部分: 負責提供能力,比如讀寫檔案,操作登錄檔,啟動和掛關閉第三方程式,網路攔截,托盤等
UI部分: 繪製頁面,必要的時候呼叫electron封裝的能力。

到這裡, electron部分與UI部分的互動,我們開啟窗體的時候,
nodeIntegration是設定為false的,所有的通訊都是通過一個所謂的bridge來連線的。

bridge通過preload的屬性注入,起到了一定的隔離。

我們專案均採用TS開發
前後端兩個專案都是基於TS開發,好處不用說。
問題在於,如何將bridge部分友好的提供給UI部分。
也很簡單,typescript 編譯的時候,其實有一個declaration的選項。
基於bridge單獨起一個配置檔案,裡面僅僅include需要的檔案,執行build的,再拷貝到UI專案裡面,UI專案就能得到友好的提示。

主程式和渲染程式的通訊

electron-better-ipc 是不錯的選擇,原理就是利用EventEmitter的once特性,內部的每次通訊都是一個新的事件型別。
對於超時和錯誤捕捉,主向多個渲染程式發訊息,都還得自己去增強。

我們是自己維護了一個呼叫資訊, 傳送的資料有一個key來標識,目前看來,還算穩定。

日誌

electron-log是不錯的選擇,簡簡單單的就能記錄主程式和渲染程式的日誌。
但是不能記錄node喚起的子程式的日誌,真要想記錄,子程式單獨通知到外面,外面記錄。或者單獨自己弄一個寫子程式的日誌也沒問題的。

日誌多了肯定不行,就會有日誌輪轉。目前這個庫,好像有一個簡單粗暴的輪轉策略,肯定是不夠用的。

具體的可以到File transport

function archiveLog(file) {
  file = file.toString();
  const info = path.parse(file);

  try {
    fs.renameSync(file, path.join(info.dir, info.name + '.old' + info.ext));
  } catch (e) {
    console.warn('Could not rotate log', e);
  }
}

我這裡就貼一段基本可用的, deleteFiles自定去實現,你可以保留幾個檔案,保留幾天的檔案,都是可以的。

// 50M
log.transports.file.maxSize = 50 * 1024 * 1024;

log.transports.file.archiveLog = function archiveLog(file: string) {
    file = file.toString();
    const info = path.parse(file);

    try {
        // 重新命名
        const dateStr = getDateStr();

        const newFileName = path.join(info.dir, `${info.name}.${dateStr}${info.ext}`);

        console.log("archiveLog", file, newFileName);

        fs.renameSync(file, newFileName);

        // 刪除舊檔案
        deleteFiles(info.dir)
    } catch (e) {
        console.error('Could not rotate log', e);
    }
}

持久化的資料

electron-store 很不錯。

我們採用的是記憶體資料 + config.json的模式,實際上本質沒變。
如果是窗體之間的頁面之間要共享資料,其實用localStorage和sessionStorage,indexedDB都是不錯的選擇。

入坑和填坑

應用啟動頁面空白

簡單的排查順序
1.瀏覽器開啟網頁地址
2.如果不能開啟, 檢視網路是否正常
3.網路正常,ping域名
4.ping 不通, 說明域名有問題
5.開啟 %appdata%, 檢視日誌是不是有開啟頁面超時的提示
6.關閉 360安全衛士,重新開啟主播端

最後一條是關鍵哈。
不簽名,不過百的情況下,升級啟動都很可能被360攔截。

原生模組.node結尾的檔案引用報錯

build時指定electron版本,headers標頭檔案路徑
https://www.electronjs.org/docs/tutorial/using-native-node-modules

這個官方是有很明確的指出來的

開發時正常,打包後提示,cannot find moudule "xxxx"

使用npm 安裝,不用cnpm。
這是和不同的安裝方式,檔案目錄結構不一致。
當然開發的時候,你是可以使用cnpm或者yarn的。
我沒有嘗試使用打包做一些修改讓cnpm有效,你們要是知道,可以留言告訴我哈。

the specified module could not be found. "................\xxxx.node"

這個和上面那個有點類似,但是又不一樣的。 下面這種情況可能是.node模組需要引用一些dll檔案,卻沒有。比如常見的msvcr120.dll,msvcr110.dll,msvcp120.dll,msvcp110.dll這些檔案,你把這些放到.node的同級目錄。至於怎麼區分32位和64位,哈哈,我知道您懂。

The specified module could not be found.

AppData\Local\Temp\xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.tmp.node

https://stackoverflow.com/questions/41253450/error-the-specified-module-could-not-be-found
有提到大致的原生就是 .node缺少一些dll庫。可以使用 http://www.dependencywalker.com/ 檢查缺少啥

我做了四種嘗試:

  • 安裝 visual-c 元件 failed
  • 複製dll到asar同目錄 failed
  • electron-builder 設定asar為false ok
  • C++模組資料夾移動asar包體外 ok

後兩種是有效的,而且這個問題可能只出現在某些機器上。某些機器是正確的,某些機器卻不正確。

electron-builder 打包後解除安裝或者更改程式中的釋出者名稱問題

首先說這個釋出者是哪個欄位,其實是package.json裡面的author欄位的name屬性,當然可以有多種寫法。

如果的你名字類似 牛潮(北京)有限公司,那麼你打包出來就只會剩下 牛潮兩個字,是不是有一些神奇。

這個其實是 electron-builder庫packages\app-builder-lib\src\util\normalizePackageData.ts 或者額是 normalize-package-data庫做了一些操作。
unParsePerson之後再parsePerson,英文括弧後面的東西就沒了。怎麼辦,我現在是手動找到修改一下。

  function fixPeople(data: any) {
    modifyPeople(data, unParsePerson)
    modifyPeople(data, parsePerson)
  },

function unParsePerson (person) {
  if (typeof person === "string") return person
  var name = person.name || ""
  var u = person.url || person.web
  var url = u ? (" ("+u+")") : ""
  var e = person.email || person.mail
  var email = e ? (" <"+e+">") : ""
  return name+email+url
}

function parsePerson (person) {
  console.log("parsePerson person", person)
  if (typeof person !== "string") return person
  var name = person.match(/^([^\(<]+)/)
  var url = person.match(/\(([^\)]+)\)/)
  var email = person.match(/<([^>]+)>/)
  var obj = {}
  if (name && name[0].trim()) obj.name = name[0].trim()
  if (email) obj.email = email[1];
  if (url) obj.url = url[1];
  return obj
}

electron-builder打包簽名問題

他這個打包可以簽名,但是要你提供.pfx檔案和密碼。
這就有點老火了,公司的這兩個東西一般不會輕易給你。
要麼你們有統一的打包中心,那就沒問題。
可惜我們公司只有統一的簽名中心,簽名之後,exe檔案是有變化的,比如大小和最後修改時間,這就會導致更新的時候,檢查hash值會失敗。

問題不大:

  1. 我們先到electron-buider的原始碼裡面packages\app-builder-lib\src\util\hash.ts找到hash值生成的方法,這個hash值會被寫入latest.yml檔案裡面
  2. 本地打包好exe
  3. 去簽名中心簽名,並覆蓋本地的exe
  4. 用hash.ts相同的方式再計算得到hash值
  5. 回寫這些值到latest.yml檔案

基本就是這樣啦。

這裡送一段基本能用的程式碼吧:


import { createHash } from "crypto"
import { createReadStream, statSync , readFileSync, writeFileSync } from "fs"
import path from "path";
import YAML from "yaml";

const jsonConfig = require("../package.json");
const version = jsonConfig.version;


const exeFilePath = path.join(__dirname, `../packages/${version}.exe`);
const yamlFilePath = path.join(__dirname, "../packages/latest.yml");
const jsFilePath = path.join(__dirname, "../packages/latest.js");

const bakYamlFilePath =  path.join(__dirname, `../packages/latest.bak.yml`);

// packages\app-builder-lib\src\util\hash.ts
function hashFile(file: string, algorithm = "sha512", encoding: "base64" | "hex" = "base64", options?: any): Promise<string> {
    return new Promise<string>((resolve, reject) => {
      const hash = createHash(algorithm)
      hash.on("error", reject).setEncoding(encoding)
  
      createReadStream(file, { ...options, highWaterMark: 1024 * 1024 /* better to use more memory but hash faster */ })
        .on("error", reject)
        .on("end", () => {
          hash.end()
          resolve(hash.read() as string)
        })
        .pipe(hash, { end: false })
    })
}

function getFileSize(path: string){
    const state = statSync(path);
    return state.size;
}


function getYAML(path: string){
  const file = readFileSync(path, 'utf8')
  const data = YAML.parse(file);
  return data;
}

function saveYAML(path:string, content: any){
  writeFileSync(path,  YAML.stringify(content))
}

function saveJS(path:string, content: string){
  writeFileSync(path,  content)
}


;(async function updateLatestYML(){
  try{
    const hash = await hashFile(exeFilePath);
    const size = getFileSize(exeFilePath);

    console.log("hash:", hash);
    console.log("size:", size);

    const yamlData = getYAML(yamlFilePath);
    saveYAML(bakYamlFilePath, yamlData);
    console.log("yamlData:before", yamlData);


    const name = `${version}.exe`

    yamlData.version = version;
    const file = yamlData.files[0];
    file.url = name;
    file.sha512 = hash;
    file.size = size;

    yamlData.path = name;
    yamlData.sha512 = hash;

    console.log("yamlData:after", yamlData);
    saveYAML(yamlFilePath, yamlData);

    saveJS(jsFilePath, `window._lastest=${JSON.stringify(yamlData)}`);

  }catch(err){
    console.log("update latest.yml 失敗", err);
  }
})();

本地測試electron-updater的問題

https://www.electron.build/auto-update#debugging
https://stackoverflow.com/questions/51003995/how-can-i-test-electron-builder-auto-update-flow
https://github.com/electron-userland/electron-builder/issues/3053#issuecomment-401001573
雖然官方建議單獨使用一個啥server來著,總讓人覺得有點複雜,其實還是可以直接在開發中測試的。

  1. 複製 resources\app-update.yml,重名命令dev-app-update.yml
  2. build時複製到dist目錄

程式碼層面做一點小修改

function checkUpdate() {
    // 開發環境
    if(!app.isPackaged){
        return autoUpdater.checkForUpdates();
    }
    return autoUpdater.checkForUpdatesAndNotify();
}

這裡還要額外注意

  1. 版本檢查的是electron的版本,所以latest.yml的版本要大
  2. 快取的預設目錄為 %localappdata%/{預設是package.json裡面name}-updater/pending,和electron-builder裡面的是指有關

404返回的頁面不會觸發did-fail-load

你想想,我們很多時候開啟的是遠端的網頁地址。 如果返回的狀態碼是404,明顯不是我們想要的,如果我們依據did-fail-load事件去判斷,是否載入失敗,當然是不滿足我們的需求的。

我們需要在遠端載入失敗的時候,使用本地的預設頁面去給使用者一些提示資訊,以及可以關閉窗體。

這個時候就要did-frame-navigate出場,根據httpResponseCode的狀態去使用本地備用頁面了。

 win.webContents.on("did-frame-navigate",  (event: Electron.Event, url: string, httpResponseCode: number, httpStatusText: string, isMainFrame: boolean, frameProcessId: number, frameRoutingId: number) =>{
            console.log("did-frame-navigate", httpResponseCode, httpStatusText);
            if(httpResponseCode >= 400 && !this.isUsedFailedPage){
               return this.useFailedPage();
            }
        });

頁面ctrl + r會被重新整理等

ctrl + r 重新整理頁面
ctrl + shift + i 開啟開發者工具

哈哈,要是不小心被使用者開啟是不是有點搞笑了。

你可以寫一個配置到配置檔案,預設不允許開啟,當需要去現場排查問題的時候,可以開啟。 被開啟肯定有危險,但是方便除錯。

我這裡做的頁面攔截

const BLACK_LIST = ["KeyI", "KeyR"];
function preventForceRefreshAndTool() {
    const env = process.env.ENV;
    console.log("process.env.ENV", process.env.ENV);
    if (env !== "prod") {
        return console.log("非產線環境,允許重新整理和開啟開發者工具");
    }

    window.addEventListener('keydown', (e: KeyboardEvent) => {
        const { ctrlKey, code, shiftKey } = e;

        console.log("keydown", e);
        // ctrl +  r
        // crtl + shift + i
        if (ctrlKey && shiftKey && BLACK_LIST.includes(code)) {
            console.log("globalShortcut:被阻止", ctrlKey, shiftKey, code);
            e.preventDefault();
        }
    
    }, false);
}

electron攔截

      win.webContents.on('before-input-event', (event, input) => {
          console.log('before-input-event', input.control, input.shift, input.key)
          if (input.control && KEY_BLACK_LIST.includes(input.key.toUpperCase())) {
            event.preventDefault()
          }
     });

結語

到這裡,老闆已經走到我的身後,我默默的說,我總結一下問題。
老闆說: 好樣子的,不過你為嘛在上班時間寫部落格。
我: 流汗,各位,拜。

其他

排查問題

Electron 常見問題
electron issues

其他工具類

electron-util 工具類
electron-router