// The sidebar "update available" card, shared by both shells (the local/desktop
// single-user shell in @executor-js/app and the multiplayer shell here). One
// `` encapsulates the whole decision, because the right way
// to upgrade depends on how this build was deployed:
//
// - desktop app (the bridge is present): a native "Restart to update" action
// wired to electron-updater (see ../hooks/desktop-update);
// - npm-installed CLI / its local web UI (`VITE_UPGRADE_HINT === "npm"`): the
// copyable `npm i -g executor@latest` command;
// - self-host and Cloudflare (`"selfhost"` / `"cloudflare"`): a link to that
// host's upgrade guide, because the steps (image pull, rebuild, redeploy)
// vary too much for one correct command;
// - managed cloud (`"managed"`): nothing, it deploys itself.
//
// The "is a newer version published?" verdict comes from the same resolver as
// the CLI notice (@executor-js/api) so the two can never disagree.
import { useCallback, useEffect, useState } from "react";
import { Effect, Exit } from "effect";
import { compareVersions, resolveUpdateChannel, type UpdateChannel } from "@executor-js/api";
import { Button } from "./button";
import { toast } from "./sonner";
import { copyToClipboard } from "../lib/clipboard";
import { type DesktopUpdate, useDesktopUpdate } from "../hooks/desktop-update";
const EXECUTOR_DIST_TAGS_PATH = "/v1/app/npm/dist-tags";
const DOCS_BASE_URL = "https://executor.sh/docs";
type UpgradeHint = "npm" | "selfhost" | "cloudflare" | "managed";
const appEnv = (
import.meta as ImportMeta & {
readonly env?: { readonly VITE_APP_VERSION?: string; readonly VITE_UPGRADE_HINT?: string };
}
).env;
const APP_VERSION = appEnv?.VITE_APP_VERSION;
const UPGRADE_HINT = appEnv?.VITE_UPGRADE_HINT as UpgradeHint | undefined;
// Per-host upgrade guide. A host without an entry (or no hint at all) falls back
// to the docs root rather than a command, so a missing hint can never show a
// wrong upgrade step.
const UPGRADE_DOCS_URL: Partial> = {
selfhost: `${DOCS_BASE_URL}/hosted/docker`,
cloudflare: `${DOCS_BASE_URL}/hosted/cloudflare`,
};
// ── useLatestVersion ────────────────────────────────────────────────────
function useLatestVersion(currentVersion: string | undefined) {
const channel: UpdateChannel = currentVersion ? resolveUpdateChannel(currentVersion) : "latest";
const [latestVersion, setLatestVersion] = useState(null);
useEffect(() => {
if (!currentVersion) return;
let cancelled = false;
void Effect.runPromiseExit(
Effect.tryPromise({
try: async () => {
const res = await fetch(EXECUTOR_DIST_TAGS_PATH);
if (!res.ok) return null;
return (await res.json()) as Partial>;
},
catch: (cause) => cause,
}),
).then((exit) => {
if (!cancelled && Exit.isSuccess(exit)) {
setLatestVersion(exit.value?.[channel] ?? null);
}
});
return () => {
cancelled = true;
};
}, [channel, currentVersion]);
const updateAvailable =
currentVersion !== undefined &&
latestVersion !== null &&
compareVersions(currentVersion, latestVersion) === -1;
return { latestVersion, updateAvailable, channel };
}
// ── Card chrome ──────────────────────────────────────────────────────────
/** The bordered card with the download glyph + "Update available" + version.
* Each variant supplies its own action as children. */
function UpdateCardShell(props: { version: string | null; children?: React.ReactNode }) {
return (
Update available
{props.version &&
v{props.version}
}
{props.children}
);
}
// ── NpmUpdateCard (npm-installed CLI: copyable command) ───────────────────
function NpmUpdateCard(props: { latestVersion: string; channel: UpdateChannel }) {
const command = `npm i -g executor@${props.channel}`;
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(() => {
void copyToClipboard(command).then((ok) => {
if (!ok) {
toast.error("Failed to copy to clipboard");
return;
}
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
}, [command]);
return (
);
}
// ── LinkUpdateCard (self-host / Cloudflare: link to the upgrade guide) ────
function LinkUpdateCard(props: { latestVersion: string; href: string }) {
return (
Upgrade guide
);
}
// ── DesktopUpdateCard (desktop app: native restart action) ────────────────
function DesktopUpdateCard(props: { update: DesktopUpdate }) {
const { status, install } = props.update;
const version = "version" in status ? status.version : null;
const action = (() => {
if (status.state === "downloaded") {
return (
);
}
if (status.state === "downloading") {
return (
Downloading… {status.percent}%
);
}
if (status.state === "available") {
return
Preparing update…
;
}
if (status.state === "installing") {
return
Restarting…
;
}
return null;
})();
return {action};
}
// ── SidebarUpdateCard (the only export the shells consume) ────────────────
/**
* The sidebar update card, or null when no update is available (or the host
* manages its own updates). Reads `VITE_APP_VERSION` / `VITE_UPGRADE_HINT` and
* the desktop bridge itself, so a shell just drops it in above its footer. Hook
* order is stable across renders.
*/
export function SidebarUpdateCard(): React.ReactElement | null {
const desktopUpdate = useDesktopUpdate();
const { latestVersion, updateAvailable, channel } = useLatestVersion(APP_VERSION);
// The desktop app updates via electron-updater, so its native card wins
// wherever the bridge is present (it loads the local app UI, whose hint is
// "npm" — the bridge overrides it).
if (desktopUpdate) {
return desktopUpdate.status.state !== "idle" ? (
) : null;
}
if (!updateAvailable || !latestVersion) return null;
// Managed cloud deploys itself: knowing a version exists is not actionable.
if (UPGRADE_HINT === "managed") return null;
// The npm-installed CLI is the only deployment a command actually upgrades.
if (UPGRADE_HINT === "npm") {
return ;
}
// Self-host / Cloudflare (and any host without an explicit hint): link to the
// upgrade guide rather than risk a wrong command.
const href = (UPGRADE_HINT && UPGRADE_DOCS_URL[UPGRADE_HINT]) ?? DOCS_BASE_URL;
return ;
}