diff --git a/apps/electron/dev-app-update.yml b/apps/electron/dev-app-update.yml index 3b33847953..36975868ea 100644 --- a/apps/electron/dev-app-update.yml +++ b/apps/electron/dev-app-update.yml @@ -1,4 +1,4 @@ owner: toeverything repo: AFFiNE -provider: github +provider: custom private: false diff --git a/apps/electron/resources/app-update.yml b/apps/electron/resources/app-update.yml index 3b33847953..36975868ea 100644 --- a/apps/electron/resources/app-update.yml +++ b/apps/electron/resources/app-update.yml @@ -1,4 +1,4 @@ owner: toeverything repo: AFFiNE -provider: github +provider: custom private: false diff --git a/apps/electron/scripts/common.mjs b/apps/electron/scripts/common.mjs index 25de256427..44e71612e2 100644 --- a/apps/electron/scripts/common.mjs +++ b/apps/electron/scripts/common.mjs @@ -44,6 +44,7 @@ export const config = () => { 'electron-updater', '@toeverything/plugin-infra', 'yjs', + 'semver', ], define: define, format: 'cjs', diff --git a/apps/electron/src/main/updater/custom-github-provider.ts b/apps/electron/src/main/updater/custom-github-provider.ts new file mode 100644 index 0000000000..55bf60102f --- /dev/null +++ b/apps/electron/src/main/updater/custom-github-provider.ts @@ -0,0 +1,250 @@ +// credits: migrated from https://github.com/electron-userland/electron-builder/blob/master/packages/electron-updater/src/providers/GitHubProvider.ts +import type { + CustomPublishOptions, + GithubOptions, + ReleaseNoteInfo, + XElement, +} from 'builder-util-runtime'; +import { HttpError, newError, parseXml } from 'builder-util-runtime'; +import { + type AppUpdater, + CancellationToken, + type ResolvedUpdateFileInfo, + type UpdateInfo, +} from 'electron-updater'; +import { BaseGitHubProvider } from 'electron-updater/out/providers/GitHubProvider'; +import { + parseUpdateInfo, + type ProviderRuntimeOptions, + resolveFiles, +} from 'electron-updater/out/providers/Provider'; +import * as semver from 'semver'; + +interface GithubUpdateInfo extends UpdateInfo { + tag: string; +} + +const hrefRegExp = /\/tag\/([^/]+)$/; + +export class CustomGitHubProvider extends BaseGitHubProvider { + constructor( + options: CustomPublishOptions, + private updater: AppUpdater, + runtimeOptions: ProviderRuntimeOptions + ) { + super(options as unknown as GithubOptions, 'github.com', runtimeOptions); + } + + async getLatestVersion(): Promise { + const cancellationToken = new CancellationToken(); + + const feedXml = await this.httpRequest( + newUrlFromBase(`${this.basePath}.atom`, this.baseUrl), + { + accept: 'application/xml, application/atom+xml, text/xml, */*', + }, + cancellationToken + ); + + if (!feedXml) { + throw new Error( + `Cannot find feed in the remote server (${this.baseUrl.href})` + ); + } + + const feed = parseXml(feedXml); + // noinspection TypeScriptValidateJSTypes + const latestRelease = feed.element( + 'entry', + false, + `No published versions on GitHub` + ); + let tag: string | null = null; + try { + const currentChannel = + this.options.channel || + this.updater?.channel || + (semver.prerelease(this.updater.currentVersion)?.[0] as string) || + null; + + if (currentChannel === null) { + throw newError( + `Cannot parse channel from version: ${this.updater.currentVersion}`, + 'ERR_UPDATER_INVALID_VERSION' + ); + } + + 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 + // If it is null, we believe it is stable version + const hrefChannel = + (semver.prerelease(hrefTag)?.[0] as string) || 'stable'; + + const isNextPreRelease = hrefChannel === currentChannel; + if (isNextPreRelease) { + tag = hrefTag; + 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 | null = null; + 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 { + const channel = this.updater.allowPrerelease + ? this.getCustomChannelName( + String(semver.prerelease(tag)?.[0] || 'latest') + ) + : this.getDefaultChannelName(); + 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 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}`; + } +} + +export interface CustomGitHubOptions { + channel: string; + repo: string; + owner: string; + releaseType: 'release' | 'prerelease'; +} + +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 (versionRelease && semver.lt(currentVersion, versionRelease)) { + releaseNotes.push({ + version: versionRelease, + note: getNoteValue(release), + }); + } + } + return releaseNotes.sort((a, b) => semver.rcompare(a.version, b.version)); +} + +// addRandomQueryToAvoidCaching is false by default because in most cases URL already contains version number, +// so, it makes sense only for Generic Provider for channel files +function newUrlFromBase( + pathname: string, + baseUrl: URL, + addRandomQueryToAvoidCaching = false +): URL { + const result = new URL(pathname, baseUrl); + // search is not propagated (search is an empty string if not specified) + const search = baseUrl.search; + if (search != null && search.length !== 0) { + result.search = search; + } else if (addRandomQueryToAvoidCaching) { + result.search = `noCache=${Date.now().toString(32)}`; + } + return result; +} + +function getChannelFilename(channel: string): string { + return `${channel}.yml`; +} diff --git a/apps/electron/src/main/updater/electron-updater.ts b/apps/electron/src/main/updater/electron-updater.ts index a764cf8e01..6615860fd5 100644 --- a/apps/electron/src/main/updater/electron-updater.ts +++ b/apps/electron/src/main/updater/electron-updater.ts @@ -4,6 +4,7 @@ import { z } from 'zod'; import { isMacOS, isWindows } from '../../shared/utils'; import { logger } from '../logger'; +import { CustomGitHubProvider } from './custom-github-provider'; import { updaterSubjects } from './event'; export const ReleaseTypeSchema = z.enum([ @@ -36,7 +37,7 @@ export const checkForUpdates = async (force = true) => { export const registerUpdater = async () => { // skip auto update in dev mode & internal - if (isDev || buildType === 'internal') { + if (buildType === 'internal') { return; } @@ -51,11 +52,14 @@ export const registerUpdater = async () => { const feedUrl: Parameters[0] = { channel: buildType, - provider: 'github', + // hack for custom provider + provider: 'custom' as 'github', // @ts-expect-error - just ignore for now repo: buildType !== 'internal' ? 'AFFiNE' : 'AFFiNE-Releases', owner: 'toeverything', releaseType: buildType === 'stable' ? 'release' : 'prerelease', + // @ts-expect-error hack for custom provider + updateProvider: CustomGitHubProvider, }; logger.debug('auto-updater feed config', feedUrl);