diff --git a/docs/Auto Update.md b/docs/Auto Update.md index 308d3784d77..999d2e3c286 100644 --- a/docs/Auto Update.md +++ b/docs/Auto Update.md @@ -44,10 +44,11 @@ autoUpdater.logger.transports.file.level = "info" ## Options -Name | Default | Description ---------------------|-------------------------|------------ -autoDownload | `true` | Automatically download an update when it is found. -logger | `console` | The logger. You can pass [electron-log](https://github.com/megahertz/electron-log), [winston](https://github.com/winstonjs/winston) or another logger with the following interface: `{ info(), warn(), error() }`. Set it to `null` if you would like to disable a logging feature. +Name | Default | Description +--------------------|-------------------|------------ +`autoDownload` | `true` | Automatically download an update when it is found. +`logger` | `console` | The logger. You can pass [electron-log](https://github.com/megahertz/electron-log), [winston](https://github.com/winstonjs/winston) or another logger with the following interface: `{ info(), warn(), error() }`. Set it to `null` if you would like to disable a logging feature. +`requestHeaders` | `null` | The request headers. ## Events diff --git a/packages/electron-builder-http/src/bintray.ts b/packages/electron-builder-http/src/bintray.ts index dba85e83506..2881cd70a5e 100644 --- a/packages/electron-builder-http/src/bintray.ts +++ b/packages/electron-builder-http/src/bintray.ts @@ -1,8 +1,8 @@ import { BintrayOptions } from "./publishOptions" -import { request } from "./httpExecutor" +import { request, configureRequestOptions } from "./httpExecutor" export function bintrayRequest(path: string, auth: string | null, data: {[name: string]: any; } | null = null, method?: string): Promise { - return request({hostname: "api.bintray.com", path: path}, auth, data, null, method) + return request(configureRequestOptions({hostname: "api.bintray.com", path: path}, auth, method), data) } export interface Version { diff --git a/packages/electron-builder-http/src/httpExecutor.ts b/packages/electron-builder-http/src/httpExecutor.ts index b9ddf376025..723bca5eb44 100644 --- a/packages/electron-builder-http/src/httpExecutor.ts +++ b/packages/electron-builder-http/src/httpExecutor.ts @@ -1,4 +1,3 @@ -import { Url } from "url" import { createHash } from "crypto" import { Transform } from "stream" import { createWriteStream } from "fs-extra-p" @@ -8,11 +7,23 @@ import _debug from "debug" import { ProgressCallbackTransform } from "./ProgressCallbackTransform" import { safeLoad } from "js-yaml" import { EventEmitter } from "events" +import { Socket } from "net" -export const debug = _debug("electron-builder") -export const maxRedirects = 10 +export interface RequestHeaders { + [key: string]: any +} + +export interface Response extends EventEmitter { + statusCode?: number + statusMessage?: string + + headers: any + + setEncoding(encoding: string): void +} export interface DownloadOptions { + headers?: RequestHeaders | null skipDirCreation?: boolean sha2?: string onProgress?(progress: any): void @@ -39,41 +50,48 @@ export function download(url: string, destination: string, options?: DownloadOpt return executorHolder.httpExecutor.download(url, destination, options) } +export class HttpError extends Error { + constructor(public readonly response: {statusMessage?: string | undefined, statusCode?: number | undefined, headers?: { [key: string]: string[]; } | undefined}, public description: any | null = null) { + super(response.statusCode + " " + response.statusMessage + (description == null ? "" : ("\n" + JSON.stringify(description, null, " "))) + "\nHeaders: " + JSON.stringify(response.headers, null, " ")) + + this.name = "HttpError" + } +} + export abstract class HttpExecutor { - request(url: Url, token?: string | null, data?: {[name: string]: any; } | null, headers?: { [key: string]: any } | null, method?: string): Promise { - const defaultHeaders: any = {"User-Agent": "electron-builder"} - const options = Object.assign({ - method: method || "GET", - headers: headers == null ? defaultHeaders : Object.assign(defaultHeaders, headers) - }, url) + protected readonly maxRedirects = 10 + protected readonly debug = _debug("electron-builder") + + request(options: RequestOptions, data?: { [name: string]: any; } | null): Promise { + options = Object.assign({headers: {"User-Agent": "electron-builder"}}, options) const encodedData = data == null ? undefined : new Buffer(JSON.stringify(data)) if (encodedData != null) { options.method = "post" + if (options.headers == null) { + options.headers = {} + } + options.headers["Content-Type"] = "application/json" options.headers["Content-Length"] = encodedData.length } - return this.doApiRequest(options, token || null, it => (it).end(encodedData), 0) + return this.doApiRequest(options, it => (it).end(encodedData), 0) } - protected abstract doApiRequest(options: REQUEST_OPTS, token: string | null, requestProcessor: (request: REQUEST, reject: (error: Error) => void) => void, redirectCount: number): Promise + protected abstract doApiRequest(options: REQUEST_OPTS, requestProcessor: (request: REQUEST, reject: (error: Error) => void) => void, redirectCount: number): Promise abstract download(url: string, destination: string, options?: DownloadOptions | null): Promise - protected handleResponse(response: Response, options: RequestOptions, resolve: (data?: any) => void, reject: (error: Error) => void, redirectCount: number, token: string | null, requestProcessor: (request: REQUEST, reject: (error: Error) => void) => void) { - if (debug.enabled) { - const safe: any = Object.assign({}, options) - if (safe.headers != null && safe.headers.authorization != null) { - safe.headers.authorization = "" - } - debug(`Response status: ${response.statusCode} ${response.statusMessage}, request options: ${JSON.stringify(safe, null, 2)}`) + protected handleResponse(response: Response, options: RequestOptions, resolve: (data?: any) => void, reject: (error: Error) => void, redirectCount: number, requestProcessor: (request: REQUEST, reject: (error: Error) => void) => void) { + if (this.debug.enabled) { + this.debug(`Response status: ${response.statusCode} ${response.statusMessage}, request options: ${dumpRequestOptions(options)}`) } // we handle any other >= 400 error on request end (read detailed message in the response body) if (response.statusCode === 404) { // error is clear, we don't need to read detailed error description reject(new HttpError(response, `method: ${options.method} url: https://${options.hostname}${options.path} - + Please double check that your authentication token is correct. Due to security reasons actual status maybe not reported, but 404. `)) return @@ -91,7 +109,7 @@ export abstract class HttpExecutor { return } - this.doApiRequest(Object.assign({}, options, parseUrl(redirectUrl)), token, requestProcessor, redirectCount) + this.doApiRequest(Object.assign({}, options, parseUrl(redirectUrl)), requestProcessor, redirectCount) .then(resolve) .catch(reject) @@ -129,23 +147,47 @@ export abstract class HttpExecutor { } }) } -} -export class HttpError extends Error { - constructor(public readonly response: {statusMessage?: string | undefined, statusCode?: number | undefined, headers?: { [key: string]: string[]; } | undefined}, public description: any | null = null) { - super(response.statusCode + " " + response.statusMessage + (description == null ? "" : ("\n" + JSON.stringify(description, null, " "))) + "\nHeaders: " + JSON.stringify(response.headers, null, " ")) + protected abstract doRequest(options: any, callback: (response: any) => void): any - this.name = "HttpError" - } -} + protected doDownload(requestOptions: any, destination: string, redirectCount: number, options: DownloadOptions, callback: (error: Error | null) => void) { + const request = this.doRequest(requestOptions, (response: Electron.IncomingMessage) => { + if (response.statusCode >= 400) { + callback(new Error(`Cannot download "${requestOptions.protocol || "https"}://${requestOptions.hostname}/${requestOptions.path}", status ${response.statusCode}: ${response.statusMessage}`)) + return + } -export interface Response extends EventEmitter { - statusCode?: number - statusMessage?: string + const redirectUrl = safeGetHeader(response, "location") + if (redirectUrl != null) { + if (redirectCount < this.maxRedirects) { + const parsedUrl = parseUrl(redirectUrl) + this.doDownload(Object.assign({}, requestOptions, { + hostname: parsedUrl.hostname, + path: parsedUrl.path, + port: parsedUrl.port == null ? undefined : parsedUrl.port + }), destination, redirectCount++, options, callback) + } + else { + callback(new Error(`Too many redirects (> ${this.maxRedirects})`)) + } + return + } - headers: any + configurePipes(options, response, destination, callback) + }) + this.addTimeOutHandler(request, callback) + request.on("error", callback) + request.end() + } - setEncoding(encoding: string): void + protected addTimeOutHandler(request: any, callback: (error: Error) => void) { + request.on("socket", function (socket: Socket) { + socket.setTimeout(60 * 1000, () => { + callback(new Error("Request timed out")) + request.abort() + }) + }) + } } class DigestTransform extends Transform { @@ -166,12 +208,8 @@ class DigestTransform extends Transform { } } -export function githubRequest(path: string, token: string | null, data: {[name: string]: any; } | null = null, method?: string): Promise { - return executorHolder.httpExecutor.request({hostname: "api.github.com", path: path}, token, data, {Accept: "application/vnd.github.v3+json"}, method) -} - -export function request(url: Url, token: string | null = null, data: {[name: string]: any; } | null = null, headers?: { [key: string]: any } | null, method?: string): Promise { - return executorHolder.httpExecutor.request(url, token, data, headers, method) +export function request(options: RequestOptions, data?: {[name: string]: any; } | null): Promise { + return executorHolder.httpExecutor.request(options, data) } function checkSha2(sha2Header: string | null | undefined, sha2: string | null | undefined, callback: (error: Error | null) => void): boolean { @@ -189,7 +227,7 @@ function checkSha2(sha2Header: string | null | undefined, sha2: string | null | return true } -export function safeGetHeader(response: any, headerKey: string) { +function safeGetHeader(response: any, headerKey: string) { const value = response.headers[headerKey] if (value == null) { return null @@ -203,7 +241,7 @@ export function safeGetHeader(response: any, headerKey: string) { } } -export function configurePipes(options: DownloadOptions, response: any, destination: string, callback: (error: Error | null) => void) { +function configurePipes(options: DownloadOptions, response: any, destination: string, callback: (error: Error | null) => void) { if (!checkSha2(safeGetHeader(response, "X-Checksum-Sha2"), options.sha2, callback)) { return } @@ -230,4 +268,31 @@ export function configurePipes(options: DownloadOptions, response: any, destinat } fileOut.on("finish", () => (fileOut.close)(callback)) +} + +export function configureRequestOptions(options: RequestOptions, token: string | null, method?: string): RequestOptions { + if (method != null) { + options.method = method + } + + let headers = options.headers + if (headers == null) { + headers = {} + options.headers = headers + } + if (token != null) { + (headers).authorization = token.startsWith("Basic") ? token : `token ${token}` + } + if (headers["User-Agent"] == null) { + headers["User-Agent"] = "electron-builder" + } + return options +} + +export function dumpRequestOptions(options: RequestOptions): string { + const safe: any = Object.assign({}, options) + if (safe.headers != null && safe.headers.authorization != null) { + safe.headers.authorization = "" + } + return JSON.stringify(safe, null, 2) } \ No newline at end of file diff --git a/packages/electron-builder/src/publish/BintrayPublisher.ts b/packages/electron-builder/src/publish/BintrayPublisher.ts index 93f235f7eba..462070cb9b6 100644 --- a/packages/electron-builder/src/publish/BintrayPublisher.ts +++ b/packages/electron-builder/src/publish/BintrayPublisher.ts @@ -6,7 +6,7 @@ import { BintrayClient, Version } from "electron-builder-http/out/bintray" import { BintrayOptions } from "electron-builder-http/out/publishOptions" import { ClientRequest } from "http" import { NodeHttpExecutor } from "../util/nodeHttpExecutor" -import { HttpError } from "electron-builder-http" +import { HttpError, configureRequestOptions } from "electron-builder-http" export class BintrayPublisher extends Publisher { private _versionPromise: BluebirdPromise @@ -58,7 +58,7 @@ export class BintrayPublisher extends Publisher { let badGatewayCount = 0 for (let i = 0; i < 3; i++) { try { - return await this.httpExecutor.doApiRequest({ + return await this.httpExecutor.doApiRequest(configureRequestOptions({ hostname: "api.bintray.com", path: `/content/${this.client.owner}/${this.client.repo}/${this.client.packageName}/${version.name}/${fileName}`, method: "PUT", @@ -68,7 +68,7 @@ export class BintrayPublisher extends Publisher { "X-Bintray-Override": "1", "X-Bintray-Publish": "1", } - }, this.client.auth, requestProcessor) + }, this.client.auth), requestProcessor) } catch (e) { if (e instanceof HttpError && e.response.statusCode === 502 && badGatewayCount++ < 3) { diff --git a/packages/electron-builder/src/publish/gitHubPublisher.ts b/packages/electron-builder/src/publish/gitHubPublisher.ts index 64ce34710c9..6fe998549ae 100644 --- a/packages/electron-builder/src/publish/gitHubPublisher.ts +++ b/packages/electron-builder/src/publish/gitHubPublisher.ts @@ -7,7 +7,7 @@ import BluebirdPromise from "bluebird-lst-c" import { PublishOptions, Publisher } from "./publisher" import { GithubOptions } from "electron-builder-http/out/publishOptions" import { ClientRequest } from "http" -import { HttpError, githubRequest } from "electron-builder-http" +import { HttpError, configureRequestOptions } from "electron-builder-http" import { NodeHttpExecutor } from "../util/nodeHttpExecutor" export interface Release { @@ -63,7 +63,7 @@ export class GitHubPublisher extends Publisher { private async getOrCreateRelease(): Promise { // we don't use "Get a release by tag name" because "tag name" means existing git tag, but we draft release and don't create git tag - const releases = await githubRequest>(`/repos/${this.info.owner}/${this.info.repo}/releases`, this.token) + const releases = await this.githubRequest>(`/repos/${this.info.owner}/${this.info.repo}/releases`, this.token) for (const release of releases) { if (release.tag_name === this.tag || release.tag_name === this.version) { if (release.draft || release.prerelease) { @@ -102,7 +102,7 @@ export class GitHubPublisher extends Publisher { let badGatewayCount = 0 uploadAttempt: for (let i = 0; i < 3; i++) { try { - return await this.httpExecutor.doApiRequest({ + return await this.httpExecutor.doApiRequest(configureRequestOptions({ hostname: parsedUrl.hostname, path: parsedUrl.path, method: "POST", @@ -112,7 +112,7 @@ export class GitHubPublisher extends Publisher { "Content-Type": mime.lookup(fileName), "Content-Length": dataLength } - }, this.token, requestProcessor) + }, this.token), requestProcessor) } catch (e) { if (e instanceof HttpError) { @@ -120,10 +120,10 @@ export class GitHubPublisher extends Publisher { // delete old artifact and re-upload log(`Artifact ${fileName} already exists, overwrite one`) - const assets = await githubRequest>(`/repos/${this.info.owner}/${this.info.repo}/releases/${release.id}/assets`, this.token, null) + const assets = await this.githubRequest>(`/repos/${this.info.owner}/${this.info.repo}/releases/${release.id}/assets`, this.token, null) for (const asset of assets) { if (asset!.name === fileName) { - await githubRequest(`/repos/${this.info.owner}/${this.info.repo}/releases/assets/${asset!.id}`, this.token, null, "DELETE") + await this.githubRequest(`/repos/${this.info.owner}/${this.info.repo}/releases/assets/${asset!.id}`, this.token, null, "DELETE") continue uploadAttempt } } @@ -142,7 +142,7 @@ export class GitHubPublisher extends Publisher { } private createRelease() { - return githubRequest(`/repos/${this.info.owner}/${this.info.repo}/releases`, this.token, { + return this.githubRequest(`/repos/${this.info.owner}/${this.info.repo}/releases`, this.token, { tag_name: this.tag, name: this.version, draft: this.options.draft == null || this.options.draft, @@ -153,7 +153,7 @@ export class GitHubPublisher extends Publisher { // test only //noinspection JSUnusedGlobalSymbols async getRelease(): Promise { - return githubRequest(`/repos/${this.info.owner}/${this.info.repo}/releases/${(await this._releasePromise).id}`, this.token) + return this.githubRequest(`/repos/${this.info.owner}/${this.info.repo}/releases/${(await this._releasePromise).id}`, this.token) } //noinspection JSUnusedGlobalSymbols @@ -165,7 +165,7 @@ export class GitHubPublisher extends Publisher { for (let i = 0; i < 3; i++) { try { - return await githubRequest(`/repos/${this.info.owner}/${this.info.repo}/releases/${release.id}`, this.token, null, "DELETE") + return await this.githubRequest(`/repos/${this.info.owner}/${this.info.repo}/releases/${release.id}`, this.token, null, "DELETE") } catch (e) { if (e instanceof HttpError) { @@ -185,10 +185,11 @@ export class GitHubPublisher extends Publisher { warn(`Cannot delete release ${release.id}`) } - // async deleteOldReleases() { - // const releases = await githubRequest>(`/repos/${this.owner}/${this.repo}/releases`, this.token) - // for (const release of releases) { - // await githubRequest(`/repos/${this.owner}/${this.repo}/releases/${release.id}`, this.token, null, "DELETE") - // } - // } + private githubRequest(path: string, token: string | null, data: {[name: string]: any; } | null = null, method?: string): Promise { + return this.httpExecutor.request(configureRequestOptions({ + hostname: "api.github.com", + path: path, + headers: {Accept: "application/vnd.github.v3+json"} + }, token, method), data) + } } \ No newline at end of file diff --git a/packages/electron-builder/src/util/nodeHttpExecutor.ts b/packages/electron-builder/src/util/nodeHttpExecutor.ts index 2673f4ae76b..7b205ac2d8d 100644 --- a/packages/electron-builder/src/util/nodeHttpExecutor.ts +++ b/packages/electron-builder/src/util/nodeHttpExecutor.ts @@ -1,4 +1,3 @@ -import { Socket } from "net" import { IncomingMessage, ClientRequest, Agent } from "http" import * as https from "https" import { ensureDir, readFile } from "fs-extra-p" @@ -6,7 +5,7 @@ import BluebirdPromise from "bluebird-lst-c" import * as path from "path" import { homedir } from "os" import { parse as parseIni } from "ini" -import { HttpExecutor, DownloadOptions, configurePipes, maxRedirects, debug } from "electron-builder-http" +import { HttpExecutor, DownloadOptions } from "electron-builder-http" import { RequestOptions } from "https" import { parse as parseUrl } from "url" @@ -24,7 +23,15 @@ export class NodeHttpExecutor extends HttpExecutor((resolve, reject) => { - this.doDownload(url, destination, 0, options || {}, agent, (error: Error) => { + const parsedUrl = parseUrl(url) + this.doDownload({ + hostname: parsedUrl.hostname, + path: parsedUrl.path, + headers: Object.assign({ + "User-Agent": "electron-builder" + }, options == null ? null : options.headers), + agent: agent, + }, destination, 0, options || {}, (error: Error) => { if (error == null) { resolve(destination) } @@ -35,62 +42,15 @@ export class NodeHttpExecutor extends HttpExecutor void) { - request.on("socket", function (socket: Socket) { - socket.setTimeout(60 * 1000, () => { - callback(new Error("Request timed out")) - request.abort() - }) - }) - } - - private doDownload(url: string, destination: string, redirectCount: number, options: DownloadOptions, agent: Agent, callback: (error: Error | null) => void) { - const parsedUrl = parseUrl(url) - // user-agent must be specified, otherwise some host can return 401 unauthorised - const request = https.request({ - hostname: parsedUrl.hostname, - path: parsedUrl.path, - headers: { - "User-Agent": "electron-builder" - }, - agent: agent, - }, (response: IncomingMessage) => { - if (response.statusCode >= 400) { - callback(new Error(`Cannot download "${url}", status ${response.statusCode}: ${response.statusMessage}`)) - return - } - - const redirectUrl = response.headers.location - if (redirectUrl != null) { - if (redirectCount < maxRedirects) { - this.doDownload(redirectUrl, destination, redirectCount++, options, agent, callback) - } - else { - callback(new Error(`Too many redirects (> ${maxRedirects})`)) - } - return - } - - configurePipes(options, response, destination, callback) - }) - this.addTimeOutHandler(request, callback) - request.on("error", callback) - request.end() - } - - doApiRequest(options: RequestOptions, token: string | null, requestProcessor: (request: ClientRequest, reject: (error: Error) => void) => void, redirectCount: number = 0): Promise { - if (debug.enabled) { - debug(`HTTPS request: ${JSON.stringify(options, null, 2)}`) - } - - if (token != null) { - (options.headers).authorization = token.startsWith("Basic") ? token : `token ${token}` + doApiRequest(options: RequestOptions, requestProcessor: (request: ClientRequest, reject: (error: Error) => void) => void, redirectCount: number = 0): Promise { + if (this.debug.enabled) { + this.debug(`HTTPS request: ${JSON.stringify(options, null, 2)}`) } return new BluebirdPromise((resolve, reject, onCancel) => { const request = https.request(options, (response: IncomingMessage) => { try { - this.handleResponse(response, options, resolve, reject, redirectCount, token, requestProcessor) + this.handleResponse(response, options, resolve, reject, redirectCount, requestProcessor) } catch (e) { reject(e) @@ -102,6 +62,11 @@ export class NodeHttpExecutor extends HttpExecutor request.abort()) }) } + + + protected doRequest(options: any, callback: (response: any) => void): any { + return https.request(options, callback) + } } // only https proxy diff --git a/packages/electron-updater/src/AppUpdater.ts b/packages/electron-updater/src/AppUpdater.ts index 3a38b53c6dc..e5e46098862 100644 --- a/packages/electron-updater/src/AppUpdater.ts +++ b/packages/electron-updater/src/AppUpdater.ts @@ -1,7 +1,7 @@ import { EventEmitter } from "events" import * as path from "path" import { gt as isVersionGreaterThan, valid as parseVersion } from "semver" -import { executorHolder } from "electron-builder-http" +import { RequestHeaders, executorHolder } from "electron-builder-http" import { Provider, UpdateCheckResult, FileInfo, UpdaterSignal } from "./api" import { BintrayProvider } from "./BintrayProvider" import BluebirdPromise from "bluebird-lst-c" @@ -27,6 +27,16 @@ export abstract class AppUpdater extends EventEmitter { */ public autoDownload = true + public requestHeaders: RequestHeaders | null + + /** + * The logger. You can pass [electron-log](https://github.com/megahertz/electron-log), [winston](https://github.com/winstonjs/winston) or another logger with the following interface: `{ info(), warn(), error() }`. + * Set it to `null` if you would like to disable a logging feature. + */ + public logger: Logger | null = (global).__test_app ? null : console + + public readonly signals = new UpdaterSignal(this) + protected updateAvailable = false private clientPromise: Promise> @@ -39,14 +49,6 @@ export abstract class AppUpdater extends EventEmitter { protected versionInfo: VersionInfo | null private fileInfo: FileInfo | null - public readonly signals = new UpdaterSignal(this) - - /** - * The logger. You can pass [electron-log](https://github.com/megahertz/electron-log), [winston](https://github.com/winstonjs/winston) or another logger with the following interface: `{ info(), warn(), error() }`. - * Set it to `null` if you would like to disable a logging feature. - */ - public logger: Logger | null = console - constructor(options: PublishConfiguration | BintrayOptions | GithubOptions | null | undefined) { super() @@ -66,13 +68,13 @@ export abstract class AppUpdater extends EventEmitter { this.untilAppReady = new BluebirdPromise(resolve => { if (this.app.isReady()) { if (this.logger != null) { - this.logger.info("Wait for app ready") + this.logger.info("App is ready") } resolve() } else { if (this.logger != null) { - this.logger.info("App is ready") + this.logger.info("Wait for app ready") } this.app.on("ready", resolve) } @@ -141,6 +143,7 @@ export abstract class AppUpdater extends EventEmitter { private async doCheckForUpdates(): Promise { const client = await this.clientPromise + client.setRequestHeaders(this.requestHeaders) const versionInfo = await client.getLatestVersion() const latestVersion = parseVersion(versionInfo.version) diff --git a/packages/electron-updater/src/BintrayProvider.ts b/packages/electron-updater/src/BintrayProvider.ts index f0006fce047..9ab57d5bbe9 100644 --- a/packages/electron-updater/src/BintrayProvider.ts +++ b/packages/electron-updater/src/BintrayProvider.ts @@ -3,10 +3,12 @@ import { BintrayClient } from "electron-builder-http/out/bintray" import { BintrayOptions, VersionInfo } from "electron-builder-http/out/publishOptions" import { HttpError } from "electron-builder-http" -export class BintrayProvider implements Provider { +export class BintrayProvider extends Provider { private client: BintrayClient constructor(configuration: BintrayOptions) { + super() + this.client = new BintrayClient(configuration) } diff --git a/packages/electron-updater/src/GenericProvider.ts b/packages/electron-updater/src/GenericProvider.ts index ea607a2b6fe..4a5f304d7ca 100644 --- a/packages/electron-updater/src/GenericProvider.ts +++ b/packages/electron-updater/src/GenericProvider.ts @@ -2,13 +2,15 @@ import { Provider, FileInfo, getDefaultChannelName, getChannelFilename, getCurre import { GenericServerOptions, UpdateInfo } from "electron-builder-http/out/publishOptions" import * as url from "url" import * as path from "path" +import { RequestOptions } from "http" import { HttpError, request } from "electron-builder-http" -export class GenericProvider implements Provider { +export class GenericProvider extends Provider { private readonly baseUrl = url.parse(this.configuration.url) private readonly channel = this.configuration.channel || getDefaultChannelName() constructor(private readonly configuration: GenericServerOptions) { + super() } async getLatestVersion(): Promise { @@ -16,7 +18,18 @@ export class GenericProvider implements Provider { const channelFile = getChannelFilename(this.channel) const pathname = path.posix.resolve(this.baseUrl.pathname || "/", `${channelFile}`) try { - result = await request({hostname: this.baseUrl.hostname, port: this.baseUrl.port || "443", path: `${pathname}${this.baseUrl.search || ""}`, protocol: this.baseUrl.protocol}) + const options: RequestOptions = { + hostname: this.baseUrl.hostname, + path: `${pathname}${this.baseUrl.search || ""}`, + protocol: this.baseUrl.protocol, + } + if (this.baseUrl.port != null) { + options.port = parseInt(this.baseUrl.port, 10) + } + if (this.requestHeaders != null) { + options.headers = this.requestHeaders + } + result = await request(options) } catch (e) { if (e instanceof HttpError && e.response.statusCode === 404) { diff --git a/packages/electron-updater/src/GitHubProvider.ts b/packages/electron-updater/src/GitHubProvider.ts index c00281dc051..315fb59bb54 100644 --- a/packages/electron-updater/src/GitHubProvider.ts +++ b/packages/electron-updater/src/GitHubProvider.ts @@ -4,8 +4,9 @@ import { validateUpdateInfo } from "./GenericProvider" import * as path from "path" import { HttpError, request } from "electron-builder-http" -export class GitHubProvider implements Provider { +export class GitHubProvider extends Provider { constructor(private readonly options: GithubOptions) { + super() } async getLatestVersion(): Promise { @@ -14,7 +15,11 @@ export class GitHubProvider implements Provider { try { // do not use API to avoid limit - const releaseInfo = (await request({hostname: "github.com", path: `${basePath}/latest`}, null, null, {Accept: "application/json"})) + const releaseInfo = (await request({ + hostname: "github.com", + path: `${basePath}/latest`, + headers: Object.assign({Accept: "application/json"}, this.requestHeaders) + })) version = (releaseInfo.tag_name.startsWith("v")) ? releaseInfo.tag_name.substring(1) : releaseInfo.tag_name } catch (e) { @@ -25,7 +30,7 @@ export class GitHubProvider implements Provider { const channelFile = getChannelFilename(getDefaultChannelName()) const channelFileUrlPath = `${basePath}/download/v${version}/${channelFile}` try { - result = await request({hostname: "github.com", path: channelFileUrlPath}) + result = await request({hostname: "github.com", path: channelFileUrlPath, headers: this.requestHeaders || undefined}) } catch (e) { if (e instanceof HttpError && e.response.statusCode === 404) { diff --git a/packages/electron-updater/src/MacUpdater.ts b/packages/electron-updater/src/MacUpdater.ts index cb93eb10fef..9d35662953a 100644 --- a/packages/electron-updater/src/MacUpdater.ts +++ b/packages/electron-updater/src/MacUpdater.ts @@ -26,7 +26,7 @@ export class MacUpdater extends AppUpdater { } protected onUpdateAvailable(versionInfo: VersionInfo, fileInfo: FileInfo) { - this.nativeUpdater.setFeedURL((versionInfo).releaseJsonUrl) + this.nativeUpdater.setFeedURL((versionInfo).releaseJsonUrl, this.requestHeaders || undefined) super.onUpdateAvailable(versionInfo, fileInfo) } diff --git a/packages/electron-updater/src/NsisUpdater.ts b/packages/electron-updater/src/NsisUpdater.ts index f537ee78070..5e0f1128c91 100644 --- a/packages/electron-updater/src/NsisUpdater.ts +++ b/packages/electron-updater/src/NsisUpdater.ts @@ -33,6 +33,9 @@ export class NsisUpdater extends AppUpdater { if (fileInfo != null && fileInfo.sha2 != null) { downloadOptions.sha2 = fileInfo.sha2 } + if (this.requestHeaders != null) { + downloadOptions.headers = this.requestHeaders + } const logger = this.logger const tempDir = await mkdtemp(`${path.join(tmpdir(), "up")}-`) diff --git a/packages/electron-updater/src/api.ts b/packages/electron-updater/src/api.ts index 4011e7fc17b..a3619cb9494 100644 --- a/packages/electron-updater/src/api.ts +++ b/packages/electron-updater/src/api.ts @@ -1,5 +1,6 @@ import { VersionInfo } from "electron-builder-http/out/publishOptions" import { EventEmitter } from "events" +import { RequestHeaders } from "electron-builder-http" import { ProgressInfo } from "electron-builder-http/out/ProgressCallbackTransform" export interface FileInfo { @@ -10,10 +11,16 @@ export interface FileInfo { sha2?: string } -export interface Provider { - getLatestVersion(): Promise +export abstract class Provider { + protected requestHeaders: RequestHeaders | null - getUpdateFile(versionInfo: T): Promise + setRequestHeaders(value: RequestHeaders | null) { + this.requestHeaders = value + } + + abstract getLatestVersion(): Promise + + abstract getUpdateFile(versionInfo: T): Promise } // due to historical reasons for windows we use channel name without platform specifier diff --git a/packages/electron-updater/src/electronHttpExecutor.ts b/packages/electron-updater/src/electronHttpExecutor.ts index cb5e0b03e9c..c456c160ee1 100644 --- a/packages/electron-updater/src/electronHttpExecutor.ts +++ b/packages/electron-updater/src/electronHttpExecutor.ts @@ -1,9 +1,8 @@ -import { Socket } from "net" import { net } from "electron" import { ensureDir } from "fs-extra-p" import BluebirdPromise from "bluebird-lst-c" import * as path from "path" -import { HttpExecutor, DownloadOptions, maxRedirects, safeGetHeader, configurePipes, debug } from "electron-builder-http" +import { HttpExecutor, DownloadOptions, dumpRequestOptions } from "electron-builder-http" import { parse as parseUrl } from "url" export class ElectronHttpExecutor extends HttpExecutor { @@ -13,7 +12,17 @@ export class ElectronHttpExecutor extends HttpExecutor((resolve, reject) => { - this.doDownload(url, destination, 0, options || {}, (error: Error) => { + const parsedUrl = parseUrl(url) + + this.doDownload({ + protocol: parsedUrl.protocol, + hostname: parsedUrl.hostname, + path: parsedUrl.path, + port: parsedUrl.port ? parsedUrl.port : undefined, + headers: Object.assign({ + "User-Agent": "electron-builder" + }, options == null ? null : options.headers), + }, destination, 0, options || {}, (error: Error) => { if (error == null) { resolve(destination) } @@ -24,68 +33,20 @@ export class ElectronHttpExecutor extends HttpExecutor void) { - request.on("socket", function (socket: Socket) { - socket.setTimeout(60 * 1000, () => { - callback(new Error("Request timed out")) - request.abort() - }) - }) - } - - private doDownload(url: string, destination: string, redirectCount: number, options: DownloadOptions, callback: (error: Error | null) => void) { - const parsedUrl = parseUrl(url) - // user-agent must be specified, otherwise some host can return 401 unauthorised - - const requestOpts = { - protocol: parsedUrl.protocol, - hostname: parsedUrl.hostname, - path: parsedUrl.path, - port: parsedUrl.port ? +parsedUrl.port : undefined, - headers: { - "User-Agent": "electron-builder" - }, + doApiRequest(options: Electron.RequestOptions, requestProcessor: (request: Electron.ClientRequest, reject: (error: Error) => void) => void, redirectCount: number = 0): Promise { + if (options.Protocol != null) { + // electron typings defines it as incorrect Protocol (uppercase P) + (options).protocol = options.Protocol } - const request = net.request(requestOpts, (response: Electron.IncomingMessage) => { - if (response.statusCode >= 400) { - callback(new Error(`Cannot download "${url}", status ${response.statusCode}: ${response.statusMessage}`)) - return - } - - const redirectUrl = safeGetHeader(response, "location") - if (redirectUrl != null) { - if (redirectCount < maxRedirects) { - this.doDownload(redirectUrl, destination, redirectCount++, options, callback) - } - else { - callback(new Error(`Too many redirects (> ${maxRedirects})`)) - } - return - } - - configurePipes(options, response, destination, callback) - }) - this.addTimeOutHandler(request, callback) - request.on("error", callback) - request.end() - } - - doApiRequest(options: Electron.RequestOptions, token: string | null, requestProcessor: (request: Electron.ClientRequest, reject: (error: Error) => void) => void, redirectCount: number = 0): Promise { - const requestOptions: any = options - if (debug.enabled) { - debug(`request: ${JSON.stringify(requestOptions, null, 2)}`) + if (this.debug.enabled) { + this.debug(`request: ${dumpRequestOptions(options)}`) } - if (token != null) { - (requestOptions.headers).authorization = token.startsWith("Basic") ? token : `token ${token}` - } - - requestOptions.protocol = options.Protocol || "https:" return new BluebirdPromise((resolve, reject, onCancel) => { const request = net.request(options, response => { try { - this.handleResponse(response, options, resolve, reject, redirectCount, token, requestProcessor) + this.handleResponse(response, options, resolve, reject, redirectCount, requestProcessor) } catch (e) { reject(e) @@ -97,4 +58,9 @@ export class ElectronHttpExecutor extends HttpExecutor request.abort()) }) } + + + protected doRequest(options: any, callback: (response: any) => void): any { + return net.request(options, callback) + } } \ No newline at end of file diff --git a/test/src/nsisUpdaterTest.ts b/test/src/nsisUpdaterTest.ts index 6bfaba0387d..2b90ae0a0ee 100644 --- a/test/src/nsisUpdaterTest.ts +++ b/test/src/nsisUpdaterTest.ts @@ -100,6 +100,7 @@ test("sha2 mismatch error event", async () => { })) g.__test_resourcesPath = testResourcesPath const updater = new NsisUpdater() + updater.logger = console const actualEvents = trackEvents(updater) diff --git a/yarn.lock b/yarn.lock index bd9aa852e1e..7db0c4ed2e7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -528,7 +528,7 @@ browser-resolve@^1.11.2: dependencies: resolve "1.1.7" -bser@^1.0.2: +bser@1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/bser/-/bser-1.0.2.tgz#381116970b2a6deea5646dd15dd7278444b56169" dependencies: @@ -992,10 +992,10 @@ fast-levenshtein@~2.0.4: resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" fb-watchman@^1.8.0, fb-watchman@^1.9.0: - version "1.9.0" - resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-1.9.0.tgz#6f268f1f347a6b3c875d1e89da7e1ed79adfc0ec" + version "1.9.2" + resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-1.9.2.tgz#a24cf47827f82d38fb59a69ad70b76e3b6ae7383" dependencies: - bser "^1.0.2" + bser "1.0.2" filename-regex@^2.0.0: version "2.0.0" @@ -2555,8 +2555,8 @@ sntp@1.x.x: hoek "2.x.x" source-map-support@^0.4.10, source-map-support@^0.4.2: - version "0.4.10" - resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.4.10.tgz#d7b19038040a14c0837a18e630a196453952b378" + version "0.4.11" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.4.11.tgz#647f939978b38535909530885303daf23279f322" dependencies: source-map "^0.5.3"