feat(updater): mark the hourly channel macOS-only in the picker (#11708)

The hourly workflow builds only macOS artifacts, so on Linux and Windows the
channel had nothing to install while still looking selectable.

Adds isChannelSupportedOnPlatform in the shared module so the picker and the
main-process check cannot drift. The segment is disabled with an Apple glyph
and a tooltip rather than hidden, so a dev who has heard of the channel sees
that it exists and why it is unavailable instead of not finding it.

A persisted 'hourly' override arriving off-macOS (settings sync, or a profile
carried from a Mac) now falls back to stable rather than rendering a selected
segment the user cannot act on. checkForPinnedBuild rejects the channel too,
since IPC is reachable regardless of what the renderer disables.
This commit is contained in:
Neil 2026-07-30 23:17:19 -07:00 committed by GitHub
parent aa6f945001
commit 94cf2f1422
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 89 additions and 9 deletions

View File

@ -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
}

View File

@ -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<ReleaseChannel, string> = {
const CHANNEL_DESCRIPTIONS: Record<ReleaseChannel, string> = {
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<string | null>(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]
) : (
<span className="inline-flex items-center gap-1">
{CHANNEL_LABELS[channel]}
<Apple className="size-3" aria-hidden="true" />
</span>
),
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.'
)
}
})}
/>
<p className="text-xs text-muted-foreground">{CHANNEL_DESCRIPTIONS[activeChannel]}</p>
</div>

View File

@ -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": {

View File

@ -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)

View File

@ -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
}