add handlers and setting for nightly release

This commit is contained in:
jeffvli
2026-02-05 23:45:32 -08:00
parent 65c215fa9c
commit cf663de2fc
11 changed files with 403 additions and 51 deletions
+7 -1
View File
@@ -448,6 +448,11 @@
"radioList": {
"title": "radio stations"
},
"releasenotes": {
"commitsSinceStable": "commits since {{stable}}",
"noNewCommits": "no new commits in this range",
"noStableReleaseToCompare": "no stable release available to compare with"
},
"favorites": {
"title": "$t(entity.favorite, {\"count\": 2})"
},
@@ -745,10 +750,11 @@
"customFontPath_description": "sets the path to the custom font to use for the application",
"customFontPath": "custom font path",
"disableAutomaticUpdates": "disable automatic updates",
"releaseChannel_optionAlpha": "alpha (nightly)",
"releaseChannel_optionBeta": "beta",
"releaseChannel_optionLatest": "latest",
"releaseChannel": "release channel",
"releaseChannel_description": "choose between stable releases or beta releases for automatic updates",
"releaseChannel_description": "choose between stable, beta, or alpha (nightly) releases for automatic updates",
"disableLibraryUpdateOnStartup": "disable checking for new versions on startup",
"discordApplicationId_description": "the application id for {{discord}} rich presence (defaults to {{defaultId}})",
"discordApplicationId": "{{discord}} application id",
+127 -26
View File
@@ -18,7 +18,7 @@ import {
} from 'electron';
import electronLocalShortcut from 'electron-localshortcut';
import log from 'electron-log/main';
import { autoUpdater } from 'electron-updater';
import { AppImageUpdater, autoUpdater, MacUpdater, NsisUpdater } from 'electron-updater';
import { access, constants } from 'fs';
import path, { join } from 'path';
@@ -40,40 +40,111 @@ import './features';
import { PlayerType, TitleTheme } from '/@/shared/types/types';
export default class AppUpdater {
const ALPHA_UPDATER_CONFIG: {
bucket: string;
channel: string;
endpoint: string;
provider: 's3';
} = {
bucket: '',
channel: 'alpha',
endpoint: 'https://feishin-nightly-bucket.jeffvli.org',
provider: 's3',
};
type UpdaterInstance = AppImageUpdater | MacUpdater | NsisUpdater | typeof autoUpdater;
class AlphaAppUpdater {
constructor() {
const updater = createAlphaUpdaterInstance();
log.transports.file.level = 'info';
autoUpdater.logger = autoUpdaterLogInterface;
updater.logger = autoUpdaterLogInterface;
updater.channel = ALPHA_UPDATER_CONFIG.channel;
updater.allowPrerelease = true;
updater.disableDifferentialDownload = true;
updater.allowDowngrade = true;
updater.autoInstallOnAppQuit = true;
updater.autoRunAppAfterInstall = true;
updater.checkForUpdatesAndNotify();
}
}
const isBetaVersion = packageJson.version.includes('-beta');
const releaseChannel = store.get('release_channel');
const isNotConfigured = !releaseChannel;
console.log('Release channel: ', releaseChannel);
console.log('Is beta version: ', isBetaVersion);
if (isNotConfigured) {
console.log(
'Release channel not configured, setting to ',
isBetaVersion ? 'beta' : 'latest',
);
store.set('release_channel', isBetaVersion ? 'beta' : 'latest');
}
if (releaseChannel === 'beta') {
autoUpdater.channel = 'beta';
autoUpdater.allowPrerelease = true;
autoUpdater.disableDifferentialDownload = true;
} else if (releaseChannel === 'latest') {
autoUpdater.channel = 'latest';
autoUpdater.allowDowngrade = true;
autoUpdater.allowPrerelease = false;
class AppUpdater {
constructor() {
const effectiveChannel = store.get('release_channel') as string;
console.log('Effective update channel:', effectiveChannel);
if (effectiveChannel === 'alpha') {
return new AlphaAppUpdater();
}
configureAndGetUpdater();
autoUpdater.checkForUpdatesAndNotify();
}
}
function configureAndGetUpdater(): UpdaterInstance {
const isBetaVersion = packageJson.version.includes('-beta');
const isAlphaVersion = packageJson.version.includes('-alpha');
let releaseChannel = store.get('release_channel');
const isNotConfigured = !releaseChannel;
console.log('Release channel:', releaseChannel);
console.log('Is beta version:', isBetaVersion);
console.log('Is alpha version:', isAlphaVersion);
console.log('Is not configured:', isNotConfigured);
if (isNotConfigured) {
console.log('Release channel not configured, setting default channel');
const defaultChannel = isAlphaVersion ? 'alpha' : isBetaVersion ? 'beta' : 'latest';
store.set('release_channel', defaultChannel);
releaseChannel = defaultChannel;
}
const effectiveChannel = store.get('release_channel') as string;
if (effectiveChannel === 'alpha') {
const updater = createAlphaUpdaterInstance();
log.transports.file.level = 'info';
updater.logger = autoUpdaterLogInterface;
updater.channel = ALPHA_UPDATER_CONFIG.channel;
updater.allowPrerelease = true;
updater.disableDifferentialDownload = true;
updater.allowDowngrade = true;
updater.autoInstallOnAppQuit = true;
updater.autoRunAppAfterInstall = true;
return updater;
}
log.transports.file.level = 'info';
autoUpdater.logger = autoUpdaterLogInterface;
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.autoRunAppAfterInstall = true;
if (effectiveChannel === 'beta') {
autoUpdater.channel = 'beta';
autoUpdater.allowPrerelease = true;
autoUpdater.disableDifferentialDownload = true;
} else {
autoUpdater.channel = 'latest';
autoUpdater.allowDowngrade = true;
autoUpdater.allowPrerelease = false;
}
return autoUpdater;
}
function createAlphaUpdaterInstance(): AppImageUpdater | MacUpdater | NsisUpdater {
if (isMacOS()) {
return new MacUpdater(ALPHA_UPDATER_CONFIG);
}
if (isLinux()) {
return new AppImageUpdater(ALPHA_UPDATER_CONFIG);
}
return new NsisUpdater(ALPHA_UPDATER_CONFIG);
}
protocol.registerSchemesAsPrivileged([{ privileges: { bypassCSP: true }, scheme: 'feishin' }]);
process.on('uncaughtException', (error: any) => {
@@ -359,6 +430,36 @@ async function createWindow(first = true): Promise<void> {
return mainWindow?.webContents.session.clearCache();
});
ipcMain.handle(
'app-check-for-updates',
async (): Promise<{ updateAvailable: boolean; version?: string }> => {
if (disableAutoUpdates()) {
console.log('Auto updates are disabled');
return { updateAvailable: false };
}
try {
console.log('Checking for updates');
const updater = configureAndGetUpdater();
const result = await updater.checkForUpdates();
const updateAvailable = result?.isUpdateAvailable ?? false;
console.log('Update available:', updateAvailable);
if (updateAvailable && store.get('disable_auto_updates') !== true) {
console.log('Downloading update');
updater.downloadUpdate();
}
return {
updateAvailable,
version: result?.updateInfo?.version,
};
} catch {
console.log('Error checking for updates');
return { updateAvailable: false };
}
},
);
ipcMain.on('app-restart', () => {
// Fix for .AppImage
if (process.env.APPIMAGE) {
+5
View File
@@ -39,6 +39,10 @@ const download = (url: string) => {
ipcRenderer.send('download-url', url);
};
const checkForUpdates = (): Promise<{ updateAvailable: boolean; version?: string }> => {
return ipcRenderer.invoke('app-check-for-updates');
};
const forceGarbageCollection = (): boolean => {
try {
if (typeof global.gc === 'function') {
@@ -58,6 +62,7 @@ const forceGarbageCollection = (): boolean => {
};
export const utils = {
checkForUpdates,
disableAutoUpdates,
download,
forceGarbageCollection,
+2
View File
@@ -11,6 +11,7 @@ import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react';
import i18n from '/@/i18n/i18n';
import { WebAudioContext } from '/@/renderer/features/player/context/webaudio-context';
import { useCheckForUpdates } from '/@/renderer/hooks/use-check-for-updates';
import { useSyncSettingsToMain } from '/@/renderer/hooks/use-sync-settings-to-main';
import { AppRouter } from '/@/renderer/router/app-router';
import { useCssSettings, useHotkeySettings, useLanguage } from '/@/renderer/store';
@@ -38,6 +39,7 @@ export const App = () => {
const cssRef = useRef<HTMLStyleElement | null>(null);
useSyncSettingsToMain();
useCheckForUpdates();
const [webAudio, setWebAudio] = useState<WebAudio>();
@@ -41,6 +41,13 @@ export const UpdateSettings = memo(() => {
}),
value: 'beta',
},
{
label: t('setting.releaseChannel', {
context: 'optionAlpha',
postProcess: 'titleCase',
}),
value: 'alpha',
},
]}
defaultValue={
(localSettings?.get('release_channel') as string | undefined) || 'latest'
@@ -50,7 +57,7 @@ export const UpdateSettings = memo(() => {
localSettings?.set('release_channel', value);
setSettings({
window: {
releaseChannel: value as 'beta' | 'latest',
releaseChannel: value as 'alpha' | 'beta' | 'latest',
},
});
}}
+1
View File
@@ -1,4 +1,5 @@
export * from './use-app-focus';
export * from './use-check-for-updates';
export * from './use-container-query';
export * from './use-fast-average-color';
export * from './use-hide-scrollbar';
@@ -0,0 +1,29 @@
import { useQuery } from '@tanstack/react-query';
import isElectron from 'is-electron';
import { useEffect, useState } from 'react';
const CHECK_FOR_UPDATES_INTERVAL_MS = 6 * 60 * 60 * 1000;
const utils = isElectron() ? window.api?.utils : null;
export const useCheckForUpdates = () => {
const [enablePeriodicCheck, setEnablePeriodicCheck] = useState(false);
// We want to skip the first check since it's already checked in the main process when the app is started
useEffect(() => {
const timer = setTimeout(() => setEnablePeriodicCheck(true), CHECK_FOR_UPDATES_INTERVAL_MS);
return () => clearTimeout(timer);
}, []);
const isEnabled =
enablePeriodicCheck &&
Boolean(isElectron() && utils?.checkForUpdates && !utils?.disableAutoUpdates?.());
return useQuery({
enabled: isEnabled,
queryFn: () => utils?.checkForUpdates?.(),
queryKey: ['app-check-for-updates'],
refetchInterval: CHECK_FOR_UPDATES_INTERVAL_MS,
refetchIntervalInBackground: true,
});
};
+187 -6
View File
@@ -20,11 +20,27 @@ import { Text } from '/@/shared/components/text/text';
import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
const GITHUB_RELEASES_URL = 'https://api.github.com/repos/jeffvli/feishin/releases';
const GITHUB_COMPARE_URL = 'https://api.github.com/repos/jeffvli/feishin/compare';
const RELEASES_TO_FETCH = 30;
interface GitHubCompareCommit {
commit: {
author: { date: string; name: string };
message: string;
};
html_url: string;
sha: string;
}
interface GitHubCompareResponse {
commits: GitHubCompareCommit[];
total_commits: number;
}
interface GitHubRelease {
body: null | string;
name: null | string;
prerelease: boolean;
published_at: string;
tag_name: string;
}
@@ -34,13 +50,22 @@ interface ReleaseNotesContentProps {
version: string;
}
function isAlphaVersion(version: string): boolean {
return version.includes('-alpha');
}
function parseVersionFromTag(tagName: string): string {
return tagName.startsWith('v') ? tagName.slice(1) : tagName;
}
function toTag(version: string): string {
return version.startsWith('v') ? version : `v${version}`;
}
const ReleaseNotesContent = ({ onDismiss, version }: ReleaseNotesContentProps) => {
const { t } = useTranslation();
const [selectedVersion, setSelectedVersion] = useState(version);
const isAlpha = isAlphaVersion(selectedVersion);
// Fetch list of recent releases for the selector
const { data: releasesList = [] } = useQuery({
@@ -54,6 +79,10 @@ const ReleaseNotesContent = ({ onDismiss, version }: ReleaseNotesContentProps) =
retry: 2,
});
const latestStableRelease = useMemo(() => {
return releasesList.find((r) => !r.prerelease);
}, [releasesList]);
const releaseOptions = useMemo(() => {
const options = releasesList.slice(0, RELEASES_TO_FETCH).map((r) => {
const v = parseVersionFromTag(r.tag_name);
@@ -70,14 +99,36 @@ const ReleaseNotesContent = ({ onDismiss, version }: ReleaseNotesContentProps) =
return options;
}, [releasesList, version]);
// For alpha: fetch commits between latest stable and current alpha
const {
data: compareData,
isError: isCompareError,
isLoading: isCompareLoading,
} = useQuery({
enabled: isAlpha && !!latestStableRelease,
queryFn: async () => {
const base = latestStableRelease!.tag_name;
const head = toTag(selectedVersion);
const response = await axios.get<GitHubCompareResponse>(
`${GITHUB_COMPARE_URL}/${base}...${head}`,
{ params: { per_page: 100 } },
);
return response.data;
},
queryKey: ['github-compare', latestStableRelease?.tag_name, selectedVersion],
retry: 2,
});
// For non-alpha: fetch release by tag
const {
data: releaseData,
isError,
isLoading,
} = useQuery({
enabled: !isAlpha,
queryFn: async () => {
const response = await axios.get<GitHubRelease>(
`${GITHUB_RELEASES_URL}/tags/v${selectedVersion}`,
`${GITHUB_RELEASES_URL}/tags/${toTag(selectedVersion)}`,
);
return response.data;
},
@@ -87,7 +138,7 @@ const ReleaseNotesContent = ({ onDismiss, version }: ReleaseNotesContentProps) =
// Convert markdown to HTML using GitHub's markdown API
const { data: htmlContent, isLoading: isConverting } = useQuery({
enabled: !!releaseData?.body,
enabled: !isAlpha && !!releaseData?.body,
queryFn: async () => {
const response = await axios.post(
'https://api.github.com/markdown',
@@ -136,7 +187,10 @@ const ReleaseNotesContent = ({ onDismiss, version }: ReleaseNotesContentProps) =
});
}, [htmlContent]);
if (isLoading || isConverting) {
const isLoadingState = isAlpha ? isCompareLoading : isLoading || isConverting;
const isErrorState = isAlpha ? isCompareError : isError || !releaseData;
if (isLoadingState) {
return (
<Center h={400}>
<Spinner />
@@ -144,7 +198,8 @@ const ReleaseNotesContent = ({ onDismiss, version }: ReleaseNotesContentProps) =
);
}
if (isError || !releaseData) {
if (isErrorState) {
const showCompareError = isAlpha && latestStableRelease;
return (
<Stack gap="md">
{releaseOptions.length > 1 && (
@@ -158,7 +213,11 @@ const ReleaseNotesContent = ({ onDismiss, version }: ReleaseNotesContentProps) =
<Group justify="flex-end">
<Button
component="a"
href={`https://github.com/jeffvli/feishin/releases/tag/v${selectedVersion}`}
href={
showCompareError
? `https://github.com/jeffvli/feishin/compare/${latestStableRelease.tag_name}...${toTag(selectedVersion)}`
: `https://github.com/jeffvli/feishin/releases/tag/${toTag(selectedVersion)}`
}
onClick={onDismiss}
rightSection={<Icon icon="externalLink" />}
target="_blank"
@@ -174,6 +233,128 @@ const ReleaseNotesContent = ({ onDismiss, version }: ReleaseNotesContentProps) =
);
}
if (isAlpha && !latestStableRelease) {
return (
<Stack gap="md">
{releaseOptions.length > 1 && (
<Select
data={releaseOptions}
onChange={(v) => v && setSelectedVersion(v)}
value={selectedVersion}
/>
)}
<Text isMuted size="sm">
{t('page.releasenotes.noStableReleaseToCompare', {
postProcess: 'sentenceCase',
})}
</Text>
<Group justify="flex-end">
<Button
component="a"
href={`https://github.com/jeffvli/feishin/releases/tag/${toTag(selectedVersion)}`}
onClick={onDismiss}
rightSection={<Icon icon="externalLink" />}
target="_blank"
variant="subtle"
>
{t('action.viewMore', { postProcess: 'sentenceCase' })}
</Button>
<Button onClick={onDismiss} variant="filled">
{t('common.dismiss', { postProcess: 'titleCase' })}
</Button>
</Group>
</Stack>
);
}
if (isAlpha && compareData) {
const commits = compareData.commits ?? [];
const compareUrl = `https://github.com/jeffvli/feishin/compare/${latestStableRelease?.tag_name}...${toTag(selectedVersion)}`;
return (
<Stack gap="md">
{releaseOptions.length > 1 && (
<Select
data={releaseOptions}
onChange={(v) => v && setSelectedVersion(v)}
value={selectedVersion}
/>
)}
<Text isMuted size="sm">
{t('page.releasenotes.commitsSinceStable', {
postProcess: 'sentenceCase',
stable: latestStableRelease
? parseVersionFromTag(latestStableRelease.tag_name)
: '',
})}
</Text>
<ScrollArea
style={{
height: '400px',
}}
>
<Stack gap="xs">
{commits.length === 0 ? (
<Text isMuted size="sm">
{t('page.releasenotes.noNewCommits', {
postProcess: 'sentenceCase',
})}
</Text>
) : (
commits.map((c) => {
const firstLine = c.commit.message.split('\n')[0];
return (
<Group
gap="sm"
key={c.sha}
style={{ alignItems: 'flex-start' }}
wrap="nowrap"
>
<Text
size="sm"
style={{ flex: 1 }}
title={c.commit.message}
truncate
>
{firstLine}
</Text>
<Text isMuted size="xs">
{formatHrDateTime(c.commit.author.date)}
</Text>
<Button
component="a"
href={c.html_url}
rightSection={<Icon icon="externalLink" />}
size="compact-xs"
target="_blank"
variant="subtle"
>
{t('common.view', { postProcess: 'sentenceCase' })}
</Button>
</Group>
);
})
)}
</Stack>
</ScrollArea>
<Group justify="flex-end">
<Button
component="a"
href={compareUrl}
onClick={onDismiss}
rightSection={<Icon icon="externalLink" />}
target="_blank"
variant="subtle"
>
{t('action.viewMore', { postProcess: 'sentenceCase' })}
</Button>
<Button onClick={onDismiss} variant="filled">
{t('common.dismiss', { postProcess: 'titleCase' })}
</Button>
</Group>
</Stack>
);
}
return (
<Stack gap="md">
{releaseOptions.length > 1 && (
@@ -198,7 +379,7 @@ const ReleaseNotesContent = ({ onDismiss, version }: ReleaseNotesContentProps) =
<Group justify="flex-end">
<Button
component="a"
href={`https://github.com/jeffvli/feishin/releases/tag/v${selectedVersion}`}
href={`https://github.com/jeffvli/feishin/releases/tag/${toTag(selectedVersion)}`}
onClick={onDismiss}
rightSection={<Icon icon="externalLink" />}
target="_blank"
+1 -1
View File
@@ -588,7 +588,7 @@ const WindowSettingsSchema = z.object({
exitToTray: z.boolean(),
minimizeToTray: z.boolean(),
preventSleepOnPlayback: z.boolean(),
releaseChannel: z.enum(['beta', 'latest']),
releaseChannel: z.enum(['alpha', 'beta', 'latest']),
startMinimized: z.boolean(),
tray: z.boolean(),
windowBarStyle: z.nativeEnum(Platform),