393 lines · 14.3 KB · TypeScript
RawDownload
/* oxlint-disable react/forbid-elements -- plugin component uses raw HTML controls per SDK convention; see @executor-js/plugin-example for the same pattern *//** * @executor-js/plugin-desktop-settings/client * * A single page mounted at `/plugins/desktop-settings/` that lets the user * inspect and configure the Electron sidecar's server connection. Talks to * the main process via `window.executor.*` (exposed by * `apps/desktop/src/preload`). * * The plugin is bundled into apps/local's renderer too (because executor * web + desktop share the same client bundle pipeline), but the page * only registers a nav entry when `window.executor` is present at * module-init time — so the web UI doesn't show a non-functional link. */ import { useCallback, useEffect, useState } from "react";import { defineClientPlugin } from "@executor-js/sdk/client"; // ---------------------------------------------------------------------------// Shape of the values the desktop preload exposes. Kept inline rather than// imported from @executor-js/plugin-desktop-settings (or a shared package)// so this client bundle has no runtime dependency on the Electron// surface — when `window.executor` is undefined (web), the page silently// no-ops instead of crashing.// --------------------------------------------------------------------------- interface DesktopServerSettings {  readonly port: number;  readonly requireAuth: boolean;  readonly password: string;} interface DesktopServerConnection {  readonly kind: "desktop-sidecar";  readonly key: "desktop-sidecar";  readonly origin: string;  readonly apiBaseUrl: string;  readonly displayName: string;  readonly auth?: {    readonly kind: "basic";    readonly username: string;    readonly password: string;  };} interface ExecutorBridge {  readonly getServerConnection: () => Promise<DesktopServerConnection | null>;  readonly getSettings: () => Promise<DesktopServerSettings>;  readonly updateSettings: (    patch: Partial<DesktopServerSettings>,  ) => Promise<DesktopServerSettings>;  readonly regeneratePassword: () => Promise<DesktopServerSettings>;  readonly restartServer: () => Promise<DesktopServerConnection>;} const readBridge = (): ExecutorBridge | null => {  if (typeof window === "undefined") return null;  const candidate = (window as Window & { readonly executor?: ExecutorBridge }).executor;  if (    !candidate ||    typeof candidate.getSettings !== "function" ||    typeof candidate.getServerConnection !== "function"  ) {    return null;  }  return candidate;}; const inDesktop = readBridge() !== null; // Normalize an IPC rejection into a user-facing string at this UI boundary.// The renderer doesn't get typed errors back from Electron's invoke channel.// We don't pull `err.message` out — the structured error doesn't help the// user, only "save failed" matters. The main process logs the full error.const describeIpcError = (_err: unknown): string =>  "Save failed — check the desktop console for details."; // ---------------------------------------------------------------------------// SettingsPage// --------------------------------------------------------------------------- function SettingsPage() {  const bridge = readBridge();  const [connection, setConnection] = useState<DesktopServerConnection | null>(null);  const [settings, setSettings] = useState<DesktopServerSettings | null>(null);  const [draft, setDraft] = useState<DesktopServerSettings | null>(null);  const [status, setStatus] = useState<"idle" | "saving" | "restarting" | "error">("idle");  const [error, setError] = useState<string | null>(null);   useEffect(() => {    if (!bridge) return;    void Promise.all([bridge.getSettings(), bridge.getServerConnection()]).then(      ([nextSettings, nextConnection]) => {        setSettings(nextSettings);        setDraft(nextSettings);        setConnection(nextConnection);      },    );  }, [bridge]);   const restartAndRefreshConnection = useCallback(async () => {    if (!bridge) return;    setStatus("restarting");    // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: renderer ↔ Electron IPC, errors surface in the form    try {      setConnection(await bridge.restartServer());    } catch (err) {      setError(describeIpcError(err));      setStatus("error");      return;    }    setStatus("idle");  }, [bridge]);   const apply = useCallback(    async (patch: Partial<DesktopServerSettings>) => {      if (!bridge) return;      setStatus("saving");      setError(null);      let next: DesktopServerSettings;      // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: renderer ↔ Electron IPC, errors surface in the form      try {        next = await bridge.updateSettings(patch);      } catch (err) {        setError(describeIpcError(err));        setStatus("error");        return;      }      setSettings(next);      setDraft(next);      await restartAndRefreshConnection();    },    [bridge, restartAndRefreshConnection],  );   const regenerate = useCallback(async () => {    if (!bridge) return;    setStatus("saving");    let next: DesktopServerSettings;    // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: renderer ↔ Electron IPC    try {      next = await bridge.regeneratePassword();    } catch (err) {      setError(describeIpcError(err));      setStatus("error");      return;    }    setSettings(next);    setDraft(next);    await restartAndRefreshConnection();  }, [bridge, restartAndRefreshConnection]);   if (!bridge) {    return (      <div style={{ maxWidth: 560, margin: "3rem auto", padding: "1.5rem" }}>        <h1 style={{ fontSize: "1.5rem", marginBottom: "1rem" }}>Desktop server settings</h1>        <p style={{ color: "var(--muted-foreground, #888)" }}>          Open this page from Executor Desktop to inspect and change the active server connection.        </p>      </div>    );  }   if (!settings || !draft || !connection) {    return <div style={{ maxWidth: 560, margin: "3rem auto", padding: "1.5rem" }}>Loading…</div>;  }   const dirty =    draft.port !== settings.port ||    draft.requireAuth !== settings.requireAuth ||    draft.password !== settings.password;   const authLabel =    connection.auth?.kind === "basic"      ? `Basic auth as ${connection.auth.username}`      : "No HTTP auth";  const cliProfileCommand = `executor server add desktop ${connection.origin} --default`;  const cliUseCommand =    connection.auth?.kind === "basic"      ? `EXECUTOR_AUTH_PASSWORD=${connection.auth.password} executor tools sources --server desktop`      : "executor tools sources --server desktop";   return (    <div style={{ maxWidth: 760, margin: "2rem auto", padding: "1.5rem" }}>      <h1 style={{ fontSize: "1.5rem", fontWeight: 600, marginBottom: "0.25rem" }}>        Desktop server connection      </h1>      <p        style={{          fontSize: "0.875rem",          color: "var(--muted-foreground, #888)",          marginBottom: "1.5rem",        }}      >        {connection.displayName}      </p>       <section        style={{          display: "grid",          gridTemplateColumns: "repeat(auto-fit, minmax(14rem, 1fr))",          gap: "0.75rem",          marginBottom: "1.5rem",        }}      >        <ConnectionField label="Origin" value={connection.origin} />        <ConnectionField label="API" value={connection.apiBaseUrl} />        <ConnectionField label="Auth" value={authLabel} />        <ConnectionField label="Kind" value={connection.kind} />      </section>       <section        style={{          display: "flex",          flexDirection: "column",          gap: "0.5rem",          marginBottom: "1.5rem",          padding: "0.85rem",          borderRadius: 6,          border: "1px solid var(--border, #ddd)",          background: "var(--muted, #f5f5f5)",        }}      >        <div style={{ fontSize: "0.8rem", fontWeight: 600 }}>CLI profile</div>        <code style={{ overflow: "auto", whiteSpace: "nowrap", fontSize: "0.8rem" }}>          {cliProfileCommand}        </code>        <code style={{ overflow: "auto", whiteSpace: "nowrap", fontSize: "0.8rem" }}>          {cliUseCommand}        </code>      </section>       <div style={{ display: "flex", flexDirection: "column", gap: "1.25rem" }}>        <label style={{ display: "flex", flexDirection: "column", gap: "0.35rem" }}>          <span style={{ fontSize: "0.875rem", fontWeight: 500 }}>Port</span>          {/* oxlint-disable-next-line react/forbid-elements -- plugin component uses raw HTML controls per SDK convention */}          <input            type="number"            min={1}            max={65535}            value={draft.port}            onChange={(e) => setDraft({ ...draft, port: Number(e.target.value) })}            style={{              padding: "0.5rem 0.7rem",              borderRadius: 6,              border: "1px solid var(--border, #ddd)",              fontFamily: "inherit",              fontSize: "0.95rem",              width: "8rem",            }}          />          <span style={{ fontSize: "0.75rem", color: "var(--muted-foreground, #888)" }}>            Changes restart the connection at <code>http://127.0.0.1:{draft.port}</code>.          </span>        </label>         <label style={{ display: "flex", alignItems: "flex-start", gap: "0.6rem" }}>          {/* oxlint-disable-next-line react/forbid-elements -- plugin component uses raw HTML controls per SDK convention */}          <input            type="checkbox"            checked={!draft.requireAuth}            onChange={(e) => setDraft({ ...draft, requireAuth: !e.target.checked })}            style={{ marginTop: "0.25rem" }}          />          <span style={{ display: "flex", flexDirection: "column", gap: "0.2rem" }}>            <span style={{ fontSize: "0.875rem", fontWeight: 500 }}>Use without a password</span>            <span style={{ fontSize: "0.75rem", color: "var(--muted-foreground, #888)" }}>              Disables HTTP Basic auth on the sidecar. Any process running as you on this machine              can hit <code>/api</code> directly.            </span>          </span>        </label>         {draft.requireAuth && (          <div style={{ display: "flex", flexDirection: "column", gap: "0.35rem" }}>            <span style={{ fontSize: "0.875rem", fontWeight: 500 }}>Password</span>            <div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>              <code                style={{                  flex: 1,                  padding: "0.5rem 0.7rem",                  borderRadius: 6,                  border: "1px solid var(--border, #ddd)",                  background: "var(--muted, #f5f5f5)",                  fontSize: "0.85rem",                  overflow: "auto",                  whiteSpace: "nowrap",                }}              >                {settings.password}              </code>              {/* oxlint-disable-next-line react/forbid-elements -- plugin component uses raw HTML controls per SDK convention */}              <button                type="button"                onClick={() => void regenerate()}                disabled={status !== "idle"}                style={{                  padding: "0.45rem 0.85rem",                  borderRadius: 6,                  border: "1px solid var(--border, #ddd)",                  background: "var(--background, white)",                  fontFamily: "inherit",                  fontSize: "0.85rem",                  cursor: status === "idle" ? "pointer" : "default",                }}              >                Regenerate              </button>            </div>            <span style={{ fontSize: "0.75rem", color: "var(--muted-foreground, #888)" }}>              Regenerating this changes the active server connection and invalidates existing HTTP              MCP client configs.            </span>          </div>        )}         <div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>          {/* oxlint-disable-next-line react/forbid-elements -- plugin component uses raw HTML controls per SDK convention */}          <button            type="button"            disabled={!dirty || status !== "idle"}            onClick={() =>              void apply({                port: draft.port,                requireAuth: draft.requireAuth,              })            }            style={{              padding: "0.55rem 1.1rem",              borderRadius: 6,              border: "1px solid transparent",              background: dirty ? "var(--primary, #0d0d10)" : "var(--muted, #eee)",              color: dirty ? "var(--primary-foreground, white)" : "var(--muted-foreground, #888)",              fontFamily: "inherit",              fontSize: "0.9rem",              cursor: dirty && status === "idle" ? "pointer" : "default",            }}          >            {status === "saving"              ? "Saving…"              : status === "restarting"                ? "Restarting server…"                : "Save"}          </button>          {error && (            <span style={{ fontSize: "0.8rem", color: "var(--destructive, #c00)" }}>{error}</span>          )}        </div>      </div>    </div>  );} function ConnectionField(props: { readonly label: string; readonly value: string }) {  return (    <div      style={{        display: "flex",        minWidth: 0,        flexDirection: "column",        gap: "0.2rem",        padding: "0.75rem",        borderRadius: 6,        border: "1px solid var(--border, #ddd)",      }}    >      <span style={{ fontSize: "0.7rem", color: "var(--muted-foreground, #888)" }}>        {props.label}      </span>      <code style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>        {props.value}      </code>    </div>  );} // ---------------------------------------------------------------------------// Plugin spec// --------------------------------------------------------------------------- export default defineClientPlugin({  id: "desktop-settings",  pages: [    {      path: "/",      component: SettingsPage,      // Only contribute a nav entry when running inside the desktop app.      // Web users see an empty Sources nav without a non-functional      // "Settings" link.      ...(inDesktop ? { nav: { label: "Settings" } } : {}),    },  ],});