310 lines · 11.6 KB · TypeScript
RawDownload
// 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// `<SidebarUpdateCard />` 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) — `isUpdateAvailable` — so the two can// never disagree.import { useCallback, useEffect, useState } from "react"; import { Effect, Exit } from "effect";import {  isUpdateAvailable,  resolveComparisonChannel,  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<Record<UpgradeHint, string>> = {  selfhost: `${DOCS_BASE_URL}/hosted/docker`,  cloudflare: `${DOCS_BASE_URL}/hosted/cloudflare`,}; // ── useLatestVersion ──────────────────────────────────────────────────── /** * The dist-tag channel to fetch for `currentVersion`, or `null` when there is * nothing to check — no version baked in yet, or a build the check should * stay quiet on (an unstamped `0.0.0*` fallback, or a prerelease channel with * no published dist-tag; see `resolveComparisonChannel`). Returning `null` * here is what lets the hook skip the network call entirely, so a dev build * never even hits `/v1/app/npm/dist-tags`. */export function updateFetchChannel(currentVersion: string | undefined): UpdateChannel | null {  if (currentVersion === undefined) return null;  return resolveComparisonChannel(currentVersion);} function useLatestVersion(currentVersion: string | undefined) {  // The channel shown in the upgrade command differs from the channel  // fetched for comparison: the command always names a real channel, while  // the fetch is skipped entirely for a version with nothing to compare.  const displayChannel: UpdateChannel = currentVersion    ? resolveUpdateChannel(currentVersion)    : "latest";  const fetchChannel = updateFetchChannel(currentVersion);  const [latestVersion, setLatestVersion] = useState<string | null>(null);   useEffect(() => {    if (fetchChannel === null) 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<Record<UpdateChannel, string>>;        },        catch: (cause) => cause,      }),    ).then((exit) => {      if (!cancelled && Exit.isSuccess(exit)) {        setLatestVersion(exit.value?.[fetchChannel] ?? null);      }    });    return () => {      cancelled = true;    };  }, [fetchChannel]);   const updateAvailable = isUpdateAvailable(currentVersion, latestVersion);   return { latestVersion, updateAvailable, channel: displayChannel };} // ── Card chrome ────────────────────────────────────────────────────────── /** The bordered card with the download glyph + title + version. *  Each variant supplies its own action as children. */function UpdateCardShell(props: {  title?: string;  version: string | null;  children?: React.ReactNode;}) {  return (    <div className="mx-2 mb-2 rounded-xl border border-primary/25 bg-primary/[0.06] p-3">      <div className="flex items-center gap-2">        <div className="flex size-5 shrink-0 items-center justify-center rounded-full bg-primary/15">          <svg viewBox="0 0 16 16" fill="none" className="size-3 text-primary">            <path              d="M8 3v7M5 7l3 3 3-3"              stroke="currentColor"              strokeWidth="1.4"              strokeLinecap="round"              strokeLinejoin="round"            />            <path d="M3 12h10" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />          </svg>        </div>        <div className="min-w-0">          <p className="text-xs font-semibold text-foreground">            {props.title ?? "Update available"}          </p>          {props.version && <p className="text-sm text-muted-foreground">v{props.version}</p>}        </div>      </div>      {props.children}    </div>  );} // ── 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 (    <UpdateCardShell version={props.latestVersion}>      <Button        type="button"        variant="outline"        onClick={handleCopy}        className="mt-2.5 flex w-full items-center justify-between gap-2 rounded-lg border-border/60 bg-background/50 px-2.5 py-1.5 text-left hover:bg-background/80"      >        <code className="truncate font-mono text-xs text-sidebar-foreground">{command}</code>        <span className="shrink-0 text-muted-foreground transition-colors group-hover:text-foreground">          {copied ? (            <svg viewBox="0 0 16 16" fill="none" className="size-3 text-primary">              <path                d="M3 8.5l3.5 3.5L13 4"                stroke="currentColor"                strokeWidth="1.4"                strokeLinecap="round"                strokeLinejoin="round"              />            </svg>          ) : (            <svg viewBox="0 0 16 16" fill="none" className="size-3">              <rect                x="5"                y="5"                width="8"                height="8"                rx="1.5"                stroke="currentColor"                strokeWidth="1.2"              />              <path                d="M3 11V3.5A.5.5 0 013.5 3H11"                stroke="currentColor"                strokeWidth="1.2"                strokeLinecap="round"              />            </svg>          )}        </span>      </Button>    </UpdateCardShell>  );} // ── LinkUpdateCard (self-host / Cloudflare: link to the upgrade guide) ──── function LinkUpdateCard(props: { latestVersion: string; href: string }) {  return (    <UpdateCardShell version={props.latestVersion}>      <a        href={props.href}        target="_blank"        rel="noreferrer"        className="mt-2.5 flex w-full items-center justify-between gap-2 rounded-lg border border-border/60 bg-background/50 px-2.5 py-1.5 text-xs text-sidebar-foreground transition-colors hover:bg-background/80 hover:text-foreground"      >        <span>Upgrade guide</span>        <svg viewBox="0 0 16 16" fill="none" className="size-3 shrink-0">          <path            d="M4 12L12 4M6 4h6v6"            stroke="currentColor"            strokeWidth="1.4"            strokeLinecap="round"            strokeLinejoin="round"          />        </svg>      </a>    </UpdateCardShell>  );} // ── 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 (        <Button          type="button"          variant="outline"          onClick={install}          className="mt-2.5 w-full rounded-lg border-border/60 bg-background/50 px-2.5 py-1.5 text-xs hover:bg-background/80"        >          Restart to update        </Button>      );    }    if (status.state === "downloading") {      return (        <p className="mt-2.5 text-xs text-muted-foreground tabular-nums">          Downloading… {status.percent}%        </p>      );    }    if (status.state === "available") {      return <p className="mt-2.5 text-xs text-muted-foreground">Preparing update…</p>;    }    if (status.state === "installing") {      return <p className="mt-2.5 text-xs text-muted-foreground">Restarting…</p>;    }    if (status.state === "error") {      return <p className="mt-2.5 text-xs text-muted-foreground">{status.message}</p>;    }    return null;  })();   return (    <UpdateCardShell      title={status.state === "error" ? "Update failed" : undefined}      version={version}    >      {action}    </UpdateCardShell>  );} // ── 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" ? (      <DesktopUpdateCard update={desktopUpdate} />    ) : 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 <NpmUpdateCard latestVersion={latestVersion} channel={channel} />;  }  // 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 <LinkUpdateCard latestVersion={latestVersion} href={href} />;}