diff --git a/.github/workflows/publish-nightly.yml b/.github/workflows/publish-alpha.yml similarity index 80% rename from .github/workflows/publish-nightly.yml rename to .github/workflows/publish-alpha.yml index efa855649..96b93aa3a 100644 --- a/.github/workflows/publish-nightly.yml +++ b/.github/workflows/publish-alpha.yml @@ -1,12 +1,12 @@ -# Nightly builds published to Cloudflare R2 with date versioning (e.g. 1.0.0-nightly-20260205). +# Alpha builds published to Cloudflare R2 with date versioning (e.g. 1.0.0-alpha-20260205). # Required repo secrets: R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY (from R2 API token in Cloudflare dashboard). -name: Publish Nightly +name: Publish Alpha on: workflow_dispatch: inputs: version: - description: 'Semantic version number (e.g., 1.0.0) - nightly suffix will be added automatically' + description: 'Semantic version number (e.g., 1.0.0) - alpha suffix will be added automatically' required: false type: string schedule: @@ -14,7 +14,27 @@ on: - cron: '0 11 * * *' jobs: + check-new-commits: + runs-on: ubuntu-latest + outputs: + has_new_commits: ${{ steps.manual.outputs.has_new_commits || steps.check.outputs['has-new-commits'] }} + steps: + - name: Set has new commits (manual trigger) + id: manual + if: github.event_name == 'workflow_dispatch' + run: echo "has_new_commits=true" >> "$GITHUB_OUTPUT" + + - name: Check for new commits (24 hr interval) + id: check + if: github.event_name != 'workflow_dispatch' + uses: adriangl/check-new-commits-action@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + seconds: 86400 + prepare: + needs: check-new-commits + if: needs.check-new-commits.outputs.has_new_commits == 'true' runs-on: ubuntu-latest outputs: version: ${{ steps.version.outputs.version }} @@ -30,7 +50,7 @@ jobs: - name: Install dependencies run: pnpm install - - name: Set date-based nightly version + - name: Set date-based alpha version id: version shell: pwsh run: | @@ -70,15 +90,15 @@ jobs: $pst = [TimeZoneInfo]::FindSystemTimeZoneById('America/Los_Angeles') $dateInPst = [TimeZoneInfo]::ConvertTimeFromUtc([DateTime]::UtcNow, $pst) $dateStr = $dateInPst.ToString("yyyyMMdd") - $nightlyVersion = "$inputVersion-nightly-$dateStr" - Write-Host "Nightly version: $nightlyVersion" + $alphaVersion = "$inputVersion-alpha-$dateStr" + Write-Host "Alpha version: $alphaVersion" # Update package.json $packageJson = Get-Content package.json | ConvertFrom-Json - $packageJson.version = $nightlyVersion + $packageJson.version = $alphaVersion $packageJson | ConvertTo-Json -Depth 10 | Set-Content package.json - echo "version=$nightlyVersion" >> $env:GITHUB_OUTPUT + echo "version=$alphaVersion" >> $env:GITHUB_OUTPUT cleanup: needs: prepare @@ -132,7 +152,7 @@ jobs: max_attempts: 3 retry_on: error command: | - pnpm run publish:win:nightly + pnpm run publish:win:alpha on_retry_command: pnpm cache delete - name: Build and Publish to R2 (macOS) @@ -143,7 +163,7 @@ jobs: max_attempts: 3 retry_on: error command: | - pnpm run publish:mac:nightly + pnpm run publish:mac:alpha on_retry_command: pnpm cache delete - name: Build and Publish to R2 (Linux) @@ -154,7 +174,7 @@ jobs: max_attempts: 3 retry_on: error command: | - pnpm run publish:linux:nightly + pnpm run publish:linux:alpha on_retry_command: pnpm cache delete - name: Build and Publish to R2 (Linux ARM64) @@ -165,5 +185,5 @@ jobs: max_attempts: 3 retry_on: error command: | - pnpm run publish:linux-arm64:nightly + pnpm run publish:linux-arm64:alpha on_retry_command: pnpm cache delete diff --git a/package.json b/package.json index 15d7c5ea2..4edeaac2f 100644 --- a/package.json +++ b/package.json @@ -47,16 +47,16 @@ "package:win:pr": "pnpm run build && electron-builder --win --publish never", "publish:linux": "pnpm run build && electron-builder --publish always --linux", "publish:linux-arm64": "pnpm run build && electron-builder --publish always --linux --arm64", + "publish:linux-arm64:alpha": "pnpm run build && electron-builder --config electron-builder-alpha.yml --publish always --linux --arm64", "publish:linux-arm64:beta": "pnpm run build && electron-builder --config electron-builder-beta.yml --publish always --linux --arm64", - "publish:linux-arm64:nightly": "pnpm run build && electron-builder --config electron-builder-nightly.yml --publish always --linux --arm64", + "publish:linux:alpha": "pnpm run build && electron-builder --config electron-builder-alpha.yml --publish always --linux", "publish:linux:beta": "pnpm run build && electron-builder --config electron-builder-beta.yml --publish always --linux", - "publish:linux:nightly": "pnpm run build && electron-builder --config electron-builder-nightly.yml --publish always --linux", "publish:mac": "pnpm run build && electron-builder --publish always --mac", + "publish:mac:alpha": "pnpm run build && electron-builder --config electron-builder-alpha.yml --publish always --mac", "publish:mac:beta": "pnpm run build && electron-builder --config electron-builder-beta.yml --publish always --mac", - "publish:mac:nightly": "pnpm run build && electron-builder --config electron-builder-nightly.yml --publish always --mac", "publish:win": "pnpm run build && electron-builder --publish always --win", + "publish:win:alpha": "pnpm run build && electron-builder --config electron-builder-alpha.yml --publish always --win", "publish:win:beta": "pnpm run build && electron-builder --config electron-builder-beta.yml --publish always --win", - "publish:win:nightly": "pnpm run build && electron-builder --config electron-builder-nightly.yml --publish always --win", "start": "electron-vite preview", "typecheck": "pnpm run typecheck:node && pnpm run typecheck:web", "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index a532ba59a..0c0e22f5e 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/main/index.ts b/src/main/index.ts index 09f54053c..43dc9c924 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 { 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) { diff --git a/src/preload/utils.ts b/src/preload/utils.ts index a3f2ded02..5d0af57f1 100644 --- a/src/preload/utils.ts +++ b/src/preload/utils.ts @@ -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, diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index 18adc6dfd..55ded0edd 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -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(null); useSyncSettingsToMain(); + useCheckForUpdates(); const [webAudio, setWebAudio] = useState(); diff --git a/src/renderer/features/settings/components/window/update-settings.tsx b/src/renderer/features/settings/components/window/update-settings.tsx index 6332ca0bc..9f77ee034 100644 --- a/src/renderer/features/settings/components/window/update-settings.tsx +++ b/src/renderer/features/settings/components/window/update-settings.tsx @@ -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', }, }); }} diff --git a/src/renderer/hooks/index.ts b/src/renderer/hooks/index.ts index 42fc9deef..dd25d8950 100644 --- a/src/renderer/hooks/index.ts +++ b/src/renderer/hooks/index.ts @@ -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'; diff --git a/src/renderer/hooks/use-check-for-updates.ts b/src/renderer/hooks/use-check-for-updates.ts new file mode 100644 index 000000000..6e3f7a239 --- /dev/null +++ b/src/renderer/hooks/use-check-for-updates.ts @@ -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, + }); +}; diff --git a/src/renderer/release-notes-modal.tsx b/src/renderer/release-notes-modal.tsx index 8a2d8fa64..26f73265b 100644 --- a/src/renderer/release-notes-modal.tsx +++ b/src/renderer/release-notes-modal.tsx @@ -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( + `${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( - `${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 (
@@ -144,7 +198,8 @@ const ReleaseNotesContent = ({ onDismiss, version }: ReleaseNotesContentProps) = ); } - if (isError || !releaseData) { + if (isErrorState) { + const showCompareError = isAlpha && latestStableRelease; return ( {releaseOptions.length > 1 && ( @@ -158,7 +213,11 @@ const ReleaseNotesContent = ({ onDismiss, version }: ReleaseNotesContentProps) = + + ); + }) + )} + + + + + + + + ); + } + return ( {releaseOptions.length > 1 && ( @@ -198,7 +379,7 @@ const ReleaseNotesContent = ({ onDismiss, version }: ReleaseNotesContentProps) =