mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 20:38:52 +00:00
fix(electron): app updater (#8043)
This commit is contained in:
@@ -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`;
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user