diff --git a/src/main/updater.ts b/src/main/updater.ts index 838c304a2..1551720b7 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -45,7 +45,11 @@ import { } from './serve-update-handoff' import type { LocalBuildFeed } from './local-builds/local-build-feed-server' import { listReleaseBuilds, resolveTargetBuild } from './updater-release-builds' -import type { ReleaseBuild, ReleaseChannel } from '../shared/release-channel' +import { + isChannelSupportedOnPlatform, + type ReleaseBuild, + type ReleaseChannel +} from '../shared/release-channel' type CheckFailureSource = 'event' | 'promise' | 'fallback-promise' type MissingManifestPrereleaseFallbackResult = { userInitiated: boolean } @@ -1524,6 +1528,16 @@ async function checkForPinnedBuild(channel: ReleaseChannel, tag: string): Promis sendStatus({ state: 'not-available', userInitiated: true }) return } + // Why here as well as in the picker: the renderer disables the option, but IPC + // is reachable regardless, and there is no artifact to install off-macOS. + if (!isChannelSupportedOnPlatform(channel, process.platform)) { + sendStatus({ + state: 'error', + message: 'Hourly builds are produced only for macOS.', + userInitiated: true + }) + return + } if (currentStatus.state === 'checking' || currentStatus.state === 'downloading') { return } diff --git a/src/renderer/src/components/settings/ReleaseChannelSection.tsx b/src/renderer/src/components/settings/ReleaseChannelSection.tsx index 368ded2a0..29a7acdfe 100644 --- a/src/renderer/src/components/settings/ReleaseChannelSection.tsx +++ b/src/renderer/src/components/settings/ReleaseChannelSection.tsx @@ -1,6 +1,6 @@ import type React from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { AlertTriangle, Loader2, RefreshCw } from 'lucide-react' +import { AlertTriangle, Apple, Loader2, RefreshCw } from 'lucide-react' import { toast } from 'sonner' import { useAppStore } from '../../store' import { Button } from '../ui/button' @@ -8,9 +8,11 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '. import { SettingsSegmentedControl, SettingsSubsectionHeader } from './SettingsFormControls' import { Badge } from '../ui/badge' import { translate } from '@/i18n/i18n' +import { getShortcutPlatform } from '@/lib/shortcut-platform' import { RELEASE_CHANNELS, getVersionChannel, + isChannelSupportedOnPlatform, parseHourlyVersionStamp, type ReleaseBuild, type ReleaseChannel @@ -25,7 +27,7 @@ const CHANNEL_LABELS: Record = { const CHANNEL_DESCRIPTIONS: Record = { stable: 'Shipped releases. What everyone else is running.', rc: 'Release candidates cut ahead of each stable.', - hourly: 'Unvetted macOS builds from main, built every hour. No tests, no notarization.' + hourly: 'macOS only. Unvetted builds from main, built every hour. No tests, no notarization.' } function formatBuildLabel(build: ReleaseBuild): string { @@ -54,8 +56,15 @@ export function ReleaseChannelSection(): React.JSX.Element { const [loading, setLoading] = useState(false) const [selectedTag, setSelectedTag] = useState(null) + const platform = getShortcutPlatform() const runningChannel = appVersion ? getVersionChannel(appVersion) : null - const activeChannel = releaseChannelOverride ?? runningChannel ?? 'stable' + const requestedChannel = releaseChannelOverride ?? runningChannel ?? 'stable' + // Why: a persisted 'hourly' can arrive on Linux/Windows — settings sync, or a + // profile carried over from a Mac. Fall back rather than rendering a selected + // segment the user cannot act on and a build list that can never install. + const activeChannel = isChannelSupportedOnPlatform(requestedChannel, platform) + ? requestedChannel + : 'stable' const busy = updateStatus.state === 'checking' || updateStatus.state === 'downloading' useEffect(() => { @@ -170,10 +179,36 @@ export function ReleaseChannelSection(): React.JSX.Element { 'auto.components.settings.ReleaseChannelSection.channelAriaLabel', 'Update channel' )} - options={RELEASE_CHANNELS.map((channel) => ({ - value: channel, - label: CHANNEL_LABELS[channel] - }))} + // Why disabled rather than hidden: a Linux/Windows dev who has heard + // about the hourly channel should see that it exists and why it is + // unavailable, instead of silently not finding it. + options={RELEASE_CHANNELS.map((channel) => { + const supported = isChannelSupportedOnPlatform(channel, platform) + return { + value: channel, + label: supported ? ( + CHANNEL_LABELS[channel] + ) : ( + + {CHANNEL_LABELS[channel]} + + ), + disabled: !supported, + ariaLabel: supported + ? undefined + : translate( + 'auto.components.settings.ReleaseChannelSection.hourlyMacOnlyAria', + 'Hourly (macOS only)' + ), + tooltip: supported + ? undefined + : translate( + 'auto.components.settings.ReleaseChannelSection.hourlyMacOnly', + 'Hourly builds are produced only for macOS. Linux and Windows stay on Stable or RC.' + ) + } + })} />

