ElectronForge打包、簽名把将、自動更新

Electron Forge由官方推薦和維護轻专,結構優(yōu)美,原本以為用起來會很順利察蹲,沒想到還是有坑请垛。

打包方案:

  • MAC: @electron-forge/maker-dmg DMG 包供用戶安裝
  • WINDOWS:
    • @electron-forge/maker-squirrel 框架默認,但它安裝時不能選安裝路徑
    • @electron-forge/maker-wix 官方推薦之一洽议,打出 MSI 鏡像包宗收。最大的坑就在這里,它的自動更新幾乎不可用绞铃,issues 也沒人回復镜雨,白白花費了很多時間
    • @felixrieseberg/electron-forge-maker-nsis 最后還是換回了electron-builder的 NSIS 方案

以下記錄使用的詳細配置

// forge.config.ts
module.exports = {
  packagerConfig: {
    name: 'APP_NAME',
    // 不加擴展名,MAC 會自動查找 .icns儿捧、WIN 使用 .ico
    icon: './icon/icon',
    // 最終包不使用的代碼,不要打入 asar
    ignore: [/\.yarn/, /src\/render/],
    appBundleId: `com.xxx.xxx`,
    appCopyright: `Copyright ? 2023 ${packageJson.author}`
  },
  ...

packageJson 中的 dependencies 引用也會被打入 asar 中挑宠,非主進程使用的包不要放入 dependencies 可以有效減小包大小

@electron-forge/maker-dmg

// forge.config.ts
const RELEASE_APP_DIR = path.join(__dirname, `./out/${APP_NAME}-${process.platform}-${ARCH}/${APP_NAME}.app`)

  makers: [
    {
      name: '@electron-forge/maker-dmg',
      config: {
        icon: './icon/icon.icns',
        background: './icon/background.png',
        format: 'ULFO',
        contents: [
          { x: 192, y: 244, type: 'file', path: RELEASE_APP_DIR },
          { x: 448, y: 244, type: 'link', path: '/Applications' },
          { x: 192, y: 700, type: 'position', path: '.background' },
          { x: 292, y: 700, type: 'position', path: '.VolumeIcon.icns' },
          { x: 392, y: 700, type: 'position', path: '.DS_Store' },
          { x: 492, y: 700, type: 'position', path: '.Trashes' }
        ]
      }
    },

@electron-forge/maker-squirrel for exe

// forge.config.ts
  makers: [
    {
      name: '@electron-forge/maker-squirrel',
      config: {
        authors: packageJson.author,
        description: packageJson.description,
        copyright: packageJson.author,
        iconUrl: 'https://www.xxxx.com/favicon.ico', // http url only
        setupIcon: path.join(__dirname, `icon/icon.ico`),
        skipUpdateIcon: true
        // certificateFile: './cert.pfx',
        // certificatePassword: process.env.CERTIFICATE_PASSWORD
      }
    },

@electron-forge/maker-wix for msi

// forge.config.ts
  makers: [
    {
      name: '@electron-forge/maker-wix',
      config: {
        language: '2052" Codepage="utf-8',
        cultures: 'zh-CN,en-US',
        icon: path.join(__dirname, `./icon/icon.ico`),
        shortName: 'APP_EN_NAME',
        manufacturer: packageJson.author,
        appUserModelId: `com.xxx.xxx`,
        upgradeCode: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
        features: {
          autoUpdate: true,  // 無法使用
          autoLaunch: false
        },
        ui: {
          template: wixUiTemplate
        }
      }
const wixUiTemplate = wixUiTemplate = `    <UI Id="UserInterface">
      <Property Id="WixUI_Mode" Value="InstallDir" />

      <TextStyle Id="WixUI_Font_Normal" FaceName="Tahoma" Size="8" />
      <TextStyle Id="WixUI_Font_Bigger" FaceName="Tahoma" Size="12" />
      <TextStyle Id="WixUI_Font_Title" FaceName="Tahoma" Size="9" Bold="yes" />

      <Property Id="DefaultUIFont" Value="WixUI_Font_Normal" />

      <DialogRef Id="ErrorDlg" />
      <DialogRef Id="FatalError" />
      <DialogRef Id="FilesInUse" />
      <DialogRef Id="MsiRMFilesInUse" />
      <DialogRef Id="PrepareDlg" />
      <DialogRef Id="ProgressDlg" />
      <DialogRef Id="ResumeDlg" />
      <DialogRef Id="UserExit" />

      <Publish Dialog="ExitDialog" Control="Finish" Event="EndDialog" Value="Return" Order="999">1</Publish>

      <Publish Dialog="WelcomeDlg" Control="Next" Event="NewDialog" Value="InstallDirDlg">NOT Installed</Publish>
      <Publish Dialog="WelcomeDlg" Control="Next" Event="NewDialog" Value="VerifyReadyDlg">Installed AND PATCH</Publish>

      <Publish Dialog="InstallDirDlg" Control="Back" Event="NewDialog" Value="WelcomeDlg">1</Publish>
      <Publish Dialog="InstallDirDlg" Control="Next" Event="SetTargetPath" Value="[WIXUI_INSTALLDIR]" Order="1">1</Publish>
      <Publish Dialog="InstallDirDlg" Control="Next" Event="DoAction" Value="WixUIValidatePath" Order="2">NOT WIXUI_DONTVALIDATEPATH</Publish>
      <Publish Dialog="InstallDirDlg" Control="Next" Event="SpawnDialog" Value="InvalidDirDlg" Order="3"><![CDATA[NOT WIXUI_DONTVALIDATEPATH AND WIXUI_INSTALLDIR_VALID<>"1"]]></Publish>
      <Publish Dialog="InstallDirDlg" Control="Next" Event="NewDialog" Value="VerifyReadyDlg" Order="4">WIXUI_DONTVALIDATEPATH OR WIXUI_INSTALLDIR_VALID="1"</Publish>
      <Publish Dialog="InstallDirDlg" Control="ChangeFolder" Property="_BrowseProperty" Value="[WIXUI_INSTALLDIR]" Order="1">1</Publish>
      <Publish Dialog="InstallDirDlg" Control="ChangeFolder" Event="SpawnDialog" Value="BrowseDlg" Order="2">1</Publish>

      <Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="InstallDirDlg" Order="1">NOT Installed</Publish>
      <Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="MaintenanceTypeDlg" Order="2">Installed AND NOT PATCH</Publish>
      <Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="WelcomeDlg" Order="2">Installed AND PATCH</Publish>

      <Publish Dialog="MaintenanceWelcomeDlg" Control="Next" Event="NewDialog" Value="MaintenanceTypeDlg">1</Publish>

      <Publish Dialog="MaintenanceTypeDlg" Control="RepairButton" Event="NewDialog" Value="VerifyReadyDlg">1</Publish>
      <Publish Dialog="MaintenanceTypeDlg" Control="RemoveButton" Event="NewDialog" Value="VerifyReadyDlg">1</Publish>
      <Publish Dialog="MaintenanceTypeDlg" Control="Back" Event="NewDialog" Value="MaintenanceWelcomeDlg">1</Publish>

    </UI>
    <Property Id="WIXUI_INSTALLDIR" Value="APPLICATIONROOTDIRECTORY" />
    <UIRef Id="WixUI_Common" />`

@felixrieseberg/electron-forge-maker-nsis for exe

// forge.config.ts
  makers: [
    {
      name: '@felixrieseberg/electron-forge-maker-nsis',
      config: {
        // codesigning: {
        //   certificateFile?: string;
        //   certificatePassword?: string;
        // },
        updater: {}
      }
// package.json
  "build": {
    "appId": "com.xxx.xxx",
    "productName": "應用名稱",
    "nsis": {
      "oneClick": false,
      "allowElevation": true,
      "createDesktopShortcut": true,
      "createStartMenuShortcut": true,
      "allowToChangeInstallationDirectory": true,
      "perMachine": true,
      "deleteAppDataOnUninstall": true,
      "installerIcon": "icon/icon.ico",
      "installerHeaderIcon": "icon/icon.ico",
      "guid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
    },
    "publish": {
      "provider": "generic",
      "url": "",
      "channel": "latest"
    }
  },

MAC 簽名與公證

坑 2:根據官網配置菲盾,始終無法成功公證,最后使用codesignxcrun notarytool指令手動簽名與公證

// forge.config.ts
  hooks: {
    preMake: async () => {
      if (process.platform == 'darwin') {
        await makeMacProfile()
        await signMac('/path/to/xxx.app', 'APP_NAME')
      }
    },
    postMake: async () => {
      if (process.platform == 'darwin') {
        await notarizeMac('/path/to/xxx.dmg')
      }
    }
  }

簽名

將證書存入鑰匙串參考此文 “代碼簽名”部分 1各淀、2

const NEED_SIGN_FW: string[] = [
  'Contents/Frameworks/Electron" "Framework.framework/Versions/A/Libraries/libEGL.dylib',
  'Contents/Frameworks/Electron" "Framework.framework/Versions/A/Libraries/libffmpeg.dylib',
  'Contents/Frameworks/Electron" "Framework.framework/Versions/A/Libraries/libGLESv2.dylib',
  'Contents/Frameworks/Electron" "Framework.framework/Versions/A/Libraries/libvk_swiftshader.dylib',
  'Contents/Frameworks/Squirrel.framework/Versions/A/Resources/ShipIt'
]
const ETM_DIR = path.join(__dirname, `./entitlements.mac.plist`)

function signMac(appPath: string, APP_NAME: string) {
  const caName = 'Developer ID Application: CompanyName (xxxxxxxx)'
  const needSignDirs: string[] = [appPath]
  NEED_SIGN_FW.forEach((subdir) => {
    needSignDirs.push(`${appPath}/${subdir}`)
  })

  return execPromise(`security find-identity -v -p codesigning`)
    .then(({ stdout, stderr }) => {
      return stdout.includes(caName)
    })
    .then((hasCertificate) => {
      if (!hasCertificate) throw new Error('鑰匙串中沒有需要的證書')
      return execPromise(`xattr -cr ${appPath}`)
    })
    .then(() => {
      const signList: Promise<{ stdout: string; stderr: string }>[] = []
      needSignDirs.forEach((dir) => {
        signList.push(
          execPromise(
            `codesign --force --deep --timestamp --options runtime --entitlements ${ETM_DIR} --sign "${caName}" --verbose=2 -v ${dir}`
          )
        )
      })
      return Promise.all(signList)
    })
    .then((resList) => {
      console.info('【簽名完成】', resList)
      return resList
    })
  // 查看應用的簽名: codesign -d -v -r - /path/to/xxx.app
}

function execPromise(command: string): Promise<{ stdout: string; stderr: string }> {
  return new Promise((resolve, reject) => {
    exec(command, (err, stdout, stderr) => {
      if (err) return reject(err)
      else resolve({ stdout, stderr })
    })
  })
}
<!-- entitlements.mac.plist -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
   <dict>
    <key>com.apple.security.cs.allow-unsigned-executable-memory</key>
    <true/>
    <key>com.apple.security.cs.allow-jit</key>
    <true/>
    <key>com.apple.security.cs.disable-library-validation</key>
    <true/>
  </dict>
</plist>

公證

export function makeMacProfile() {
  const conf = `--apple-id "${process.env.NOTARY_APP_ID}" --team-id "${process.env.NOTARY_TEAM_ID}" --password "${process.env.NOTARY_PASSWORD}"`
  return execPromise(`xcrun notarytool store-credentials HAHAHA ${conf}`)
}

function notarizeMac(dmgPath: string) {
  console.info(`【開始公證】: ${dmgPath}`)

  return execPromise(`xcrun notarytool submit ${dmgPath} --keychain-profile "HAHAHA" --wait`).then(
    ({ stdout, stderr }) => {
      const res = stdout || stderr
      if (res.includes('Invalid')) {
        console.info('【公證失敗】', res)
        // @ts-ignore
        return logNotarytoolErr(res.match(/id: .{36}/)[0]?.substr(4))
      }
      console.info('【公證完成】', stdout, stderr)
      return res
    }
  )
  // xcrun notarytool log "96e8072f-4a0c-443d-b2c3-076b39376817" --keychain-profile "OneApps"
}

function logNotarytoolErr(id: string) {
  return execPromise(`xcrun notarytool log "${id}" --keychain-profile "OneApps"`).then(({ stdout, stderr }) => {
    const res = stdout || stderr
    console.info('【公證失敗原因】', res)
    return Promise.reject(res)
  })
}

自動更新

MAC 自更新依賴@electron-forge/maker-zip

// forge.config.ts
  makers: [
    {
      name: '@electron-forge/maker-zip',
      platforms: ['darwin'],
      config: {
        macUpdateManifestBaseUrl: 'https://XX.oss.aliyuncs.com/apps',
        macUpdateReleaseNotes: '添加了自動更新功能'
      }
    }

打包后出現RELEASES.json懒鉴、XXXX-${platform}-${arch}-${version}.zip,后續(xù)自動更新依賴此文件。DMG 包供第一次安裝使用临谱。

import { app, autoUpdater, dialog } from 'electron'

autoUpdater.on('error', (message) => {
  console.error('自動更新', message)
})
autoUpdater.on('update-available', () => {
  console.info('自動更新 有新版本')
})
autoUpdater.on('update-not-available', () => {
  console.info('自動更新 沒有新版本')
})
autoUpdater.on('update-downloaded', () => {
  console.info('自動更新 新版本下載完成')
  autoUpdater.quitAndInstall()
})
autoUpdater.setFeedURL({ url: 'https://XX.oss.aliyuncs.com/apps/RELEASES.json', serverType: 'json' })
autoUpdater.checkForUpdates()

WIN 自更新依賴electron-updater隸屬于 electron-builder

打包后出現latest.yml璃俗、XXX Setup ${version}.exe.blockmapXXX Setup ${version}.exe

import { autoUpdater as winAutoUpdater } from 'electron-updater'

winAutoUpdater.on('error', (message) => {
  console.error('自動更新', message)
})
winAutoUpdater.on('update-available', () => {
  console.info('自動更新 有新版本')
})
winAutoUpdater.on('update-not-available', () => {
  console.info('自動更新 沒有新版本')
})
winAutoUpdater.on('update-downloaded', () => {
  console.info('自動更新 新版本下載完成')
  winAutoUpdater.quitAndInstall()
})
winAutoUpdater.setFeedURL('https://XX.oss.aliyuncs.com/apps')
winAutoUpdater.checkForUpdates()
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
  • 序言:七十年代末悉默,一起剝皮案震驚了整個濱河市城豁,隨后出現的幾起案子,更是在濱河造成了極大的恐慌抄课,老刑警劉巖唱星,帶你破解...
    沈念sama閱讀 211,743評論 6 492
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現場離奇詭異跟磨,居然都是意外死亡间聊,警方通過查閱死者的電腦和手機,發(fā)現死者居然都...
    沈念sama閱讀 90,296評論 3 385
  • 文/潘曉璐 我一進店門抵拘,熙熙樓的掌柜王于貴愁眉苦臉地迎上來哎榴,“玉大人,你說我怎么就攤上這事僵蛛∩序颍” “怎么了?”我有些...
    開封第一講書人閱讀 157,285評論 0 348
  • 文/不壞的土叔 我叫張陵墩瞳,是天一觀的道長驼壶。 經常有香客問我,道長喉酌,這世上最難降的妖魔是什么热凹? 我笑而不...
    開封第一講書人閱讀 56,485評論 1 283
  • 正文 為了忘掉前任,我火速辦了婚禮泪电,結果婚禮上般妙,老公的妹妹穿的比我還像新娘。我一直安慰自己相速,他們只是感情好碟渺,可當我...
    茶點故事閱讀 65,581評論 6 386
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著突诬,像睡著了一般苫拍。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上旺隙,一...
    開封第一講書人閱讀 49,821評論 1 290
  • 那天绒极,我揣著相機與錄音,去河邊找鬼蔬捷。 笑死垄提,一個胖子當著我的面吹牛榔袋,可吹牛的內容都是我干的。 我是一名探鬼主播铡俐,決...
    沈念sama閱讀 38,960評論 3 408
  • 文/蒼蘭香墨 我猛地睜開眼凰兑,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了审丘?” 一聲冷哼從身側響起吏够,我...
    開封第一講書人閱讀 37,719評論 0 266
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎备恤,沒想到半個月后稿饰,有當地人在樹林里發(fā)現了一具尸體,經...
    沈念sama閱讀 44,186評論 1 303
  • 正文 獨居荒郊野嶺守林人離奇死亡露泊,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 36,516評論 2 327
  • 正文 我和宋清朗相戀三年喉镰,在試婚紗的時候發(fā)現自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片惭笑。...
    茶點故事閱讀 38,650評論 1 340
  • 序言:一個原本活蹦亂跳的男人離奇死亡侣姆,死狀恐怖,靈堂內的尸體忽然破棺而出沉噩,到底是詐尸還是另有隱情捺宗,我是刑警寧澤,帶...
    沈念sama閱讀 34,329評論 4 330
  • 正文 年R本政府宣布川蒙,位于F島的核電站蚜厉,受9級特大地震影響,放射性物質發(fā)生泄漏畜眨。R本人自食惡果不足惜昼牛,卻給世界環(huán)境...
    茶點故事閱讀 39,936評論 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望康聂。 院中可真熱鬧贰健,春花似錦、人聲如沸恬汁。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,757評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽氓侧。三九已至脊另,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間约巷,已是汗流浹背尝蠕。 一陣腳步聲響...
    開封第一講書人閱讀 31,991評論 1 266
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留载庭,地道東北人。 一個月前我還...
    沈念sama閱讀 46,370評論 2 360
  • 正文 我出身青樓,卻偏偏與公主長得像囚聚,于是被迫代替她去往敵國和親靖榕。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 43,527評論 2 349

推薦閱讀更多精彩內容