import { CancellationToken, GithubOptions, githubUrl, HttpError, newError, parseXml, ReleaseNoteInfo, UpdateInfo, XElement } from "builder-util-runtime" import * as semver from "semver" import { URL } from "url" import { AppUpdater } from "../AppUpdater" import { ResolvedUpdateFileInfo } from "../types" import { getChannelFilename, newBaseUrl, newUrlFromBase } from "../util" import { parseUpdateInfo, Provider, ProviderRuntimeOptions, resolveFiles } from "./Provider" const hrefRegExp = /\/tag\/([^/]+)$/ interface GithubUpdateInfo extends UpdateInfo { tag: string } export abstract class BaseGitHubProvider extends Provider { // so, we don't need to parse port (because node http doesn't support host as url does) protected readonly baseUrl: URL protected readonly baseApiUrl: URL protected constructor( protected readonly options: GithubOptions, defaultHost: string, runtimeOptions: ProviderRuntimeOptions ) { super({ ...runtimeOptions, /* because GitHib uses S3 */ isUseMultipleRangeRequest: false, }) this.baseUrl = newBaseUrl(githubUrl(options, defaultHost)) const apiHost = defaultHost === "github.com" ? "api.github.com" : defaultHost this.baseApiUrl = newBaseUrl(githubUrl(options, apiHost)) } protected computeGithubBasePath(result: string): string { // https://github.com/electron-userland/electron-builder/issues/1903#issuecomment-320881211 const host = this.options.host return host && !["github.com", "api.github.com"].includes(host) ? `/api/v3${result}` : result } } export class GitHubProvider extends BaseGitHubProvider { constructor( protected readonly options: GithubOptions, private readonly updater: AppUpdater, runtimeOptions: ProviderRuntimeOptions ) { super(options, "github.com", runtimeOptions) } private get channel(): string { const result = this.updater.channel || this.options.channel return result == null ? this.getDefaultChannelName() : this.getCustomChannelName(result) } async getLatestVersion(): Promise { const cancellationToken = new CancellationToken() const feedXml: string = (await this.httpRequest( newUrlFromBase(`${this.basePath}.atom`, this.baseUrl), { accept: "application/xml, application/atom+xml, text/xml, */*", }, cancellationToken ))! const feed = parseXml(feedXml) // noinspection TypeScriptValidateJSTypes let latestRelease = feed.element("entry", false, `No published versions on GitHub`) let tag: string | null = null try { if (this.updater.allowPrerelease) { const currentChannel = this.updater?.channel || (semver.prerelease(this.updater.currentVersion)?.[0] as string) || null if (currentChannel === null) { // noinspection TypeScriptValidateJSTypes tag = hrefRegExp.exec(latestRelease.element("link").attribute("href"))![1] } else { for (const element of feed.getElements("entry")) { // noinspection TypeScriptValidateJSTypes const hrefElement = hrefRegExp.exec(element.element("link").attribute("href"))! // If this is null then something is wrong and skip this release if (hrefElement === null) continue // This Release's Tag const hrefTag = hrefElement[1] //Get Channel from this release's tag const hrefChannel = (semver.prerelease(hrefTag)?.[0] as string) || null const shouldFetchVersion = !currentChannel || ["alpha", "beta"].includes(currentChannel) const isCustomChannel = hrefChannel !== null && !["alpha", "beta"].includes(String(hrefChannel)) // Allow moving from alpha to beta but not down const channelMismatch = currentChannel === "beta" && hrefChannel === "alpha" if (shouldFetchVersion && !isCustomChannel && !channelMismatch) { tag = hrefTag break } const isNextPreRelease = hrefChannel && hrefChannel === currentChannel if (isNextPreRelease) { tag = hrefTag break } } } } else { tag = await this.getLatestTagName(cancellationToken) for (const element of feed.getElements("entry")) { // noinspection TypeScriptValidateJSTypes if (hrefRegExp.exec(element.element("link").attribute("href"))![1] === tag) { latestRelease = element break } } } } catch (e: any) { throw newError(`Cannot parse releases feed: ${e.stack || e.message},\nXML:\n${feedXml}`, "ERR_UPDATER_INVALID_RELEASE_FEED") } if (tag == null) { throw newError(`No published versions on GitHub`, "ERR_UPDATER_NO_PUBLISHED_VERSIONS") } let rawData: string let channelFile = "" let channelFileUrl: any = "" const fetchData = async (channelName: string) => { channelFile = getChannelFilename(channelName) channelFileUrl = newUrlFromBase(this.getBaseDownloadPath(String(tag), channelFile), this.baseUrl) const requestOptions = this.createRequestOptions(channelFileUrl) try { return (await this.executor.request(requestOptions, cancellationToken))! } catch (e: any) { if (e instanceof HttpError && e.statusCode === 404) { throw newError(`Cannot find ${channelFile} in the latest release artifacts (${channelFileUrl}): ${e.stack || e.message}`, "ERR_UPDATER_CHANNEL_FILE_NOT_FOUND") } throw e } } try { let channel = this.channel if (this.updater.allowPrerelease && semver.prerelease(tag)?.[0]) { channel = this.getCustomChannelName(String(semver.prerelease(tag)?.[0])) } rawData = await fetchData(channel) } catch (e: any) { if (this.updater.allowPrerelease) { // Allow fallback to `latest.yml` rawData = await fetchData(this.getDefaultChannelName()) } else { throw e } } const result = parseUpdateInfo(rawData, channelFile, channelFileUrl) if (result.releaseName == null) { result.releaseName = latestRelease.elementValueOrEmpty("title") } if (result.releaseNotes == null) { result.releaseNotes = computeReleaseNotes(this.updater.currentVersion, this.updater.fullChangelog, feed, latestRelease) } return { tag: tag, ...result, } } private async getLatestTagName(cancellationToken: CancellationToken): Promise { const options = this.options // do not use API for GitHub to avoid limit, only for custom host or GitHub Enterprise const url = options.host == null || options.host === "github.com" ? newUrlFromBase(`${this.basePath}/latest`, this.baseUrl) : new URL(`${this.computeGithubBasePath(`/repos/${options.owner}/${options.repo}/releases`)}/latest`, this.baseApiUrl) try { const rawData = await this.httpRequest(url, { Accept: "application/json" }, cancellationToken) if (rawData == null) { return null } const releaseInfo: GithubReleaseInfo = JSON.parse(rawData) return releaseInfo.tag_name } catch (e: any) { throw newError(`Unable to find latest version on GitHub (${url}), please ensure a production release exists: ${e.stack || e.message}`, "ERR_UPDATER_LATEST_VERSION_NOT_FOUND") } } private get basePath(): string { return `/${this.options.owner}/${this.options.repo}/releases` } resolveFiles(updateInfo: GithubUpdateInfo): Array { // still replace space to - due to backward compatibility return resolveFiles(updateInfo, this.baseUrl, p => this.getBaseDownloadPath(updateInfo.tag, p.replace(/ /g, "-"))) } private getBaseDownloadPath(tag: string, fileName: string): string { return `${this.basePath}/download/${tag}/${fileName}` } } interface GithubReleaseInfo { readonly tag_name: string } function getNoteValue(parent: XElement): string { const result = parent.elementValueOrEmpty("content") // GitHub reports empty notes as No content. return result === "No content." ? "" : result } export function computeReleaseNotes(currentVersion: semver.SemVer, isFullChangelog: boolean, feed: XElement, latestRelease: any): string | Array | null { if (!isFullChangelog) { return getNoteValue(latestRelease) } const releaseNotes: Array = [] for (const release of feed.getElements("entry")) { // noinspection TypeScriptValidateJSTypes const versionRelease = /\/tag\/v?([^/]+)$/.exec(release.element("link").attribute("href"))![1] if (semver.lt(currentVersion, versionRelease)) { releaseNotes.push({ version: versionRelease, note: getNoteValue(release), }) } } return releaseNotes.sort((a, b) => semver.rcompare(a.version, b.version)) }