更新:
此文章部分技術點已落後,能夠查看 最新文章 javascript
這多是中文史上最詳細的 NW.js 打包教程php
本文適應有必定 js 基礎,第一次玩 windows 下 setup 打包的同窗,默認的環境 windows。而後,文章太過於詳實,看完會耗費大量時間,暫時不想實操的,我會直接提供一個 vue-nw-seed 種子項目,包含了當前文章的一些優化點。 前端
本文涉及到的點:vue
Node.js 打包 zip 、文件處理、crypto 提取 MD5 、iconv 處理字符串等java
Resource Hacker 配置應用的權限、圖標、版權等node
InnoSetup 製做安裝包、iss 文件配置webpack
NW.js 應用的更新(增量、全量更新)git
...程序員
未涉及到的點:github
代碼加密,本着前端的心態作的桌面端應用,代碼 Uglify 後就已經不可看了。若是有機密代碼或者加密算法等須要另外考慮,不在本文的討論範圍,提供一個官方文檔 Protect JavaScript Source Code
這部分沒啥好說的,都很簡單。
對新手友好。。。還有個 NW.js 的打包在 gayhub 上還專門有個 npm 包 nw-builder ,這個用起來就更簡單了,我連示例都不想寫的那種簡單。而後這兒須要下載 NW.js 的 SDK 或者 NORMAL 的包,方法同我上一篇文章 用 vue2 和 webpack 快速建構 NW.js 項目 中 網絡不太好
部分
NW.js 被打包出來後是一個文件夾,裏面有整個 runtime 和一個 exe 文件,這時候整個打包就成功了,差很少有 100MB 左右。
可是,咱們的應用再也不是給內部使用,給用戶下載總不能直接給用戶拷貝一個文件夾或者下載 zip 壓縮包,那樣忒不靠譜的樣子,還覺得是啥病毒呢。
咱們能不能就像吃自助餐那樣,想吃啥就拿啥,想打包成啥樣就弄成啥樣。
實現思路
本身搞一個 runtime,而後用 Node.js 對打包好的代碼進行 zip 壓縮爲 package.nw
,而後放到 runtime 中,再用官方推薦的 InnoSetup 來打包成一個 setup.exe。
使用 NW.js 的主要優點是兼容 XP,教育行業這個真的很重要呀。。。
NW.js 不是全版本都支持 XP,因爲 Chromium50 開始就不支持XP了,因此若是你的客戶端要支持 XP,目前最佳的版本選擇是 0.14.7
。參見 NW.js 的博客 NW.js v0.14.7 (LTS) Released
從官網 http://dl.nwjs.io/v0.14.7/ 下載一個 normal 的包,而後在此基礎上進行 DIY。
大概目錄就是這樣子
而後就開始優化和自定義工做:
1) 先整理下 locales 下的語言包,減小部分冗餘。
2) 替換下 ffmpeg.dll 解決部分格式 video 的播放問題等,下載的時候注意下版本,和 NW.js 相對應就好。
3) 將 nw.exe
更名字爲咱們的應用的名字,好比myProgramApp.exe
,更正規一點。而後用 Resource Hacker
修改下版本和版權公司等相關信息。
4) 再用使用 Resource Hacker
進行圖標替換,建議尺寸是256。
5) 同時爲其添加管理員權限。由於咱們要作增量更新,須要用 Node.js 寫文件到應用所在目錄,當安裝目錄是 C:\Program Files\
的時候,普通權限用戶沒有寫權限。
具體操做仍是用 Resource Hacker
打開myProgramApp.exe
,找到 Manifest
中
<requestedExecutionLevel level="asInvoker" uiAccess="false"/></requestedPrivileges>
修改成
<requestedExecutionLevel level="requireAdministrator" uiAccess="false"/></requestedPrivileges>
弄完了大概是這個樣子
package.nw
須要一個 zip 處理的依賴 archiver,第一次用這個依賴,建議直接去看他們的英文文檔,謹慎使用 bulk
這個方法,在 0.21.0 的時候就被廢棄了。
打包 zip 的方法大概就長這樣:
const fs = require('fs') const archive = require('archive') function buildZipFile({ outZipPath, files, mainPackage } = {}) { let filesArr = Array.isArray(files) ? files : [files] // 建立一個可寫流的 zip 文件 var output = fs.createWriteStream(outZipPath) var archive = archiver('zip', { store: true }) archive.on('error', console.error) // 打包 dist 目錄爲 zip 壓縮包格式的 nw 文件 archive.pipe(output) if (filesArr.length > 0) { filesArr.forEach(p => { if (!p) return // 剔除 package.json let hasPackJson = path.resolve(p, 'package.json') if (fs.existsSync(hasPackJson)) fs.unlinkSync(hasPackJson) // 壓縮目錄 archive.directory(p, '') }) // 添加 package.json archive.file(mainPackage, { name: 'package.json' }) } archive.finalize() }
Node.js 的豐富的生態已經有人提供了一個 node-innosetup-compiler 了,因此這個也很方便。不過對於我這種第一次玩這個的玩家仍是有點懵逼,特別是那個 iss
文件的編寫。。。
鑑於本文不想寫成 InnoSetup 的使用教程,因此只講講普通使用,若是你須要更復雜的功能,給你個文檔 Inno Setup Help
我提供一個我用的 setup.iss
文件,其中用下劃線開頭(如: _appName )這種將會被 js 正則匹配掉
; Script generated by the Inno Setup Script Wizard. ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! ; 該執行目錄爲 setup.iss 所在的目錄,請注意拼接相對目錄 #define MyAppName "_appName" #define MyAppNameZh "_appZhName" #define MyAppVersion "_appVersion" #define MyAppPublisher "_appPublisher" #define MyAppURL "_appURL" #define MyAppExeName "_appName.exe" #define OutputPath "_appOutputPath" #define SourceMain "_appRuntimePath\_appName.exe" #define SourceFolder "_appRuntimePath\*" #define LicenseFilePath "_appResourcesPath\license.txt" #define SetupIconFilePath "_appResourcesPath\_appName.ico" #define MyAppId "_appId" [Setup] ; NOTE: The value of AppId uniquely identifies this application. ; Do not use the same AppId value in installers for other applications. ; (To generate a new GUID, click Tools | Generate GUID inside the IDE.) AppId={#MyAppId} AppName={#MyAppName} AppVersion={#MyAppVersion} AppVerName={#MyAppName} AppPublisher={#MyAppPublisher} AppPublisherURL={#MyAppURL} AppSupportURL={#MyAppURL} AppUpdatesURL={#MyAppURL} DefaultDirName={pf}\{#MyAppName} LicenseFile={#LicenseFilePath} OutputDir={#OutputPath} OutputBaseFilename={#MyAppName}-v{#MyAppVersion}-setup SetupIconFile={#SetupIconFilePath} Compression=lzma SolidCompression=yes PrivilegesRequired=admin Uninstallable=yes UninstallDisplayName={#MyAppNameZh} DefaultGroupName={#MyAppNameZh} [Tasks] Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: checkedonce [Files] Source: {#SourceMain}; DestDir: "{app}"; Flags: ignoreversion Source: {#SourceFolder}; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs [Icons] Name: "{commondesktop}\{#MyAppNameZh}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon Name: "{group}\{#MyAppNameZh}"; Filename: "{app}\{#MyAppExeName}" Name: "{group}\卸載{#MyAppNameZh}"; Filename: "{uninstallexe}" [Languages] Name: "chinese"; MessagesFile: "innosetup\Languages\ChineseSimp.isl" [Run] Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
建立一個 resources 文件夾,裏面放上 icon 和 license,就像這樣
再而後此 iss 配合 makeExeSetup
使用,格外酸爽,請忽略那一串 replace,233333333
// 新依賴,用於處理 utf 和 ansi 的字符串 const iconv = require('iconv-lite') function makeExeSetup(opt) { const { issPath, outputPath, mainPackage, runtimePath, resourcesPath, appPublisher, appURL, appId } = opt const { name, appName, version } = require(mainPackage) const tmpIssPath = path.resolve(path.parse(issPath).dir, '_tmp.iss') const innosetupCompiler = require('innosetup-compiler') // rewrite name, version to iss fs.readFile(issPath, null, (err, text) => { if (err) throw err let str = iconv.decode(text, 'gbk') .replace(/_appName/g, name) .replace(/_appZhName/g, appName) .replace(/_appVersion/g, version) .replace(/_appOutputPath/g, outputPath) .replace(/_appRuntimePath/g, runtimePath) .replace(/_appResourcesPath/g, resourcesPath) .replace(/_appPublisher/g, appPublisher) .replace(/_appURL/g, appURL) .replace(/_appId/g, appId) fs.writeFile(tmpIssPath, iconv.encode(str, 'gbk'), null, err => { if (err) throw err // inno setup start innosetupCompiler(tmpIssPath, { gui: false, verbose: true }, function(err) { fs.unlinkSync(tmpIssPath) if (err) throw err }) }) }) }
這個時候就能製做出一個安裝包了,就像這樣
而後是安裝的流程
安裝完成的目錄
雖然 InnoSetup 簡單好使,可是製做出來的安裝包的安裝界面默認是 windows2000 的界面,那個醜那個老舊喲。。。
若是你的應用只要能用就好了,那這一步已經徹底夠了。
但技術人怎麼能不折騰,下面,咱們來搞炫酷的安裝包的製做方法。
先擺一個被我模仿的例子 INNOSETUP 仿有道雲安裝包界面,同時還有個參考資料:互聯網軟件的安裝包界面設計-Inno setup 真心吐個槽,這方面的資料真少。。。
我其實都按照已有的素材包寫好了一個了,但咱們的 ui 還沒設計出更漂亮的安裝界面出來,因此,我就暫時不放相關資源和效果了。
這一塊,應該是最輕鬆的,蛤。
咱們的更新策略分爲兩種,一種是隻更新咱們的業務代碼,每次只須要下載1MB多的業務代碼就搞定,走增量更新渠道;另外一種是更新了咱們的 runtime ,或者其餘啥玩意的重要更新,須要全量更新,走全量更新的渠道。
實現思路
在打包的時候把版本和更新信息寫入到 update.json
中,在每次客戶端打開的時候都去請求這個 json ,檢查 json 中版本和客戶端版本是否匹配,不匹配則根據 json 中的約定規則進行增量更新或全量更新。
一個開發原則是能懶就懶,能用工具作的就必定要用工具作。蛤蛤,在這個原則的堅持下,咱們來繼續優化上文提到的打包建構。
用 Node.js 把以前臨時放在 runtime 中的 package.nw (zip) 包拷貝到 output 目錄,再根據 changelog.txt
文件寫更新信息到 update.json 中。
準備一個 changelog.txt 文件在 config 配置目錄下,大概就長這樣子,每次更新以---
進行分割,第一行是版本,後面是更新信息:
0.1.0 - 程序員 peter 開始開發了! - 順便,請老闆給 peter 漲工資。 --- 1.0.0 - 客戶端正式版成功發佈啦! - 同時,peter 由於要求漲工資已被打殘住院中,因此暫時不會有其餘更新。 ---
有同窗問我,爲啥要這麼設計個 log.txt 出來,不直接用 json 等其餘形式進行描述?
由於這個文件在將來可能要被打包到應用中,連同 license 文件進行打包;還有就是分離這部分描述,更易擴展。
而後寫一讀取這個 log 的方法
function getLatestLogBycheckVersion({ changelogPath, mainPackage }) { // get package.json by package const packageJson = require(mainPackage) // check version // 大於等於3是由於合法的版本信息最少 "---" 有3個長度 const changeLogArr = fs.readFileSync(changelogPath, 'utf-8').split('---').filter(v => v.trim().length >= 3) const latestInfo = changeLogArr.pop().split('\n').map(v => v.trim()).filter(v => v.length) const version = latestInfo[0] if (packageJson.version !== version) { // 更新 package.json 的版本 packageJson.version = version fs.writeFileSync(mainPackage, JSON.stringify(packageJson, null, ' '), 'utf-8') } return latestInfo } // 這就是全局的 options opt.latestLog = getLatestLogBycheckVersion(opt) // 更新約定,用來判斷當前版本是否須要增量更新 opt.noIncremental = process.argv.indexOf('--noIncremental') >= 0
增量更新的約定
經過 process.argv
來檢測當前是否須要增量更新,並寫入到 options 中,這一點看起來有點稍微繁瑣,若是有其餘更好的點子,歡迎踊躍來提 issue 或者直接私信我,謝謝!
接下來繼續處理打包完成的系列流程,需求是要移動 nw 到 output 目錄,還要寫一個 update.json
const crypto = require('crypto') function finishedPackage(opt) { const { mainPackage, outputPath, latestLog, outZipPath, updateServerPath, noIncremental } = opt const { name, appName, version } = require(mainPackage) let versionCode = parseInt(version.replace(/\./g, '')) let updateDesc = latestLog.slice(1).join('#%#') let outNWName = `${name}-v${version}.nw` let outNWPath = path.resolve(outputPath, outNWName) let updateJsonPath = path.resolve(outputPath, 'update.json') // write update.json let updateJson = { appName, version, versionCode, requiredVersion: version, requiredVersionCode: versionCode, updateDesc, filePath: updateServerPath + outNWName, incremental: !noIncremental } // fileSize and MD5 getMd5ByFile(outZipPath, (err, hexStr) => { if (err) throw err updateJson.MD5 = hexStr updateJson.fileSize = fs.statSync(outZipPath).size fs.writeFileSync(updateJsonPath, JSON.stringify(updateJson, null, ' '), 'utf-8') copyFile(outZipPath, outNWPath) fs.unlink(outZipPath, err => err && console.error(err)) }) } function getMd5ByFile(filePath, callback) { let rs = fs.createReadStream(filePath) let hash = crypto.createHash('md5') rs.on('error', err => { if (typeof callback === 'function') callback(err) }) rs.on('data', hash.update.bind(hash)) rs.on('end', () => { if (typeof callback === 'function') callback(null, hash.digest('hex')) }) } function copyFile(src, dst) { fs.createReadStream(src).pipe(fs.createWriteStream(dst)) }
整個打包完了差很少就這樣子了
那個 update.json 裏面的實際內容就是這些
{ "appName": "doudou", "version": "1.0.1-beta19", "versionCode": 101, "requiredVersion": "1.0.1-beta19", "requiredVersionCode": 101, "updateDesc": "- 程序員 peter 無話可說", "filePath": "http://upgrade.iclassedu.com/doudou/upgrade/teacher/doudou-v1.0.1-beta19.nw", "incremental": true, "MD5": "9be46fc8fb04d38449eeb4358c3b5a31", "fileSize": 5469 }
上代碼,代碼切換到 src 目錄中,在咱們的應用代碼中寫上 utils/update.js
的相關方法。具體的幾個小方法,看註釋吧。
import { updateApi } from 'config/app' import { App } from 'nw.gui' const options = { method: 'GET', mode: 'cors', credentials: 'include' } let tmpUpdateJson = null // 請求 update.json,返回的是 promise 類型的 json export function getUpdateJson(noCache) { if (!noCache && tmpUpdateJson) return new Promise((resolve, reject) => resolve(tmpUpdateJson)) return window.fetch(updateApi + '?' + (new Date().getTime()), options) .then(resp => resp.json()) .then(json => { tmpUpdateJson = json return tmpUpdateJson }) } // 檢查版本,若是有更新則跳轉到更新頁面 export function checkUpdate() { getUpdateJson().then(json => { if (json.version === App.manifest.version) return setTimeout(() => { window.location.hash = '/update' }, 500) }) }
而後在 main.js 中進行更新檢查
// 優先更新 import { checkUpdate } from '@/utils/update' if (process.env.NODE_ENV !== 'development') checkUpdate()
在上面的基礎上作增量更新,基本思路就是用 Node.js 去下載 nw 包到應用所在的目錄,並直接替換掉原有的 package.nw ,再重啓一下本身就搞定了;全量更新的話,就直接打開應用的下載頁面,讓用戶自行下載覆蓋安裝就搞定了。
// 下載 nw 包 export function updatePackage() { return new Promise((resolve, reject) => { getUpdateJson().then(json => { // 全量更新 if (!json.incremental) { Shell.openExternal(getSetupApi) return reject({ message: '請下載最新版本,再覆蓋安裝' }) } // 增量更新 let packageZip = fs.createWriteStream(tmpNWPath) http .get(json.filePath, res => { if (res.statusCode < 200 || res.statusCode >= 300) return reject({ message: '下載出錯,請稍後重試' }) res.on('end', () => { if (fs.statSync(tmpNWPath).size < 10) return reject({ message: '更新包出錯,請稍後重試' }) fs.renameSync(tmpNWPath, appPath) resolve(json) }) res.pipe(packageZip) }) .on('error', reject) }) }) } // 重啓本身 export function restartSelf(waitTime) { setTimeout(() => { require('child_process').spawn('restart.bat', [], { detached: true, cwd: rootPath }) }, ~~waitTime || 2000) }
這兒有個小小的 hack ,仔細看看代碼的同窗應該已經發現了 restart.bat
。我嘗試了不少辦法,想讓 NW.exe 重啓本身,最終多番嘗試後失敗了。。。就寫了個 bat 來重啓本身。
taskkill /im doudou.exe /f start .\doudou.exe exit
若是有其餘更好的辦法,歡迎踊躍來提 issue 或者直接私信我,謝謝!
可能會有同窗會問,爲啥不直接下載 exe 包下來,再打開引導安裝?
我試過了,當應用被安裝在 C:\Program Files
目錄裏面,管理員權限都不能寫 .exe
後綴的文件進去。。。因此,我乾脆用瀏覽器打開咱們的應用的下載頁,讓用戶本身去下載後,本身安裝算了。這兒應該能夠優化,下載到 用戶數據目錄,或者其餘臨時目錄。
這個頁面就沒啥技術點,就是體力勞動了。根據前面 getUpdateJson
方法得到的 json 來渲染出要更新的版本和更新信息,而後提供一個更新按鈕,按鈕點擊後,執行 updatePackage
這個方法,若是順利執行就在 then 裏面調用 restartSelf
重啓本身就好了。
總體效果就是這樣的
若是對您有用,幫我點個 star ,謝謝!您的支持是我繼續更新下去的動力。