1 line
8.4 KiB
JavaScript
1 line
8.4 KiB
JavaScript
import fs from"node:fs";import os from"node:os";import path from"node:path";import{spawn}from"node:child_process";import url from"node:url";import License from"./license.mjs";import Files from"../files/files.mjs";import{SOFTWARE_UPDATE_CHANNELS,TIMEOUTS}from"../../common/constants.mjs";import{fetchJsonWithTimeout}from"../../common/utils/http.mjs";import{ReleaseData}from"../../common/config.mjs";import FileDownloader from"../files/downloader.mjs";import ProgressEmitter from"../components/progress-emitter.mjs";export default class Updater{constructor(e){this.appDir=e.root,this.updateDir=path.join(e.root,"_update"),this.platform=this._getPlatform(),this.target=null,this.availability={hasUpdate:!1,couldReachWebsite:!1,slowResponse:!1,version:null,channel:null,willDisableModules:!1},this._throttlePct=null,this._updateCheckTime=0}_getPlatform(){let e=os.platform();return"win32"===e?"win":"darwin"===e?"mac":"linux"}get file(){if(!this.target?.download)throw new Error("No target download URL has yet been identified!");const e=url.parse(this.target.download).pathname;return e?path.parse(e).base:null}get localDest(){return`${this.updateDir}/${this.file}`}async check({updateChannel:e="stable",forceUpdate:t}={}){if(t=["true",!0].includes(t),this.target=null,this.availability.version=null,this.availability.channel=null,this.availability.couldReachWebsite=!0,!((e={alpha:"prototype",beta:"testing",release:"stable"}[e]||e)in SOFTWARE_UPDATE_CHANNELS))throw new Error(`${e} is not a valid software update channel to check.`);e!==config.options.updateChannel&&(config.options.updateSource({updateChannel:e}),config.options.save());const o=await this.#e(),s=!0===config.options.telemetry,i=Date.now();let a;try{a=await this.#t(e,s?o:null)}catch(e){return 403!==e.code&&(logger.warn("The Foundry Virtual Tabletop website could not be reached to check for core software updates"),this.availability.couldReachWebsite=!1),null}finally{this.availability.slowResponse=Date.now()-i>2e3}if(globalThis.game.saveNews(a.news,a.featured_content),globalThis.release.updateSource({maxGeneration:a.max_generation,maxStableGeneration:a.max_stable_generation}),!1===a.valid_key)throw config.license.expire(),new Error("SETUP.UpdateLicenseInvalid");if(config.options.noupdate)throw new Error("SETUP.UpdateNoUpdateMode");if("error"===a.status){const e=new Error(a.message);if(e.messageCode=a.message_code,!("latest_release"in a))throw e}const r=new ReleaseData(a.latest_release);this.availability.hasUpdate=r.isNewer(config.release);const n=t||this.availability.hasUpdate;return n&&(logger.info(`Core software ${r.channel} update ${r.version} is available!`),this.target=r,this.availability.version=r.version,this.availability.channel=r.channel),this.availability.willDisableModules=n&&r.isGenerationalChange(config.release),this.target}async#t(e,t){return fetchJsonWithTimeout(License.SOFTWARE_UPDATE_URL,{headers:{"Content-Type":"application/json",Authorization:config.license.authorizationHeader},method:"POST",body:JSON.stringify({diagnostics:t,updateChannel:e,license:config.license.data,versions:{foundry:config.release.version,node:process.versions.node,electron:process.versions.electron}})},{timeoutMs:TIMEOUTS.PACKAGE_REPOSITORY})}update(){if(config.options.noupdate)throw new Error("You are not allowed to update this instance of Foundry Virtual Tabletop because it was launched in --noupdate mode.");return new Promise((async(e,t)=>{const{ACTIONS:o}=CONST.SETUP_PACKAGE_PROGRESS;this.progress=new ProgressEmitter(o.UPDATE_DOWNLOAD,null,1,{id:o.UPDATE_CORE,type:"core",name:"FoundryVTT"},{operationName:"Core Software Update"});try{await this.download({onFetched:()=>e({})});await this.install()&&this.restart()}catch(e){this.progress.error(e),t(e)}}))}async download({onFetched:e}={}){if(!this.target)throw new Error("No update target has been identified");try{fs.accessSync(paths.root,fs.constants.W_OK),await fs.promises.rm(this.updateDir,{force:!0,recursive:!0}),await fs.promises.mkdir(this.updateDir)}catch(e){throw console.error(e),new Error("You do not have permission to write files in your Foundry Virtual Tabletop installation \n location. You may need to run the application as an Administrator to perform an update.")}const t=this.target.download=await this._getDownloadURL(this.target);let o=!1;const s=new FileDownloader(t,this.localDest);return s.on("error",(e=>this.progress.error(e))),s.on("progress",((e,t)=>{o||(this.progress.nextStep(CONST.SETUP_PACKAGE_PROGRESS.STEPS.DOWNLOAD,t,{message:"SETUP.UpdateDownloadingS"}),o=!0),this.progress.emit(e,{log:"Downloading software update"})})),e&&s.on("fetched",e),await s.download(),this.progress.log("Update download complete"),this.localDest}async _getDownloadURL(e){return(await fetchJsonWithTimeout(License.SOFTWARE_DOWNLOAD_URL,{headers:{"Content-Type":"application/json",Authorization:config.license.authorizationHeader},method:"POST",body:JSON.stringify({generation:e.generation,build:e.build,license:config.license.data})})).download}async install(){const{STEPS:e}=CONST.SETUP_PACKAGE_PROGRESS;if(!fs.existsSync(this.localDest))return this.progress.error(new Error("SETUP.UpdateNoArchive"),{log:"No archive"}),!1;const t=config.options.debug,o=t?path.join(this.appDir,"_testInstall"):this.appDir;t&&await fs.promises.rm(o,{force:!0,recursive:!0});let s=!1;const i=await Files.extractArchive(this.localDest,this.updateDir,{onProgress:(t,o,i)=>{s||(this.progress.nextStep(e.EXTRACT,i,{message:"SETUP.UpdateExtractingS"}),s=!0),this.progress.emit(o,{log:"Extracting downloaded update files"})}});this.progress.log("Update extraction complete"),fs.unlinkSync(this.localDest);const a=t?[]:["dist","public","templates"];this.progress.nextStep(e.CLEANUP,a.length+1,{message:"SETUP.UpdateCleaningS"}),this.progress.emit(1,{log:"Cleaning existing folder content"});for(let[e,t]of a.entries())await fs.promises.rm(path.join(o,t),{force:!0,recursive:!0}),this.progress.emit(e+2,{log:"Cleaning existing folder content"});this.progress.log("Folder cleaning complete");let r=0;return this.progress.nextStep(e.INSTALL,i,{message:"SETUP.UpdateInstallingS"}),await Files.copyDirectory(this.updateDir,o,{onProgress:()=>{this.progress.emit(++r,{log:"Installing update files"})},onError:async(e,t,o)=>{if("EBUSY"!==o.code)throw o;await Files.areFilesIdentical(e,t)||logger.error(`Attempting to overwrite file '${t}' failed as the file is in use by the process. It will be skipped, but this may cause instability in the application.`)},ignore:["package.json"]}),await fs.promises.copyFile(path.join(this.updateDir,"package.json"),path.join(o,"package.json")),this.progress.log("Update installation complete"),await fs.promises.rm(this.updateDir,{recursive:!0}),t&&await fs.promises.rm(o,{recursive:!0}),!0}async restart(e={}){const{options:t}=config;if(!t.isElectron)return setTimeout(process.exit,50),void this.progress.complete({context:{message:"SETUP.UpdateCompleteManual"},log:"Update Complete"});let o=process.argv[0],s=process.argv.slice(1)||[];e.restart=1,setTimeout((()=>{spawn(o,s,{detached:!0,cwd:this.appDir,stdio:"inherit",env:e}).unref(),process.exit()}),50),this.progress.complete({context:{message:"SETUP.UpdateCompleteAuto"},log:"Update Complete"})}async checkCoreUpdateAvailability(){const{config:e,packages:t}=global,o=Date.now();if(o-this._updateCheckTime<864e5)return this.availability;this._updateCheckTime=o;try{await this.check({updateChannel:e.options.updateChannel})}catch{}return Object.values(t.PACKAGE_TYPE_MAPPING).forEach((e=>e.reevaluateAvailabilities())),this.availability}async#e(){const e={timestamp:Date.now()};e.os={platform:os.platform(),version:this.#o(),node:process.versions.node,electron:process.versions.electron},e.foundry={generation:config.release.generation,build:config.release.build,channel:config.options.updateChannel,hosting:config.service.key?config.service.id:null};for(const t of Object.values(packages.PACKAGE_TYPE_MAPPING)){const o=e[t.collection]={},s=t.getPackages();for(const e of s.values())e.manifest&&(o[e.id]={version:e.version})}e.playtime={total:0,max:0};for(const t of packages.World.packages.values())e.playtime.total+=t.playtime,t.playtime>e.playtime.max&&(e.playtime.max=t.playtime),t.system?.id in e.systems&&(e.systems[t.system.id].playtime||=0,e.systems[t.system.id].playtime+=t.playtime);const t=path.join(paths.logs,"diagnostics.json");return Files.writeFileSyncSafe(t,JSON.stringify(e,null,2)),e}#o(){try{const e=fs.readFileSync("/etc/os-release","utf-8").match(/^PRETTY_NAME="(.*)"$/m);return e?e[1]:os.release()}catch(e){return os.release()}}} |