Files
NetMesh/application/state/usePluginAuthenticationChallenges.ts
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

168 lines
5.6 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { AuthenticationChallenge } from "@netcatty/plugin-contract";
import { isSafePluginAuthenticationUrl } from "../../domain/pluginConnection";
import { pluginExtensionBridge } from "./pluginExtensionBridge";
type ChallengeEvent = NetcattyPluginAuthenticationChallengeEvent;
export type PluginAuthenticationChallengeResponse = string | boolean | ReadonlyArray<string>;
type ActiveChallengeEvent = Extract<ChallengeEvent, { challenge: AuthenticationChallenge }>;
type ChallengeQueueRef = { current: ActiveChallengeEvent[] };
type ChallengeQueueSetter = (next: ActiveChallengeEvent[]) => void;
type ChallengeResponder = typeof pluginExtensionBridge.respondAuthenticationChallenge;
const MAX_PENDING_AUTHENTICATION_CHALLENGES = 32;
export const handlePluginAuthenticationChallengeEvent = (
queueRef: ChallengeQueueRef,
setQueue: ChallengeQueueSetter,
event: ChallengeEvent,
respond: ChallengeResponder,
) => {
const existing = queueRef.current;
if ("cancelled" in event && event.cancelled === true) {
const next = existing.filter((item) => item.challengeRequestId !== event.challengeRequestId);
if (next.length !== existing.length) {
queueRef.current = next;
setQueue(next);
}
return;
}
if (existing.some((item) => item.challengeRequestId === event.challengeRequestId)) return;
if (existing.length >= MAX_PENDING_AUTHENTICATION_CHALLENGES) {
void respond({
requestId: event.requestId,
challengeRequestId: event.challengeRequestId,
challengeId: event.challenge.id,
cancelled: true,
}).catch(() => {});
return;
}
const next = [...existing, event];
queueRef.current = next;
setQueue(next);
};
export const pluginAuthenticationChallengeMessage = (challenge: AuthenticationChallenge): string | undefined => (
"message" in challenge && typeof challenge.message === "string" ? challenge.message : undefined
);
export const pluginAuthenticationResponseErrorMessage = (error: unknown): string => {
const message = error instanceof Error
? error.message
: typeof error === "string" ? error : "";
return message.trim().slice(0, 512);
};
export function usePluginAuthenticationChallenges() {
const [queue, setQueue] = useState<ActiveChallengeEvent[]>([]);
const queueRef = useRef<ActiveChallengeEvent[]>([]);
const [textValue, setTextValue] = useState("");
const [selectedChoices, setSelectedChoices] = useState<string[]>([]);
const [responseError, setResponseError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const current = queue[0];
const challenge = current?.challenge;
useEffect(() => {
return pluginExtensionBridge.onAuthenticationChallenge((event) => {
handlePluginAuthenticationChallengeEvent(
queueRef,
setQueue,
event,
(response) => pluginExtensionBridge.respondAuthenticationChallenge(response),
);
});
}, []);
useEffect(() => {
setTextValue("");
setSelectedChoices([]);
setResponseError(null);
setBusy(false);
}, [current?.challengeRequestId]);
const complete = useCallback(async (
response?: PluginAuthenticationChallengeResponse,
cancelled = false,
) => {
if (!current || busy) return;
setBusy(true);
try {
await pluginExtensionBridge.respondAuthenticationChallenge({
requestId: current.requestId,
challengeRequestId: current.challengeRequestId,
challengeId: current.challenge.id,
...(cancelled ? { cancelled: true } : { response }),
});
const next = queueRef.current.filter(
(item) => item.challengeRequestId !== current.challengeRequestId,
);
queueRef.current = next;
setQueue(next);
setResponseError(null);
} catch (error) {
setResponseError(pluginAuthenticationResponseErrorMessage(error));
} finally {
setBusy(false);
}
}, [busy, current]);
const externalUrl = useMemo(() => {
if (!challenge) return null;
const value = challenge.kind === "browser"
? challenge.url
: challenge.kind === "deviceCode"
? challenge.verificationUri
: null;
return value && isSafePluginAuthenticationUrl(value) ? value : null;
}, [challenge]);
const openExternal = useCallback(async () => {
if (!externalUrl) return;
await pluginExtensionBridge.openExternal(externalUrl);
}, [externalUrl]);
const isText = challenge?.kind === "text" || challenge?.kind === "password" || challenge?.kind === "otp";
const canSubmit = challenge?.kind === "choice"
? selectedChoices.length > 0
: isText
? textValue.length > 0
: challenge?.kind === "browser" || challenge?.kind === "deviceCode"
? externalUrl !== null
: Boolean(challenge);
const submit = useCallback(() => {
if (!challenge) return;
if (isText) void complete(textValue);
else if (challenge.kind === "choice") {
void complete(challenge.multiple ? selectedChoices : selectedChoices[0]);
} else {
void complete(true);
}
}, [challenge, complete, isText, selectedChoices, textValue]);
const setChoiceSelected = useCallback((choiceId: string, checked: boolean) => {
setSelectedChoices((existing) => challenge?.kind === "choice" && challenge.multiple
? checked
? [...new Set([...existing, choiceId])]
: existing.filter((id) => id !== choiceId)
: checked ? [choiceId] : []);
}, [challenge]);
return {
challenge,
busy,
textValue,
setTextValue,
selectedChoices,
setChoiceSelected,
externalUrl,
responseError,
openExternal,
complete,
isText,
canSubmit,
submit,
};
}