fix(electron): app updater (#8043)

This commit is contained in:
forehalo
2024-09-02 07:53:17 +00:00
parent a802dc4fd6
commit d9cedf89e1
22 changed files with 537 additions and 730 deletions

View File

@@ -0,0 +1,162 @@
// credits: migrated from https://github.com/electron-userland/electron-builder/blob/master/packages/electron-updater/src/providers/GitHubProvider.ts
import type { CustomPublishOptions } from 'builder-util-runtime';
import { newError } from 'builder-util-runtime';
import type {
AppUpdater,
ResolvedUpdateFileInfo,
UpdateFileInfo,
UpdateInfo,
} from 'electron-updater';
import { CancellationToken, Provider } from 'electron-updater';
import type { ProviderRuntimeOptions } from 'electron-updater/out/providers/Provider';
import {
getFileList,
parseUpdateInfo,
} from 'electron-updater/out/providers/Provider';
import type { buildType } from '../config';
import { isSquirrelBuild } from './utils';
interface GithubUpdateInfo extends UpdateInfo {
tag: string;
}
interface GithubRelease {
name: string;
tag_name: string;
published_at: string;
assets: Array<{
name: string;
url: string;
}>;
}
interface UpdateProviderOptions {
feedUrl?: string;
channel: typeof buildType;
}
export class AFFiNEUpdateProvider extends Provider<GithubUpdateInfo> {
static configFeed(options: UpdateProviderOptions): CustomPublishOptions {
return {
provider: 'custom',
feedUrl: 'https://affine.pro/api/worker/releases',
updateProvider: AFFiNEUpdateProvider,
...options,
};
}
constructor(
private readonly options: CustomPublishOptions,
_updater: AppUpdater,
runtimeOptions: ProviderRuntimeOptions
) {
super(runtimeOptions);
}
get feedUrl(): URL {
const url = new URL(this.options.feedUrl);
url.searchParams.set('channel', this.options.channel);
url.searchParams.set('minimal', 'true');
return url;
}
async getLatestVersion(): Promise<GithubUpdateInfo> {
const cancellationToken = new CancellationToken();
const releasesJsonStr = await this.httpRequest(
this.feedUrl,
{
accept: 'application/json',
'cache-control': 'no-cache',
},
cancellationToken
);
if (!releasesJsonStr) {
throw new Error(
`Failed to get releases from ${this.feedUrl.toString()}, response is empty`
);
}
const releases = JSON.parse(releasesJsonStr);
if (releases.length === 0) {
throw new Error(
`No published versions in channel ${this.options.channel}`
);
}
const latestRelease = releases[0] as GithubRelease;
const tag = latestRelease.tag_name;
const channelFileName = getChannelFilename(this.getDefaultChannelName());
const channelFileAsset = latestRelease.assets.find(({ url }) =>
url.endsWith(channelFileName)
);
if (!channelFileAsset) {
throw newError(
`Cannot find ${channelFileName} in the latest release artifacts.`,
'ERR_UPDATER_CHANNEL_FILE_NOT_FOUND'
);
}
const channelFileUrl = new URL(channelFileAsset.url);
const channelFileContent = await this.httpRequest(channelFileUrl);
const result = parseUpdateInfo(
channelFileContent,
channelFileName,
channelFileUrl
);
const files: UpdateFileInfo[] = [];
result.files.forEach(file => {
const asset = latestRelease.assets.find(({ name }) => name === file.url);
if (asset) {
file.url = asset.url;
}
// for windows, we need to determine its installer type (nsis or squirrel)
if (process.platform === 'win32') {
const isSquirrel = isSquirrelBuild();
if (isSquirrel && file.url.endsWith('.nsis.exe')) {
return;
}
}
files.push(file);
});
if (result.releaseName == null) {
result.releaseName = latestRelease.name;
}
if (result.releaseNotes == null) {
// TODO(@forehalo): add release notes
result.releaseNotes = '';
}
return {
tag: tag,
...result,
};
}
resolveFiles(updateInfo: GithubUpdateInfo): Array<ResolvedUpdateFileInfo> {
const files = getFileList(updateInfo);
return files.map(file => ({
url: new URL(file.url),
info: file,
}));
}
}
function getChannelFilename(channel: string): string {
return `${channel}.yml`;
}

View File

@@ -1,332 +0,0 @@
// 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,
ResolvedUpdateFileInfo,
UpdateInfo,
} from 'electron-updater';
import { CancellationToken } from 'electron-updater';
import { BaseGitHubProvider } from 'electron-updater/out/providers/GitHubProvider';
import type { ProviderRuntimeOptions } from 'electron-updater/out/providers/Provider';
import {
parseUpdateInfo,
resolveFiles,
} from 'electron-updater/out/providers/Provider';
import * as semver from 'semver';
import { isSquirrelBuild } from './utils';
interface GithubUpdateInfo extends UpdateInfo {
tag: string;
}
interface GithubRelease {
id: number;
tag_name: string;
target_commitish: string;
name: string;
draft: boolean;
prerelease: boolean;
created_at: string;
published_at: string;
}
const hrefRegExp = /\/tag\/([^/]+)$/;
export class CustomGitHubProvider extends BaseGitHubProvider<GithubUpdateInfo> {
constructor(
options: CustomPublishOptions,
private readonly updater: AppUpdater,
runtimeOptions: ProviderRuntimeOptions
) {
super(options as unknown as GithubOptions, 'github.com', runtimeOptions);
}
async getLatestVersion(): Promise<GithubUpdateInfo> {
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
let 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'
);
}
const releaseTag = await this.getLatestTagByRelease(
currentChannel,
cancellationToken
);
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';
let isNextPreRelease = false;
if (releaseTag) {
isNextPreRelease = releaseTag === hrefTag;
} else {
isNextPreRelease = hrefChannel === currentChannel;
}
if (isNextPreRelease) {
tag = hrefTag;
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 || tag === undefined) {
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`;
}
/**
* Use release api to get latest version to filter draft version.
* But this api have low request limit 60-times/1-hour, use this to help, not depend on it
* https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28
* https://api.github.com/repos/toeverything/affine/releases
* https://docs.github.com/en/rest/rate-limit/rate-limit?apiVersion=2022-11-28#about-rate-limits
*/
private async getLatestTagByRelease(
currentChannel: string,
cancellationToken: CancellationToken
) {
try {
const releasesStr = await this.httpRequest(
newUrlFromBase(`/repos${this.basePath}`, this.baseApiUrl),
{
accept: 'Accept: application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
},
cancellationToken
);
if (!releasesStr) {
return null;
}
const releases: GithubRelease[] = JSON.parse(releasesStr);
for (const release of releases) {
if (release.draft) {
continue;
}
const releaseTag = release.tag_name;
const releaseChannel =
(semver.prerelease(releaseTag)?.[0] as string) || 'stable';
if (releaseChannel === currentChannel) {
return release.tag_name;
}
}
} catch (e: any) {
console.info(`Cannot parse release: ${e.stack || e.message}`);
}
return null;
}
resolveFiles(updateInfo: GithubUpdateInfo): Array<ResolvedUpdateFileInfo> {
const filteredUpdateInfo = structuredClone(updateInfo);
// for windows, we need to determine its installer type (nsis or squirrel)
if (process.platform === 'win32' && updateInfo.files.length > 1) {
const isSquirrel = isSquirrelBuild();
// @ts-expect-error we should be able to modify the object
filteredUpdateInfo.files = updateInfo.files.filter(file => {
return isSquirrel
? !file.url.includes('nsis.exe')
: file.url.includes('nsis.exe');
});
}
// still replace space to - due to backward compatibility
return resolveFiles(filteredUpdateInfo, this.baseUrl, p =>
this.getBaseDownloadPath(filteredUpdateInfo.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 <content>No content.</content>
return result === 'No content.' ? '' : result;
}
export function computeReleaseNotes(
currentVersion: semver.SemVer,
isFullChangelog: boolean,
feed: XElement,
latestRelease: any
): string | Array<ReleaseNoteInfo> | null {
if (!isFullChangelog) {
return getNoteValue(latestRelease);
}
const releaseNotes: Array<ReleaseNoteInfo> = [];
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`;
}

View File

@@ -3,7 +3,7 @@ import { autoUpdater as defaultAutoUpdater } from 'electron-updater';
import { buildType } from '../config';
import { logger } from '../logger';
import { CustomGitHubProvider } from './custom-github-provider';
import { AFFiNEUpdateProvider } from './affine-update-provider';
import { updaterSubjects } from './event';
import { WindowsUpdater } from './windows-updater';
@@ -93,16 +93,9 @@ export const registerUpdater = async () => {
autoUpdater.autoInstallOnAppQuit = false;
autoUpdater.autoRunAppAfterInstall = true;
const feedUrl: Parameters<typeof autoUpdater.setFeedURL>[0] = {
const feedUrl = AFFiNEUpdateProvider.configFeed({
channel: buildType,
// hack for custom provider
provider: 'custom' as 'github',
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);