Skip to content

Commit 0a5565b

Browse files
authored
🤖 feat: add stable/nightly Electron update channels (#2430)
## Summary Adds a user-selectable update channel (Stable / Nightly) to the Mux Electron app. Users can switch channels from the About dialog; the choice is persisted in `~/.mux/config.json` and takes effect immediately at runtime. ## Background The Electron app currently updates only from official GitHub Releases ("Stable"). This PR adds support for a "Nightly" channel that pulls pre-release builds published from `main`. This gives users who want bleeding-edge features an opt-in path without disrupting the default stable experience. ## Implementation ### Config layer (`src/node/config.ts`) - New `UpdateChannel` type (`"stable" | "nightly"`) with `parseUpdateChannel()` validation. - Persisted in config.json — only non-default values are written (stable is the default, so the key is omitted when on stable). ### Desktop updater (`src/desktop/updater.ts`) - `applyChannel()` configures `autoUpdater`: `allowPrerelease`, `channel`, feed URL `releaseType`. - `setChannel()` method with state guards (blocks switching during download/install). - Transient error filtering (404s, network timeouts) to avoid noisy errors during manifest propagation. ### oRPC surface (`src/common/orpc/schemas/api.ts`, `src/node/orpc/router.ts`) - `update.getChannel` / `update.setChannel` endpoints with Zod-validated `UpdateChannelSchema`. ### Frontend (`src/browser/components/About/AboutDialog.tsx`) - Channel picker using standard `ToggleGroup` segmented control (size="sm", matching other consumers). - Race condition protection via `channelRequestTokenRef` counter. - Channel state initialized to `null` until fetched, preventing layout flicker. - Local `pendingAction` state gives immediate click feedback (spinner + disabled) for Check, Download, and Install buttons while waiting for the backend status stream to catch up. ### Button press feedback (`src/browser/components/ui/button.tsx`) - Added `active:scale-[0.98]` press animation and `transition-all` so all buttons give tactile click feedback. ### CI: manifest naming (`scripts/set-package-version.js`) - Detects prerelease suffix (e.g. `-nightly.N`) and sets `build.publish.channel` in `package.json` so electron-builder generates channel-specific manifests (`nightly.yml`, `nightly-mac.yml`) instead of `latest.yml`. ## Validation - All static checks pass locally (`make static-check`: typecheck, lint, fmt, broken links). - Updater test suite (`src/desktop/updater.test.ts`) covers channel switching, state guards, and transient error handling. ## Risks - **macOS code signing**: The manifest naming fix (set-package-version.js) ensures electron-updater finds the correct `nightly-mac.yml` manifest. Previously, the build produced `latest-mac.yml` which caused signature validation failures when the client expected `nightly-mac.yml`. --- _Generated with `mux` • Model: `anthropic:claude-opus-4-6` • Thinking: `xhigh` • Cost: `$26.04`_ <!-- mux-attribution: model=anthropic:claude-opus-4-6 thinking=xhigh costs=26.04 -->
1 parent d8e6fdd commit 0a5565b

12 files changed

Lines changed: 437 additions & 23 deletions

File tree

scripts/set-package-version.js

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,26 @@ if (!nextVersion || typeof nextVersion !== "string" || nextVersion.trim().length
1616

1717
const packageJsonPath = "package.json";
1818
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
19-
packageJson.version = nextVersion.trim();
19+
const version = nextVersion.trim();
20+
packageJson.version = version;
21+
22+
// When version includes a prerelease suffix like "-nightly.N", tell
23+
// electron-builder to generate channel-specific manifests (e.g. nightly.yml,
24+
// nightly-mac.yml) instead of the default latest.yml. electron-updater on
25+
// the client uses autoUpdater.channel to resolve the matching manifest name.
26+
const prereleaseMatch = version.match(/-([a-z]+)\./);
27+
if (prereleaseMatch && packageJson.build?.publish) {
28+
const channel = prereleaseMatch[1]; // e.g. "nightly"
29+
if (Array.isArray(packageJson.build.publish)) {
30+
for (const pub of packageJson.build.publish) {
31+
pub.channel = channel;
32+
}
33+
} else if (typeof packageJson.build.publish === "object") {
34+
packageJson.build.publish.channel = channel;
35+
}
36+
console.log(`Set publish channel to "${channel}"`);
37+
}
38+
2039
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
2140

2241
console.log(`Set package.json version to ${packageJson.version}`);

src/browser/components/About/AboutDialog.tsx

Lines changed: 141 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useState } from "react";
1+
import { useEffect, useRef, useState } from "react";
22
import { Download, Loader2, RefreshCw } from "lucide-react";
33
import { VERSION } from "@/version";
44
import type { UpdateStatus } from "@/common/orpc/types";
@@ -9,6 +9,7 @@ import { useAPI } from "@/browser/contexts/API";
99
import { useAboutDialog } from "@/browser/contexts/AboutDialogContext";
1010
import { Button } from "@/browser/components/ui/button";
1111
import { Dialog, DialogContent, DialogTitle } from "@/browser/components/ui/dialog";
12+
import { ToggleGroup, ToggleGroupItem } from "@/browser/components/ui/toggle-group";
1213

1314
interface VersionRecord {
1415
buildTime?: unknown;
@@ -65,6 +66,10 @@ export function AboutDialog() {
6566
const MuxLogo = theme === "dark" || theme.endsWith("-dark") ? MuxLogoDark : MuxLogoLight;
6667
const { gitDescribe, buildTime } = parseVersionInfo(VERSION satisfies unknown);
6768
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>({ type: "idle" });
69+
const [channel, setChannel] = useState<"stable" | "nightly" | null>(null);
70+
const [channelLoading, setChannelLoading] = useState(false);
71+
const [pendingAction, setPendingAction] = useState<"check" | "download" | "install" | null>(null);
72+
const channelRequestTokenRef = useRef(0);
6873

6974
const isDesktop = typeof window !== "undefined" && Boolean(window.api);
7075

@@ -84,6 +89,7 @@ export function AboutDialog() {
8489
break;
8590
}
8691
setUpdateStatus(status);
92+
setPendingAction(null);
8793
}
8894
} catch (error) {
8995
if (!signal.aborted) {
@@ -97,32 +103,86 @@ export function AboutDialog() {
97103
};
98104
}, [api, isDesktop, isOpen]);
99105

106+
useEffect(() => {
107+
if (!isOpen || !isDesktop || !api) {
108+
return;
109+
}
110+
111+
let active = true;
112+
// Ignore stale getChannel() responses when a newer request or manual selection has already happened.
113+
const requestToken = ++channelRequestTokenRef.current;
114+
115+
api.update
116+
.getChannel()
117+
.then((nextChannel) => {
118+
if (active && requestToken === channelRequestTokenRef.current) {
119+
setChannel(nextChannel);
120+
}
121+
})
122+
.catch(console.error);
123+
124+
return () => {
125+
active = false;
126+
};
127+
}, [api, isDesktop, isOpen]);
128+
100129
const canUseUpdateApi = isDesktop && Boolean(api);
101130
const isChecking =
102-
canUseUpdateApi && (updateStatus.type === "checking" || updateStatus.type === "downloading");
131+
canUseUpdateApi &&
132+
(updateStatus.type === "checking" ||
133+
updateStatus.type === "downloading" ||
134+
pendingAction === "check");
135+
136+
const handleChannelChange = (next: "stable" | "nightly") => {
137+
if (!api || next === channel || channelLoading) {
138+
return;
139+
}
140+
141+
// Invalidate any in-flight getChannel() request so late responses cannot overwrite user intent.
142+
channelRequestTokenRef.current += 1;
143+
setChannelLoading(true);
144+
api.update
145+
.setChannel({ channel: next })
146+
.then(() => setChannel(next))
147+
.catch(console.error)
148+
.finally(() => setChannelLoading(false));
149+
};
103150

104151
const handleCheckForUpdates = () => {
105152
if (!api) {
106153
return;
107154
}
108155

109-
api.update.check({ source: "manual" }).catch(console.error);
156+
setPendingAction("check");
157+
api.update
158+
.check({ source: "manual" })
159+
.catch(console.error)
160+
// Clear pending if the backend no-ops (e.g. already downloaded) and emits no status event.
161+
.finally(() => setPendingAction((prev) => (prev === "check" ? null : prev)));
110162
};
111163

112164
const handleDownload = () => {
113165
if (!api) {
114166
return;
115167
}
116168

117-
api.update.download(undefined).catch(console.error);
169+
setPendingAction("download");
170+
api.update
171+
.download(undefined)
172+
.catch(console.error)
173+
.finally(() => setPendingAction((prev) => (prev === "download" ? null : prev)));
118174
};
119175

120176
const handleInstall = () => {
121177
if (!api) {
122178
return;
123179
}
124180

125-
api.update.install(undefined).catch(console.error);
181+
setPendingAction("install");
182+
api.update
183+
.install(undefined)
184+
.catch(console.error)
185+
.finally(() => setPendingAction((prev) => (prev === "install" ? null : prev)));
126186
};
127187

128188
return (
@@ -160,6 +220,38 @@ export function AboutDialog() {
160220
<div className="text-muted text-xs">Connecting to desktop update service…</div>
161221
) : (
162222
<>
223+
{channel !== null && (
224+
<div className="space-y-2">
225+
<div className="flex items-center gap-2">
226+
<span className="text-muted text-xs">Channel</span>
227+
<ToggleGroup
228+
type="single"
229+
value={channel}
230+
onValueChange={(next) => {
231+
if (next === "stable" || next === "nightly") {
232+
handleChannelChange(next);
233+
}
234+
}}
235+
disabled={channelLoading}
236+
aria-label="Update channel"
237+
size="sm"
238+
>
239+
<ToggleGroupItem value="stable" size="sm">
240+
Stable
241+
</ToggleGroupItem>
242+
<ToggleGroupItem value="nightly" size="sm">
243+
Nightly
244+
</ToggleGroupItem>
245+
</ToggleGroup>
246+
</div>
247+
<div className="text-muted text-xs">
248+
{channel === "stable"
249+
? "Official releases only."
250+
: "Nightly pre-release builds from main."}
251+
</div>
252+
</div>
253+
)}
254+
163255
<Button
164256
variant="outline"
165257
size="sm"
@@ -179,8 +271,16 @@ export function AboutDialog() {
179271
<div className="text-foreground text-xs">
180272
Update available: <span className="font-mono">{updateStatus.info.version}</span>
181273
</div>
182-
<Button size="sm" onClick={handleDownload}>
183-
<Download className="h-3.5 w-3.5" />
274+
<Button
275+
size="sm"
276+
onClick={handleDownload}
277+
disabled={pendingAction === "download"}
278+
>
279+
{pendingAction === "download" ? (
280+
<Loader2 className="h-3.5 w-3.5 animate-spin" />
281+
) : (
282+
<Download className="h-3.5 w-3.5" />
283+
)}
184284
Download
185285
</Button>
186286
</div>
@@ -197,9 +297,13 @@ export function AboutDialog() {
197297
<div className="text-foreground text-xs">
198298
Ready to install: <span className="font-mono">{updateStatus.info.version}</span>
199299
</div>
200-
<Button size="sm" onClick={handleInstall}>
201-
<RefreshCw className="h-3.5 w-3.5" />
202-
Install & restart
300+
<Button size="sm" onClick={handleInstall} disabled={pendingAction === "install"}>
301+
{pendingAction === "install" ? (
302+
<Loader2 className="h-3.5 w-3.5 animate-spin" />
303+
) : (
304+
<RefreshCw className="h-3.5 w-3.5" />
305+
)}
306+
{pendingAction === "install" ? "Installing…" : "Install & restart"}
203307
</Button>
204308
</div>
205309
)}
@@ -223,18 +327,40 @@ export function AboutDialog() {
223327
</div>
224328
<div className="flex items-center gap-2">
225329
{updateStatus.phase === "download" && (
226-
<Button size="sm" onClick={handleDownload}>
227-
<Download className="h-3.5 w-3.5" />
330+
<Button
331+
size="sm"
332+
onClick={handleDownload}
333+
disabled={pendingAction === "download"}
334+
>
335+
{pendingAction === "download" ? (
336+
<Loader2 className="h-3.5 w-3.5 animate-spin" />
337+
) : (
338+
<Download className="h-3.5 w-3.5" />
339+
)}
228340
Retry download
229341
</Button>
230342
)}
231343
{updateStatus.phase === "install" && (
232-
<Button size="sm" onClick={handleInstall}>
233-
<RefreshCw className="h-3.5 w-3.5" />
344+
<Button
345+
size="sm"
346+
onClick={handleInstall}
347+
disabled={pendingAction === "install"}
348+
>
349+
{pendingAction === "install" ? (
350+
<Loader2 className="h-3.5 w-3.5 animate-spin" />
351+
) : (
352+
<RefreshCw className="h-3.5 w-3.5" />
353+
)}
234354
Try install again
235355
</Button>
236356
)}
237-
<Button variant="outline" size="sm" onClick={handleCheckForUpdates}>
357+
<Button
358+
variant="outline"
359+
size="sm"
360+
onClick={handleCheckForUpdates}
361+
disabled={isChecking}
362+
>
363+
{isChecking ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
238364
{updateStatus.phase === "check" ? "Try again" : "Check again"}
239365
</Button>
240366
</div>

src/browser/components/ui/button.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
44
import { cn } from "@/common/lib/utils";
55

66
const buttonVariants = cva(
7-
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
7+
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all active:scale-[0.98] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
88
{
99
variants: {
1010
variant: {

src/browser/stories/mocks/orpc.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1302,6 +1302,8 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
13021302
yield* [];
13031303
await new Promise<void>(() => undefined);
13041304
},
1305+
getChannel: () => Promise.resolve("stable" as const),
1306+
setChannel: () => Promise.resolve(undefined),
13051307
},
13061308
policy: {
13071309
get: () => Promise.resolve(policyResponse),

src/common/orpc/schemas/api.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1593,6 +1593,8 @@ export const splashScreens = {
15931593
};
15941594

15951595
// Update
1596+
export const UpdateChannelSchema = z.enum(["stable", "nightly"]);
1597+
15961598
export const update = {
15971599
check: {
15981600
input: z.object({ source: z.enum(["auto", "manual"]).optional() }).optional(),
@@ -1610,6 +1612,14 @@ export const update = {
16101612
input: z.void(),
16111613
output: eventIterator(UpdateStatusSchema),
16121614
},
1615+
getChannel: {
1616+
input: z.void(),
1617+
output: UpdateChannelSchema,
1618+
},
1619+
setChannel: {
1620+
input: z.object({ channel: UpdateChannelSchema }),
1621+
output: z.void(),
1622+
},
16131623
};
16141624

16151625
// Editor config schema for openWorkspaceInEditor

src/common/types/project.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,18 @@ export type ProjectConfig = z.infer<typeof ProjectConfigSchema>;
2121

2222
export type FeatureFlagOverride = "default" | "on" | "off";
2323

24+
/**
25+
* Update channel preference for Electron desktop app.
26+
* Keep in sync with `UpdateChannelSchema` in `src/common/orpc/schemas/api.ts`.
27+
*/
28+
export type UpdateChannel = "stable" | "nightly";
29+
2430
export interface ProjectsConfig {
2531
projects: Map<string, ProjectConfig>;
32+
/**
33+
* Update channel preference for Electron desktop app. Defaults to "stable".
34+
*/
35+
updateChannel?: UpdateChannel;
2636
/**
2737
* Bind host/interface for the desktop HTTP/WS API server.
2838
*

0 commit comments

Comments
 (0)