Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion scripts/set-package-version.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,26 @@ if (!nextVersion || typeof nextVersion !== "string" || nextVersion.trim().length

const packageJsonPath = "package.json";
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
packageJson.version = nextVersion.trim();
const version = nextVersion.trim();
packageJson.version = version;

// When version includes a prerelease suffix like "-nightly.N", tell
// electron-builder to generate channel-specific manifests (e.g. nightly.yml,
// nightly-mac.yml) instead of the default latest.yml. electron-updater on
// the client uses autoUpdater.channel to resolve the matching manifest name.
const prereleaseMatch = version.match(/-([a-z]+)\./);
if (prereleaseMatch && packageJson.build?.publish) {
const channel = prereleaseMatch[1]; // e.g. "nightly"
if (Array.isArray(packageJson.build.publish)) {
for (const pub of packageJson.build.publish) {
pub.channel = channel;
}
} else if (typeof packageJson.build.publish === "object") {
packageJson.build.publish.channel = channel;
}
console.log(`Set publish channel to "${channel}"`);
}

fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);

console.log(`Set package.json version to ${packageJson.version}`);
146 changes: 131 additions & 15 deletions src/browser/components/About/AboutDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Download, Loader2, RefreshCw } from "lucide-react";
import { VERSION } from "@/version";
import type { UpdateStatus } from "@/common/orpc/types";
Expand All @@ -9,6 +9,7 @@ import { useAPI } from "@/browser/contexts/API";
import { useAboutDialog } from "@/browser/contexts/AboutDialogContext";
import { Button } from "@/browser/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/browser/components/ui/dialog";
import { ToggleGroup, ToggleGroupItem } from "@/browser/components/ui/toggle-group";

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

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

Expand All @@ -84,6 +89,7 @@ export function AboutDialog() {
break;
}
setUpdateStatus(status);
setPendingAction(null);
}
} catch (error) {
if (!signal.aborted) {
Expand All @@ -97,32 +103,76 @@ export function AboutDialog() {
};
}, [api, isDesktop, isOpen]);

useEffect(() => {
if (!isOpen || !isDesktop || !api) {
return;
}

let active = true;
// Ignore stale getChannel() responses when a newer request or manual selection has already happened.
const requestToken = ++channelRequestTokenRef.current;

api.update
.getChannel()
.then((nextChannel) => {
if (active && requestToken === channelRequestTokenRef.current) {
setChannel(nextChannel);
}
})
.catch(console.error);

return () => {
active = false;
};
}, [api, isDesktop, isOpen]);

const canUseUpdateApi = isDesktop && Boolean(api);
const isChecking =
canUseUpdateApi && (updateStatus.type === "checking" || updateStatus.type === "downloading");
canUseUpdateApi &&
(updateStatus.type === "checking" ||
updateStatus.type === "downloading" ||
pendingAction === "check");

const handleChannelChange = (next: "stable" | "nightly") => {
if (!api || next === channel || channelLoading) {
return;
}

// Invalidate any in-flight getChannel() request so late responses cannot overwrite user intent.
channelRequestTokenRef.current += 1;
setChannelLoading(true);
api.update
.setChannel({ channel: next })
.then(() => setChannel(next))
.catch(console.error)
.finally(() => setChannelLoading(false));
};

const handleCheckForUpdates = () => {
if (!api) {
return;
}

api.update.check({ source: "manual" }).catch(console.error);
setPendingAction("check");
api.update.check({ source: "manual" }).catch(() => setPendingAction(null));
Comment thread
ibetitsmike marked this conversation as resolved.
Outdated
};

const handleDownload = () => {
if (!api) {
return;
}

api.update.download(undefined).catch(console.error);
setPendingAction("download");
api.update.download(undefined).catch(() => setPendingAction(null));
};

const handleInstall = () => {
if (!api) {
return;
}

api.update.install(undefined).catch(console.error);
setPendingAction("install");
api.update.install(undefined).catch(() => setPendingAction(null));
};

