@cjhyy/code-shell-web 0.9.25 → 0.9.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -21,6 +21,8 @@ export * from "./lib/pairing.js";
21
21
  export * from "./lib/deviceCredential.js";
22
22
  export * from "./lib/mobileAttachments.js";
23
23
  export * from "./lib/uiLanguage.js";
24
+ export * from "./link-authorization.js";
25
+ export * from "./link-authorization-view.js";
24
26
  export { mobile } from "./i18n/mobile.js";
25
27
  export { translate, t, webMessages, type WebTranslationKey, type WebTranslateParams, } from "./i18n/translate.js";
26
28
  export { isEnvironmentDescriptor, projectReferenceKey } from "./lib/environment.js";
package/dist/index.js CHANGED
@@ -24,6 +24,8 @@ export * from "./lib/pairing.js";
24
24
  export * from "./lib/deviceCredential.js";
25
25
  export * from "./lib/mobileAttachments.js";
26
26
  export * from "./lib/uiLanguage.js";
27
+ export * from "./link-authorization.js";
28
+ export * from "./link-authorization-view.js";
27
29
  // ── i18n: mobile namespace dict + zh-fallback translate ─────────────────────
28
30
  export { mobile } from "./i18n/mobile.js";
29
31
  export { translate, t, webMessages, } from "./i18n/translate.js";
