// 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 ; }