-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
/
Copy pathMacUpdater.ts
282 lines (245 loc) · 10.6 KB
/
MacUpdater.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
import { AllPublishOptions, newError, safeStringifyJson } from "builder-util-runtime"
import { pathExistsSync, stat, copyFile } from "fs-extra"
import { createReadStream } from "fs"
import * as path from "path"
import { createServer, IncomingMessage, Server, ServerResponse } from "http"
import { AppAdapter } from "./AppAdapter"
import { AppUpdater, DownloadUpdateOptions } from "./AppUpdater"
import { ResolvedUpdateFileInfo, UpdateDownloadedEvent } from "./main"
import { findFile } from "./providers/Provider"
import AutoUpdater = Electron.AutoUpdater
import { execFileSync } from "child_process"
import { randomBytes } from "crypto"
export class MacUpdater extends AppUpdater {
private readonly nativeUpdater: AutoUpdater = require("electron").autoUpdater
private squirrelDownloadedUpdate = false
private server?: Server
constructor(options?: AllPublishOptions, app?: AppAdapter) {
super(options, app)
this.nativeUpdater.on("error", it => {
this._logger.warn(it)
this.emit("error", it)
})
this.nativeUpdater.on("update-downloaded", () => {
this.squirrelDownloadedUpdate = true
this.debug("nativeUpdater.update-downloaded")
})
}
private debug(message: string): void {
if (this._logger.debug != null) {
this._logger.debug(message)
}
}
private closeServerIfExists() {
if (this.server) {
this.debug("Closing proxy server")
this.server.close(err => {
if (err) {
this.debug("proxy server wasn't already open, probably attempted closing again as a safety check before quit")
}
})
}
}
protected async doDownloadUpdate(downloadUpdateOptions: DownloadUpdateOptions): Promise<Array<string>> {
let files = downloadUpdateOptions.updateInfoAndProvider.provider.resolveFiles(downloadUpdateOptions.updateInfoAndProvider.info)
const log = this._logger
// detect if we are running inside Rosetta emulation
const sysctlRosettaInfoKey = "sysctl.proc_translated"
let isRosetta = false
try {
this.debug("Checking for macOS Rosetta environment")
const result = execFileSync("sysctl", [sysctlRosettaInfoKey], { encoding: "utf8" })
isRosetta = result.includes(`${sysctlRosettaInfoKey}: 1`)
log.info(`Checked for macOS Rosetta environment (isRosetta=${isRosetta})`)
} catch (e: any) {
log.warn(`sysctl shell command to check for macOS Rosetta environment failed: ${e}`)
}
let isArm64Mac = false
try {
this.debug("Checking for arm64 in uname")
const result = execFileSync("uname", ["-a"], { encoding: "utf8" })
const isArm = result.includes("ARM")
log.info(`Checked 'uname -a': arm64=${isArm}`)
isArm64Mac = isArm64Mac || isArm
} catch (e: any) {
log.warn(`uname shell command to check for arm64 failed: ${e}`)
}
isArm64Mac = isArm64Mac || process.arch === "arm64" || isRosetta
// allow arm64 macs to install universal or rosetta2(x64) - https://github.com/electron-userland/electron-builder/pull/5524
const isArm64 = (file: ResolvedUpdateFileInfo) => file.url.pathname.includes("arm64") || file.info.url?.includes("arm64")
if (isArm64Mac && files.some(isArm64)) {
files = files.filter(file => isArm64Mac === isArm64(file))
} else {
files = files.filter(file => !isArm64(file))
}
const zipFileInfo = findFile(files, "zip", ["pkg", "dmg"])
if (zipFileInfo == null) {
throw newError(`ZIP file not provided: ${safeStringifyJson(files)}`, "ERR_UPDATER_ZIP_FILE_NOT_FOUND")
}
const provider = downloadUpdateOptions.updateInfoAndProvider.provider
const CURRENT_MAC_APP_ZIP_FILE_NAME = "update.zip"
return this.executeDownload({
fileExtension: "zip",
fileInfo: zipFileInfo,
downloadUpdateOptions,
task: async (destinationFile, downloadOptions) => {
const cachedUpdateFilePath = path.join(this.downloadedUpdateHelper!.cacheDir, CURRENT_MAC_APP_ZIP_FILE_NAME)
const canDifferentialDownload = () => {
if (!pathExistsSync(cachedUpdateFilePath)) {
log.info("Unable to locate previous update.zip for differential download (is this first install?), falling back to full download")
return false
}
return !downloadUpdateOptions.disableDifferentialDownload
}
let differentialDownloadFailed = true
if (canDifferentialDownload()) {
differentialDownloadFailed = await this.differentialDownloadInstaller(zipFileInfo, downloadUpdateOptions, destinationFile, provider, CURRENT_MAC_APP_ZIP_FILE_NAME)
}
if (differentialDownloadFailed) {
await this.httpExecutor.download(zipFileInfo.url, destinationFile, downloadOptions)
}
},
done: async event => {
if (!downloadUpdateOptions.disableDifferentialDownload) {
try {
const cachedUpdateFilePath = path.join(this.downloadedUpdateHelper!.cacheDir, CURRENT_MAC_APP_ZIP_FILE_NAME)
await copyFile(event.downloadedFile, cachedUpdateFilePath)
} catch (error: any) {
this._logger.warn(`Unable to copy file for caching for future differential downloads: ${error.message}`)
}
}
return this.updateDownloaded(zipFileInfo, event)
},
})
}
private async updateDownloaded(zipFileInfo: ResolvedUpdateFileInfo, event: UpdateDownloadedEvent): Promise<Array<string>> {
const downloadedFile = event.downloadedFile
const updateFileSize = zipFileInfo.info.size ?? (await stat(downloadedFile)).size
const log = this._logger
const logContext = `fileToProxy=${zipFileInfo.url.href}`
this.closeServerIfExists()
this.debug(`Creating proxy server for native Squirrel.Mac (${logContext})`)
this.server = createServer()
this.debug(`Proxy server for native Squirrel.Mac is created (${logContext})`)
this.server.on("close", () => {
log.info(`Proxy server for native Squirrel.Mac is closed (${logContext})`)
})
// must be called after server is listening, otherwise address is null
const getServerUrl = (s: Server): string => {
const address = s.address()
if (typeof address === "string") {
return address
}
return `http://127.0.0.1:${address?.port}`
}
return await new Promise<Array<string>>((resolve, reject) => {
const pass = randomBytes(64).toString("base64").replace(/\//g, "_").replace(/\+/g, "-")
const authInfo = Buffer.from(`autoupdater:${pass}`, "ascii")
// insecure random is ok
const fileUrl = `/${randomBytes(64).toString("hex")}.zip`
this.server!.on("request", (request: IncomingMessage, response: ServerResponse) => {
const requestUrl = request.url!
log.info(`${requestUrl} requested`)
if (requestUrl === "/") {
// check for basic auth header
if (!request.headers.authorization || request.headers.authorization.indexOf("Basic ") === -1) {
response.statusCode = 401
response.statusMessage = "Invalid Authentication Credentials"
response.end()
log.warn("No authenthication info")
return
}
// verify auth credentials
const base64Credentials = request.headers.authorization.split(" ")[1]
const credentials = Buffer.from(base64Credentials, "base64").toString("ascii")
const [username, password] = credentials.split(":")
if (username !== "autoupdater" || password !== pass) {
response.statusCode = 401
response.statusMessage = "Invalid Authentication Credentials"
response.end()
log.warn("Invalid authenthication credentials")
return
}
const data = Buffer.from(`{ "url": "${getServerUrl(this.server!)}${fileUrl}" }`)
response.writeHead(200, { "Content-Type": "application/json", "Content-Length": data.length })
response.end(data)
return
}
if (!requestUrl.startsWith(fileUrl)) {
log.warn(`${requestUrl} requested, but not supported`)
response.writeHead(404)
response.end()
return
}
log.info(`${fileUrl} requested by Squirrel.Mac, pipe ${downloadedFile}`)
let errorOccurred = false
response.on("finish", () => {
if (!errorOccurred) {
this.nativeUpdater.removeListener("error", reject)
resolve([])
}
})
const readStream = createReadStream(downloadedFile)
readStream.on("error", error => {
try {
response.end()
} catch (e: any) {
log.warn(`cannot end response: ${e}`)
}
errorOccurred = true
this.nativeUpdater.removeListener("error", reject)
reject(new Error(`Cannot pipe "${downloadedFile}": ${error}`))
})
response.writeHead(200, {
"Content-Type": "application/zip",
"Content-Length": updateFileSize,
})
readStream.pipe(response)
})
this.debug(`Proxy server for native Squirrel.Mac is starting to listen (${logContext})`)
this.server!.listen(0, "127.0.0.1", () => {
this.debug(`Proxy server for native Squirrel.Mac is listening (address=${getServerUrl(this.server!)}, ${logContext})`)
this.nativeUpdater.setFeedURL({
url: getServerUrl(this.server!),
headers: {
"Cache-Control": "no-cache",
Authorization: `Basic ${authInfo.toString("base64")}`,
},
})
// The update has been downloaded and is ready to be served to Squirrel
this.dispatchUpdateDownloaded(event)
if (this.autoInstallOnAppQuit) {
this.nativeUpdater.once("error", reject)
// This will trigger fetching and installing the file on Squirrel side
this.nativeUpdater.checkForUpdates()
} else {
resolve([])
}
})
})
}
private handleUpdateDownloaded() {
if (this.autoRunAppAfterInstall) {
this.nativeUpdater.quitAndInstall()
} else {
this.app.quit()
}
this.closeServerIfExists()
}
quitAndInstall(): void {
if (this.squirrelDownloadedUpdate) {
// update already fetched by Squirrel, it's ready to install
this.handleUpdateDownloaded()
} else {
// Quit and install as soon as Squirrel get the update
this.nativeUpdater.on("update-downloaded", () => this.handleUpdateDownloaded())
if (!this.autoInstallOnAppQuit) {
/**
* If this was not `true` previously then MacUpdater.doDownloadUpdate()
* would not actually initiate the downloading by electron's autoUpdater
*/
this.nativeUpdater.checkForUpdates()
}
}
}
}