@@ -54,6 +54,7 @@ function runStateForReason(reason) {
54
54
  case "model_error":
55
55
  case "image_error":
56
56
  case "replay_incomplete":
57
+ case "unverified_write":
57
58
  return "error";
58
59
  // "completed", "max_turns", "goal_budget_exhausted", "stop_hook_prevented",
59
60
  // "hook_stopped", and any future/unknown terminal reason: a normal stop.
@@ -0,0 +1,48 @@
1
+ import React from "react";
2
+ import type { LinkAuthorization, LinkAuthorizationResponse } from "@cjhyy/code-shell-link";
3
+ export interface LinkAuthorizationLabels {
4
+ openPage: string;
5
+ waitingBrowser: string;
6
+ deviceInstruction: string;
7
+ copy: string;
8
+ submit: string;
9
+ verifyCode: string;
10
+ secondFactor: string;
11
+ detectSession: string;
12
+ bindSession: string;
13
+ loginSession: string;
14
+ notInstalled: string;
15
+ notAuthenticated: string;
16
+ authenticated: string;
17
+ processing: string;
18
+ connected: string;
19
+ cancelled: string;
20
+ failed: string;
21
+ expired: string;
22
+ unsupported: string;
23
+ invalidUrl: string;
24
+ scan: string;
25
+ confirmOnPhone: string;
26
+ qrExpired: string;
27
+ refreshQr: string;
28
+ qrError: string;
29
+ qrAlt: string;
30
+ confirm: string;
31
+ resourcesTruncated: string;
32
+ expiresAt: string;
33
+ rejected: string;
34
+ cliUnavailable: string;
35
+ }
36
+ export interface LinkAuthorizationStepViewProps {
37
+ authorization: LinkAuthorization;
38
+ busy?: boolean;
39
+ onRespond: (response: LinkAuthorizationResponse) => void;
40
+ /** The host decides how to open an already validated URL. */
41
+ onOpenUrl: (url: string) => void;
42
+ /** Native remote authorization already has its own controlled window. */
43
+ canOpenUrl?: boolean;
44
+ onCopy?: (value: string) => void;
45
+ labels?: Partial<LinkAuthorizationLabels>;
46
+ }
47
+ /** Fixed React controls only: provider metadata never supplies HTML or handlers. */
48
+ export declare function LinkAuthorizationStepView(props: LinkAuthorizationStepViewProps): React.JSX.Element;
@@ -0,0 +1,178 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import React from "react";
3
+ import QRCode from "qrcode";
4
+ import { getLinkAuthorizationStep, safeLinkAuthorizationUrl } from "./link-authorization.js";
5
+ const DEFAULT_LABELS = {
6
+ openPage: "打开授权页面",
7
+ waitingBrowser: "在服务商页面完成授权,返回后会自动确认连接。",
8
+ deviceInstruction: "在授权页面输入此验证码:",
9
+ copy: "复制验证码",
10
+ submit: "验证并保存",
11
+ verifyCode: "输入服务商提供的验证码",
12
+ secondFactor: "完成服务商要求的追加验证",
13
+ detectSession: "检查登录状态",
14
+ bindSession: "使用这个账号",
15
+ loginSession: "登录账号",
16
+ notInstalled: "此环境尚未安装对应 CLI。",
17
+ notAuthenticated: "请先在当前环境登录对应 CLI,再检查状态。",
18
+ authenticated: "已登录",
19
+ processing: "正在确认并保存连接…",
20
+ connected: "连接已保存。",
21
+ cancelled: "授权已取消。",
22
+ failed: "授权未完成,请重新连接。",
23
+ expired: "授权已过期,请重新连接。",
24
+ unsupported: "当前客户端不支持这个授权步骤,请更新客户端。",
25
+ invalidUrl: "授权地址无效,请检查服务配置。",
26
+ scan: "使用服务商的手机应用扫描二维码。",
27
+ confirmOnPhone: "已扫码,请在手机上确认。",
28
+ qrExpired: "二维码已过期。",
29
+ refreshQr: "刷新二维码",
30
+ qrError: "无法显示二维码,请重试。",
31
+ qrAlt: "服务商授权二维码",
32
+ confirm: "确认授权",
33
+ resourcesTruncated: "此列表仅显示部分资源,请缩小选择范围后继续。",
34
+ expiresAt: "有效期至",
35
+ rejected: "服务商未接受此凭据,请检查后重试。",
36
+ cliUnavailable: "此环境没有可用的 CLI 登录,请检查登录状态或选择其他方式。",
37
+ };
38
+ /** Fixed React controls only: provider metadata never supplies HTML or handlers. */
39
+ export function LinkAuthorizationStepView(props) {
40
+ const labels = { ...DEFAULT_LABELS, ...props.labels };
41
+ const step = getLinkAuthorizationStep(props.authorization);
42
+ const [fields, setFields] = React.useState({});
43
+ const [selection, setSelection] = React.useState({});
44
+ const [qrImage, setQrImage] = React.useState();
45
+ const [qrError, setQrError] = React.useState(false);
46
+ const [now, setNow] = React.useState(Date.now());
47
+ const deadline = Math.min(props.authorization.expiresAt ? Date.parse(props.authorization.expiresAt) : Infinity, step ? Date.parse(step.expiresAt) : Infinity);
48
+ const qrDeadline = step?.kind === "qr-code" && step.qr ? Date.parse(step.qr.challengeExpiresAt) : Infinity;
49
+ const qrPayload = step?.kind === "qr-code" && step.phase !== "expired" ? step.qr?.payload : undefined;
50
+ React.useEffect(() => {
51
+ setFields({});
52
+ setSelection({});
53
+ }, [props.authorization.id, step?.id]);
54
+ React.useEffect(() => {
55
+ setNow(Date.now());
56
+ const timers = [deadline, qrDeadline]
57
+ .filter((expires) => Number.isFinite(expires) && expires > Date.now())
58
+ .map((expires) => setTimeout(() => setNow(Date.now()), expires - Date.now() + 1));
59
+ return () => {
60
+ for (const timer of timers)
61
+ clearTimeout(timer);
62
+ };
63
+ }, [deadline, qrDeadline]);
64
+ React.useEffect(() => {
65
+ let active = true;
66
+ setQrImage(undefined);
67
+ setQrError(false);
68
+ if (!qrPayload || qrPayload.length > 4_096 || qrDeadline <= Date.now()) {
69
+ if (qrPayload && qrPayload.length > 4_096)
70
+ setQrError(true);
71
+ return;
72
+ }
73
+ // Render the literal challenge locally. Never fetch or navigate QR content.
74
+ void QRCode.toDataURL(qrPayload, { width: 220, margin: 2, errorCorrectionLevel: "M" })
75
+ .then((value) => {
76
+ if (active)
77
+ setQrImage(value);
78
+ })
79
+ .catch(() => {
80
+ if (active)
81
+ setQrError(true);
82
+ });
83
+ return () => {
84
+ active = false;
85
+ };
86
+ }, [qrPayload, qrDeadline]);
87
+ if (props.authorization.state !== "pending") {
88
+ const message = props.authorization.state === "connected"
89
+ ? props.authorization.connection
90
+ ? labels.connected
91
+ : labels.processing
92
+ : props.authorization.state === "cancelled"
93
+ ? labels.cancelled
94
+ : props.authorization.errorCode === "authorization_expired"
95
+ ? labels.expired
96
+ : labels.failed;
97
+ return (_jsx("p", { className: "link-authorization-status", role: "status", children: message }));
98
+ }
99
+ if (!step)
100
+ return _jsx("p", { role: "alert", children: labels.unsupported });
101
+ if (!Number.isFinite(deadline) || deadline <= now)
102
+ return (_jsx("p", { className: "link-authorization-status", role: "status", children: labels.expired }));
103
+ const disabled = Boolean(props.busy);
104
+ const respond = (operation, input) => props.onRespond({ stepId: step.id, operation, ...(input ? { input } : {}) });
105
+ const openButton = (value) => {
106
+ if (props.canOpenUrl === false)
107
+ return null;
108
+ const url = safeLinkAuthorizationUrl(value);
109
+ return url ? (_jsxs("button", { type: "button", disabled: disabled, onClick: () => props.onOpenUrl(url), children: [labels.openPage, " \u2197"] })) : (_jsx("p", { role: "alert", children: labels.invalidUrl }));
110
+ };
111
+ const expires = (_jsxs("p", { className: "link-authorization-expires", children: [labels.expiresAt, " ", new Date(deadline).toLocaleTimeString()] }));
112
+ const error = props.authorization.errorCode ? (_jsx("p", { className: "link-authorization-error", role: "alert", children: props.authorization.errorCode === "provider_rejected"
113
+ ? labels.rejected
114
+ : props.authorization.errorCode === "cli_unavailable"
115
+ ? labels.cliUnavailable
116
+ : labels.failed })) : null;
117
+ const content = () => {
118
+ switch (step.kind) {
119
+ case "redirect":
120
+ return (_jsxs("div", { className: "link-authorization-step", role: "status", children: [_jsx("p", { children: labels.waitingBrowser }), openButton(step.authorizationUrl), expires] }));
121
+ case "device-code":
122
+ return (_jsxs("div", { className: "link-authorization-step link-authorization-device", children: [_jsx("p", { children: labels.deviceInstruction }), _jsx("strong", { className: "link-authorization-code", children: step.userCode }), _jsxs("div", { className: "link-authorization-actions", children: [props.onCopy && (_jsx("button", { type: "button", onClick: () => props.onCopy?.(step.userCode), children: labels.copy })), openButton(step.verificationUriComplete ?? step.verificationUri)] }), expires] }));
123
+ case "qr-code": {
124
+ const challengeExpired = step.phase === "expired" || qrDeadline <= now;
125
+ return (_jsxs("div", { className: "link-authorization-step link-authorization-qr", children: [_jsx("p", { role: "status", children: challengeExpired
126
+ ? labels.qrExpired
127
+ : step.phase === "awaiting-confirmation"
128
+ ? labels.confirmOnPhone
129
+ : labels.scan }), !challengeExpired && qrImage && (_jsx("img", { width: 220, height: 220, src: qrImage, alt: labels.qrAlt })), !challengeExpired && qrError && _jsx("p", { role: "alert", children: labels.qrError }), !challengeExpired &&
130
+ step.qr?.instructions.map((instruction, index) => _jsx("p", { children: instruction }, index)), step.canRefresh && (_jsx("button", { type: "button", disabled: disabled, onClick: () => respond("refresh-qr"), children: labels.refreshQr })), expires] }));
131
+ }
132
+ case "credential-input":
133
+ return (_jsxs("form", { className: "link-authorization-step link-authorization-fields", onSubmit: (event) => {
134
+ event.preventDefault();
135
+ if (disabled)
136
+ return;
137
+ const input = {};
138
+ for (const field of step.fields)
139
+ input[field.id] = fields[field.id] ?? "";
140
+ // Secrets are one-shot UI input. An uncertain response is reconciled with GET.
141
+ setFields({});
142
+ respond("submit", input);
143
+ }, children: [step.purpose !== "credential" && (_jsx("p", { children: step.purpose === "verification-code" ? labels.verifyCode : labels.secondFactor })), step.fields.map((field) => (_jsxs("label", { children: [field.label, _jsx("input", { name: field.id, type: field.secret ? "password" : "text", autoComplete: "off", spellCheck: false, value: fields[field.id] ?? "", placeholder: field.placeholder, maxLength: Math.min(field.maxLength ?? 16_384, 16_384), required: field.required, disabled: disabled, onChange: (event) => setFields((current) => ({ ...current, [field.id]: event.target.value })) })] }, field.id))), _jsx("button", { type: "submit", disabled: disabled || step.fields.some((field) => field.required && !fields[field.id]?.trim()), children: labels.submit }), expires] }));
144
+ case "local-session":
145
+ return (_jsxs("div", { className: "link-authorization-step", children: [_jsx("p", { role: "status", children: step.session.authenticated
146
+ ? `${labels.authenticated}${step.session.account ? `:${step.session.account}` : ""}`
147
+ : step.session.installed
148
+ ? labels.notAuthenticated
149
+ : labels.notInstalled }), step.session.message && _jsx("p", { children: step.session.message }), _jsxs("div", { className: "link-authorization-actions", children: [_jsx("button", { type: "button", disabled: disabled, onClick: () => respond("detect-session"), children: labels.detectSession }), !step.session.authenticated && step.session.canLogin && (_jsx("button", { type: "button", disabled: disabled, onClick: () => respond("login-session"), children: labels.loginSession })), step.session.authenticated && (_jsx("button", { type: "button", disabled: disabled, onClick: () => respond("bind-session"), children: labels.bindSession }))] }), expires] }));
150
+ case "consent": {
151
+ const permissionIds = selection.permissions ??
152
+ step.permissions.filter((item) => item.required).map((item) => item.id);
153
+ const toggle = (key, id, checked, current) => setSelection((previous) => ({
154
+ ...previous,
155
+ [key]: checked
156
+ ? [...new Set([...current, id])]
157
+ : current.filter((value) => value !== id),
158
+ }));
159
+ const invalid = step.resourceGroups.some((group) => {
160
+ const size = (selection[group.id] ?? []).length;
161
+ return (size < (group.minSelected ?? 0) ||
162
+ size > (group.maxSelected ?? Infinity) ||
163
+ group.truncated);
164
+ });
165
+ return (_jsxs("form", { className: "link-authorization-step link-authorization-consent", onSubmit: (event) => {
166
+ event.preventDefault();
167
+ if (!disabled && !invalid)
168
+ respond("confirm", { ...selection, permissions: permissionIds });
169
+ }, children: [_jsx("p", { children: _jsx("strong", { children: step.account.label }) }), _jsx("div", { children: step.permissions.map((permission) => (_jsxs("label", { children: [_jsx("input", { type: "checkbox", checked: permission.required || permissionIds.includes(permission.id), disabled: disabled || permission.required, onChange: (event) => toggle("permissions", permission.id, event.target.checked, permissionIds) }), permission.label, permission.description && _jsx("small", { children: permission.description })] }, permission.id))) }), step.resourceGroups.map((group) => (_jsxs("fieldset", { children: [_jsx("legend", { children: group.label }), group.items.map((item) => (_jsxs("label", { children: [_jsx("input", { type: "checkbox", checked: (selection[group.id] ?? []).includes(item.id), disabled: disabled, onChange: (event) => toggle(group.id, item.id, event.target.checked, selection[group.id] ?? []) }), item.label, item.description && _jsx("small", { children: item.description })] }, item.id))), group.truncated && _jsx("p", { children: labels.resourcesTruncated })] }, group.id))), _jsx("button", { type: "submit", disabled: disabled || invalid, children: labels.confirm }), expires] }));
170
+ }
171
+ case "processing":
172
+ return (_jsx("p", { className: "link-authorization-status", role: "status", children: labels.processing }));
173
+ default:
174
+ return _jsx("p", { role: "alert", children: labels.unsupported });
175
+ }
176
+ };
177
+ return (_jsxs(_Fragment, { children: [error, content()] }));
178
+ }
@@ -0,0 +1,51 @@
1
+ import type { LinkAuthMode, LinkAuthorization, LinkAuthorizationResponse, LinkAuthorizationStep, LinkConnectionInput, LinkProviderView } from "@cjhyy/code-shell-link";
2
+ export interface LinkAuthorizationTransport {
3
+ begin(input: LinkConnectionInput, authModeId: string): Promise<LinkAuthorization>;
4
+ status(id: string): Promise<LinkAuthorization>;
5
+ respond(id: string, response: LinkAuthorizationResponse): Promise<LinkAuthorization>;
6
+ cancel(id: string): Promise<LinkAuthorization | void>;
7
+ }
8
+ export interface LinkAuthorizationControllerSnapshot {
9
+ authorization?: LinkAuthorization;
10
+ busy: boolean;
11
+ /** Transport errors do not imply that the Host rejected the authorization. */
12
+ error?: Error;
13
+ }
14
+ /** Presentation URLs cannot contain credentials or executable protocols. */
15
+ export declare function safeLinkAuthorizationUrl(value?: string): string | undefined;
16
+ export declare function selectPreferredLinkAuthMode(provider: Pick<LinkProviderView, "authModes">, methodId?: string): LinkAuthMode | undefined;
17
+ /** Adapt only the two legacy wire shapes. Unknown new steps remain unsupported. */
18
+ export declare function getLinkAuthorizationStep(authorization: LinkAuthorization): LinkAuthorizationStep | undefined;
19
+ /**
20
+ * Browser-safe lifecycle shared by Desktop and Web. The Host owns credentials,
21
+ * challenge progression and persistence; this controller owns one UI attempt.
22
+ */
23
+ export declare class LinkAuthorizationController {
24
+ private readonly transport;
25
+ private readonly options;
26
+ private value;
27
+ private readonly listeners;
28
+ private timer?;
29
+ private generation;
30
+ private disposed;
31
+ private binding?;
32
+ constructor(transport: LinkAuthorizationTransport, options?: {
33
+ pollIntervalMs?: number;
34
+ retryIntervalMs?: number;
35
+ });
36
+ getSnapshot: () => LinkAuthorizationControllerSnapshot;
37
+ subscribe: (listener: () => void) => (() => void);
38
+ private publish;
39
+ private stop;
40
+ private current;
41
+ private deadline;
42
+ private accept;
43
+ private schedule;
44
+ private poll;
45
+ begin(input: LinkConnectionInput, authModeId: string): Promise<LinkAuthorization | undefined>;
46
+ respond(response: LinkAuthorizationResponse): Promise<LinkAuthorization | undefined>;
47
+ cancel(): Promise<void>;
48
+ clearError(): void;
49
+ reset(): void;
50
+ dispose(): void;
51
+ }
@@ -0,0 +1,273 @@
1
+ /** Presentation URLs cannot contain credentials or executable protocols. */
2
+ export function safeLinkAuthorizationUrl(value) {
3
+ try {
4
+ const url = new URL(value ?? "");
5
+ const transport = url.protocol === "https:" ||
6
+ (url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname));
7
+ return transport && !url.username && !url.password ? url.href : undefined;
8
+ }
9
+ catch {
10
+ return undefined;
11
+ }
12
+ }
13
+ export function selectPreferredLinkAuthMode(provider, methodId) {
14
+ const modes = provider.authModes?.filter((mode) => mode.available && (!methodId || mode.methodId === methodId));
15
+ return modes?.find((mode) => mode.preferred) ?? modes?.[0];
16
+ }
17
+ /** Adapt only the two legacy wire shapes. Unknown new steps remain unsupported. */
18
+ export function getLinkAuthorizationStep(authorization) {
19
+ if (authorization.state !== "pending")
20
+ return undefined;
21
+ if (authorization.step)
22
+ return authorization.step;
23
+ if (authorization.redirect)
24
+ return {
25
+ id: authorization.id,
26
+ kind: "redirect",
27
+ ...authorization.redirect,
28
+ };
29
+ if (authorization.prompt)
30
+ return { id: authorization.id, kind: "device-code", ...authorization.prompt };
31
+ return undefined;
32
+ }
33
+ const SUPPORTED_STEPS = new Set([
34
+ "redirect",
35
+ "device-code",
36
+ "qr-code",
37
+ "credential-input",
38
+ "local-session",
39
+ "consent",
40
+ "processing",
41
+ ]);
42
+ function assertAuthorization(value, expectedId) {
43
+ if (typeof value.id !== "string" || !value.id || (expectedId && value.id !== expectedId))
44
+ throw new Error("授权响应与当前连接不匹配。");
45
+ if (!["pending", "connected", "failed", "cancelled"].includes(value.state))
46
+ throw new Error("无法识别授权状态,请更新客户端。");
47
+ if (value.state === "connected" && !value.connection)
48
+ throw new Error("服务尚未确认连接已保存,请刷新后重试。");
49
+ if (value.state === "connected" &&
50
+ (value.connection?.providerId !== value.providerId ||
51
+ (value.methodId && value.connection.methodId !== value.methodId)))
52
+ throw new Error("保存的连接与当前授权不匹配。");
53
+ const step = getLinkAuthorizationStep(value);
54
+ if (value.state === "pending" && (!step || !SUPPORTED_STEPS.has(step.kind)))
55
+ throw new Error("当前客户端不支持这个授权步骤,请更新客户端。");
56
+ if (step && (!step.id || !Number.isFinite(Date.parse(step.expiresAt))))
57
+ throw new Error("授权步骤缺少有效期限,请重新连接。");
58
+ if (value.expiresAt &&
59
+ (!Number.isFinite(Date.parse(value.expiresAt)) ||
60
+ (step && Date.parse(step.expiresAt) > Date.parse(value.expiresAt))))
61
+ throw new Error("授权任务的有效期限无效,请重新连接。");
62
+ }
63
+ /**
64
+ * Browser-safe lifecycle shared by Desktop and Web. The Host owns credentials,
65
+ * challenge progression and persistence; this controller owns one UI attempt.
66
+ */
67
+ export class LinkAuthorizationController {
68
+ transport;
69
+ options;
70
+ value = { busy: false };
71
+ listeners = new Set();
72
+ timer;
73
+ generation = 0;
74
+ disposed = false;
75
+ binding;
76
+ constructor(transport, options = {}) {
77
+ this.transport = transport;
78
+ this.options = options;
79
+ }
80
+ getSnapshot = () => this.value;
81
+ subscribe = (listener) => {
82
+ this.listeners.add(listener);
83
+ return () => this.listeners.delete(listener);
84
+ };
85
+ publish(next) {
86
+ if (this.disposed)
87
+ return;
88
+ this.value = next;
89
+ for (const listener of this.listeners)
90
+ listener();
91
+ }
92
+ stop() {
93
+ clearTimeout(this.timer);
94
+ return ++this.generation;
95
+ }
96
+ current(generation) {
97
+ return !this.disposed && generation === this.generation;
98
+ }
99
+ deadline(authorization) {
100
+ const step = getLinkAuthorizationStep(authorization);
101
+ return Math.min(authorization.expiresAt ? Date.parse(authorization.expiresAt) : Infinity, step ? Date.parse(step.expiresAt) : Infinity);
102
+ }
103
+ accept(value, generation, expectedId) {
104
+ if (!this.current(generation))
105
+ return;
106
+ assertAuthorization(value, expectedId);
107
+ if (this.binding &&
108
+ (value.providerId !== this.binding.providerId ||
109
+ (value.methodId && value.methodId !== this.binding.methodId)))
110
+ throw new Error("授权响应与当前服务不匹配。");
111
+ this.publish({ authorization: value, busy: false });
112
+ if (value.state === "pending")
113
+ this.schedule(generation);
114
+ }
115
+ schedule(generation, delay = this.options.pollIntervalMs ?? 2_000) {
116
+ const authorization = this.value.authorization;
117
+ if (!this.current(generation) || authorization?.state !== "pending")
118
+ return;
119
+ const remaining = this.deadline(authorization) - Date.now();
120
+ if (remaining <= 0) {
121
+ this.stop();
122
+ this.publish({
123
+ busy: false,
124
+ authorization: {
125
+ ...authorization,
126
+ state: "failed",
127
+ step: undefined,
128
+ prompt: undefined,
129
+ redirect: undefined,
130
+ errorCode: "authorization_expired",
131
+ },
132
+ });
133
+ void this.transport.cancel(authorization.id).catch(() => { });
134
+ return;
135
+ }
136
+ const step = getLinkAuthorizationStep(authorization);
137
+ // Input and consent are advanced by explicit responses, never by a GET.
138
+ const needsInput = step && ["credential-input", "local-session", "consent"].includes(step.kind);
139
+ this.timer = setTimeout(() => {
140
+ if (needsInput)
141
+ this.schedule(generation);
142
+ else
143
+ void this.poll(generation);
144
+ }, Math.min(delay, remaining));
145
+ }
146
+ async poll(generation) {
147
+ const authorization = this.value.authorization;
148
+ if (!this.current(generation) || authorization?.state !== "pending")
149
+ return;
150
+ if (this.deadline(authorization) <= Date.now()) {
151
+ this.schedule(generation);
152
+ return;
153
+ }
154
+ try {
155
+ const next = await this.transport.status(authorization.id);
156
+ this.accept(next, generation, authorization.id);
157
+ }
158
+ catch (cause) {
159
+ if (!this.current(generation))
160
+ return;
161
+ this.publish({ ...this.value, busy: false, error: asError(cause) });
162
+ const status = cause?.status;
163
+ if (status !== 401 && status !== 404)
164
+ this.schedule(generation, this.options.retryIntervalMs ?? 4_000);
165
+ }
166
+ }
167
+ async begin(input, authModeId) {
168
+ if (this.disposed || this.value.busy || this.value.authorization?.state === "pending")
169
+ return;
170
+ const generation = this.stop();
171
+ this.binding = input;
172
+ this.publish({ busy: true });
173
+ try {
174
+ const value = await this.transport.begin(input, authModeId);
175
+ if (!this.current(generation)) {
176
+ if (value.state === "pending")
177
+ void this.transport.cancel(value.id).catch(() => { });
178
+ return;
179
+ }
180
+ try {
181
+ this.accept(value, generation);
182
+ }
183
+ catch (cause) {
184
+ if (value.state === "pending")
185
+ void this.transport.cancel(value.id).catch(() => { });
186
+ throw cause;
187
+ }
188
+ return value;
189
+ }
190
+ catch (cause) {
191
+ if (this.current(generation))
192
+ this.publish({ busy: false, error: asError(cause) });
193
+ }
194
+ }
195
+ async respond(response) {
196
+ const authorization = this.value.authorization;
197
+ const step = authorization && getLinkAuthorizationStep(authorization);
198
+ if (this.disposed ||
199
+ this.value.busy ||
200
+ authorization?.state !== "pending" ||
201
+ !step ||
202
+ step.id !== response.stepId)
203
+ return;
204
+ if (this.deadline(authorization) <= Date.now()) {
205
+ this.schedule(this.generation);
206
+ return;
207
+ }
208
+ const generation = this.stop();
209
+ this.publish({ authorization, busy: true });
210
+ try {
211
+ const value = await this.transport.respond(authorization.id, response);
212
+ this.accept(value, generation, authorization.id);
213
+ return this.current(generation) ? value : undefined;
214
+ }
215
+ catch (cause) {
216
+ if (this.current(generation)) {
217
+ this.publish({ authorization, busy: false, error: asError(cause) });
218
+ // An uncertain write must be reconciled by a read, never replayed.
219
+ this.timer = setTimeout(() => void this.poll(generation), this.options.retryIntervalMs ?? 4_000);
220
+ }
221
+ }
222
+ }
223
+ async cancel() {
224
+ const authorization = this.value.authorization;
225
+ const generation = this.stop();
226
+ if (authorization?.state !== "pending") {
227
+ this.publish({ busy: false });
228
+ return;
229
+ }
230
+ this.publish({ authorization, busy: true });
231
+ try {
232
+ const result = await this.transport.cancel(authorization.id);
233
+ if (!this.current(generation))
234
+ return;
235
+ this.publish({
236
+ busy: false,
237
+ authorization: result ?? {
238
+ ...authorization,
239
+ state: "cancelled",
240
+ step: undefined,
241
+ prompt: undefined,
242
+ redirect: undefined,
243
+ },
244
+ });
245
+ }
246
+ catch (cause) {
247
+ if (this.current(generation)) {
248
+ this.publish({ authorization, busy: false, error: asError(cause) });
249
+ this.schedule(generation, this.options.retryIntervalMs ?? 4_000);
250
+ }
251
+ }
252
+ }
253
+ clearError() {
254
+ this.publish({ ...this.value, error: undefined });
255
+ }
256
+ reset() {
257
+ if (this.value.busy || this.value.authorization?.state === "pending")
258
+ return;
259
+ this.stop();
260
+ this.publish({ busy: false });
261
+ }
262
+ dispose() {
263
+ const authorization = this.value.authorization;
264
+ this.stop();
265
+ this.disposed = true;
266
+ this.listeners.clear();
267
+ if (authorization?.state === "pending")
268
+ void this.transport.cancel(authorization.id).catch(() => { });
269
+ }
270
+ }
271
+ function asError(cause) {
272
+ return cause instanceof Error ? cause : new Error("连接操作失败,请重试。");
273
+ }