// 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) — `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> = { 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(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>; }, 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 (

{props.title ?? "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…

; } if (status.state === "error") { return

{status.message}

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