摘要:再用使用進行圖標替換,建議尺寸是。同時為其添加管理員權限。用把之前臨時放在中的包拷貝到目錄,再根據文件寫更新信息到中。這兒應該可以優化,下載到用戶數據目錄,或者其他臨時目錄。
打包NW.js應用和制作windows安裝文件
更新:
此文章部分技術點已落后,可以查看 最新文章
這可能是中文史上最詳細的 NW.js 打包教程
本文適應有一定 js 基礎,第一次玩 windows 下 setup 打包的同學,默認的環境 windows。然后,文章太過于詳實,看完會耗費大量時間,暫時不想實操的,我會直接提供一個 vue-nw-seed 種子項目,包含了當前文章的一些優化點。
本文涉及到的點:
Node.js 打包 zip 、文件處理、crypto 提取 MD5 、iconv 處理字符串等
Resource Hacker 配置應用的權限、圖標、版權等
InnoSetup 制作安裝包、iss 文件配置
NW.js 應用的更新(增量、全量更新)
...
未涉及到的點:
代碼加密,本著前端的心態做的桌面端應用,代碼 Uglify 后就已經不可看了。如果有機密代碼或者加密算法等需要另外考慮,不在本文的討論范圍,提供一個官方文檔 Protect JavaScript Source Code
一、折騰能力強,直接上文檔How-to-package-and-distribute-your-apps
setup-on-windows
這部分沒啥好說的,都很簡單。
對新手友好。。。還有個 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中
修改為
弄完了大概是這個樣子
需要一個 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() }4. InnoSetup 打包安裝包
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 "_appResourcesPathlicense.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: "innosetupLanguagesChineseSimp.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(" ").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 }2、獲取 update.json 并檢查更新
上代碼,代碼切換到 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()3、更新
在上面的基礎上做增量更新,基本思路就是用 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 ,謝謝!您的支持是我繼續更新下去的動力。
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/88195.html
摘要:增加文件這個文件主要做的事情就是整理出用的,然后再調用進行打包在中增加打包入口增加下面這一行代碼在打包完成的回調中簡單部就完成了打包,是不是異常清晰和簡單。參見如果有就寫上新增這個文件就是用來打包下安裝包的。。。 閱讀本文需要一點 JS 基礎和閱讀的耐心,我特么自己寫完后發現這文章咋這么長啊。。。如果你認真看完算我輸! 另我專門做了個 vue-nw-seed 項目,里面包含了我這篇...
摘要:五六月份推薦集合查看最新的請點擊集前端最近很火的框架資源定時更新,歡迎一下。蘇幕遮燎沈香宋周邦彥燎沈香,消溽暑。鳥雀呼晴,侵曉窺檐語。葉上初陽乾宿雨,水面清圓,一一風荷舉。家住吳門,久作長安旅。五月漁郎相憶否。小楫輕舟,夢入芙蓉浦。 五、六月份推薦集合 查看github最新的Vue weekly;請::點擊::集web前端最近很火的vue2框架資源;定時更新,歡迎 Star 一下。 蘇...
摘要:五六月份推薦集合查看最新的請點擊集前端最近很火的框架資源定時更新,歡迎一下。蘇幕遮燎沈香宋周邦彥燎沈香,消溽暑。鳥雀呼晴,侵曉窺檐語。葉上初陽乾宿雨,水面清圓,一一風荷舉。家住吳門,久作長安旅。五月漁郎相憶否。小楫輕舟,夢入芙蓉浦。 五、六月份推薦集合 查看github最新的Vue weekly;請::點擊::集web前端最近很火的vue2框架資源;定時更新,歡迎 Star 一下。 蘇...
閱讀 712·2021-11-22 13:52
閱讀 1518·2021-09-27 13:36
閱讀 2818·2021-09-24 09:47
閱讀 2172·2021-09-22 15:48
閱讀 3600·2021-09-22 15:39
閱讀 1463·2019-08-30 12:43
閱讀 2918·2019-08-29 18:39
閱讀 3181·2019-08-29 12:51