{CHANNEL_DESCRIPTIONS[activeChannel]}

diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index e3c586a4d..b38e69150 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -10089,7 +10089,9 @@ "switchTo": "Switch to build", "alreadyRunning": "This is the build you are running.", "willSwitch": "{{value0}} → {{value1}}", - "webUnavailable": "Switching builds is only available in the desktop app." + "webUnavailable": "Switching builds is only available in the desktop app.", + "hourlyMacOnlyAria": "Hourly (macOS only)", + "hourlyMacOnly": "Hourly builds are produced only for macOS. Linux and Windows stay on Stable or RC." } }, "right": { diff --git a/src/shared/release-channel.test.ts b/src/shared/release-channel.test.ts index c02ffe888..089487f0a 100644 --- a/src/shared/release-channel.test.ts +++ b/src/shared/release-channel.test.ts @@ -3,6 +3,7 @@ import { formatHourlyVersion, getReleaseRepoForChannel, getVersionChannel, + isChannelSupportedOnPlatform, isHourlyVersion, isReleaseChannel, parseHourlyVersionStamp, @@ -57,6 +58,22 @@ describe('release channel', () => { ) }) + // Why: the hourly workflow is macOS-only, so the channel has no artifact to + // offer elsewhere. Both the picker and the main-process check read this, so a + // regression here would silently re-expose an uninstallable channel. + it('offers hourly only on macOS', () => { + expect(isChannelSupportedOnPlatform('hourly', 'darwin')).toBe(true) + expect(isChannelSupportedOnPlatform('hourly', 'linux')).toBe(false) + expect(isChannelSupportedOnPlatform('hourly', 'win32')).toBe(false) + }) + + it('offers stable and rc on every platform', () => { + for (const platform of ['darwin', 'linux', 'win32'] as const) { + expect(isChannelSupportedOnPlatform('stable', platform)).toBe(true) + expect(isChannelSupportedOnPlatform('rc', platform)).toBe(true) + } + }) + it('accepts only known channels', () => { expect(isReleaseChannel('hourly')).toBe(true) expect(isReleaseChannel('stable')).toBe(true) diff --git a/src/shared/release-channel.ts b/src/shared/release-channel.ts index 5295f4447..fc0c09cef 100644 --- a/src/shared/release-channel.ts +++ b/src/shared/release-channel.ts @@ -16,6 +16,18 @@ export function isReleaseChannel(value: unknown): value is ReleaseChannel { return typeof value === 'string' && RELEASE_CHANNELS.includes(value as ReleaseChannel) } +/** + * Hourly builds are produced only by the macOS workflow, so the channel has + * nothing to offer elsewhere. Shared so the picker, the main-process check, and + * any future surface cannot drift on where it is available. + */ +export function isChannelSupportedOnPlatform( + channel: ReleaseChannel, + platform: NodeJS.Platform +): boolean { + return channel !== 'hourly' || platform === 'darwin' +} + export function getReleaseRepoForChannel(channel: ReleaseChannel): string { return channel === 'hourly' ? HOURLY_RELEASE_REPO : MAIN_RELEASE_REPO }