return (
Expand Down Expand Up @@ -160,6 +210,38 @@ export function AboutDialog() {
<div className="text-muted text-xs">Connecting to desktop update service…</div>
) : (
<>
{channel !== null && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-muted text-xs">Channel</span>
<ToggleGroup
type="single"
value={channel}
onValueChange={(next) => {
if (next === "stable" || next === "nightly") {
handleChannelChange(next);
}
}}
disabled={channelLoading}
aria-label="Update channel"
size="sm"
>
<ToggleGroupItem value="stable" size="sm">
Stable
</ToggleGroupItem>
<ToggleGroupItem value="nightly" size="sm">
Nightly
</ToggleGroupItem>
</ToggleGroup>
</div>
<div className="text-muted text-xs">
{channel === "stable"
? "Official releases only."
: "Nightly pre-release builds from main."}
</div>
</div>
)}

<Button
variant="outline"
size="sm"
Expand All @@ -179,8 +261,16 @@ export function AboutDialog() {
<div className="text-foreground text-xs">
Update available: <span className="font-mono">{updateStatus.info.version}</span>
</div>
<Button size="sm" onClick={handleDownload}>
<Download className="h-3.5 w-3.5" />
<Button
size="sm"
onClick={handleDownload}
disabled={pendingAction === "download"}
>
{pendingAction === "download" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Download className="h-3.5 w-3.5" />
)}
Download
</Button>
</div>
Expand All @@ -197,9 +287,13 @@ export function AboutDialog() {
<div className="text-foreground text-xs">
Ready to install: <span className="font-mono">{updateStatus.info.version}</span>
</div>
<Button size="sm" onClick={handleInstall}>
<RefreshCw className="h-3.5 w-3.5" />
Install & restart
<Button size="sm" onClick={handleInstall} disabled={pendingAction === "install"}>
{pendingAction === "install" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
{pendingAction === "install" ? "Installing…" : "Install & restart"}
</Button>
</div>
)}
Expand All @@ -223,18 +317,40 @@ export function AboutDialog() {
</div>
<div className="flex items-center gap-2">
{updateStatus.phase === "download" && (
<Button size="sm" onClick={handleDownload}>
<Download className="h-3.5 w-3.5" />
<Button
size="sm"
onClick={handleDownload}
disabled={pendingAction === "download"}
>
{pendingAction === "download" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Download className="h-3.5 w-3.5" />
)}
Retry download
</Button>
)}
{updateStatus.phase === "install" && (
<Button size="sm" onClick={handleInstall}>
<RefreshCw className="h-3.5 w-3.5" />
<Button
size="sm"
onClick={handleInstall}
disabled={pendingAction === "install"}
>
{pendingAction === "install" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
Try install again
</Button>
)}
<Button variant="outline" size="sm" onClick={handleCheckForUpdates}>
<Button
variant="outline"
size="sm"
onClick={handleCheckForUpdates}
disabled={isChecking}
>
{isChecking ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
{updateStatus.phase === "check" ? "Try again" : "Check again"}
</Button>
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/browser/components/ui/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/common/lib/utils";

const buttonVariants = cva(
"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",
"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",
{
variants: {
variant: {
Expand Down
2 changes: 2 additions & 0 deletions src/browser/stories/mocks/orpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1302,6 +1302,8 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
yield* [];
await new Promise<void>(() => undefined);
},
getChannel: () => Promise.resolve("stable" as const),
setChannel: () => Promise.resolve(undefined),
},
policy: {
get: () => Promise.resolve(policyResponse),
Expand Down
10 changes: 10 additions & 0 deletions src/common/orpc/schemas/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1593,6 +1593,8 @@ export const splashScreens = {
};

// Update
export const UpdateChannelSchema = z.enum(["stable", "nightly"]);

export const update = {
check: {
input: z.object({ source: z.enum(["auto", "manual"]).optional() }).optional(),
Expand All @@ -1610,6 +1612,14 @@ export const update = {
input: z.void(),
output: eventIterator(UpdateStatusSchema),
},
getChannel: {
input: z.void(),
output: UpdateChannelSchema,
},
setChannel: {
input: z.object({ channel: UpdateChannelSchema }),
output: z.void(),
},
};

// Editor config schema for openWorkspaceInEditor
Expand Down
10 changes: 10 additions & 0 deletions src/common/types/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,18 @@ export type ProjectConfig = z.infer<typeof ProjectConfigSchema>;

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

/**
* Update channel preference for Electron desktop app.
* Keep in sync with `UpdateChannelSchema` in `src/common/orpc/schemas/api.ts`.
*/
export type UpdateChannel = "stable" | "nightly";

export interface ProjectsConfig {
projects: Map<string, ProjectConfig>;
/**
* Update channel preference for Electron desktop app. Defaults to "stable".
*/
updateChannel?: UpdateChannel;
/**
* Bind host/interface for the desktop HTTP/WS API server.
*
Expand Down
Loading
Loading