@huaqiu/dsh-auth 0.1.1
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/LICENSE +21 -0
- package/README.md +22 -0
- package/cordis.patch.yml +7 -0
- package/lib/client.js +1427 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.mts +73 -0
- package/lib/index.mjs +291 -0
- package/package.json +55 -0
- package/src/client/auth-state.ts +96 -0
- package/src/client/client.ts +169 -0
- package/src/client/i18n.ts +97 -0
- package/src/client/index.tsx +109 -0
- package/src/client/lib.ts +234 -0
- package/src/client/storage.ts +43 -0
- package/src/client/transport.ts +36 -0
- package/src/client/ui/common.tsx +169 -0
- package/src/client/ui/hq-icon.tsx +50 -0
- package/src/client/ui/login-dialog.ts +219 -0
- package/src/client/ui/needs-auth-toolview.tsx +118 -0
- package/src/client/ui/sidebar-action.tsx +395 -0
- package/src/client/ui-env.ts +175 -0
- package/src/host.ts +139 -0
- package/src/index.ts +42 -0
- package/src/routes.ts +84 -0
- package/src/service.ts +172 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,1427 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "@huaqiu/dsh-auth",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
let react_jsx_runtime = require("react/jsx-runtime");
|
|
8
|
+
let react = require("react");
|
|
9
|
+
let react_dom = require("react-dom");
|
|
10
|
+
//#region src/client/storage.ts
|
|
11
|
+
const DEFAULT_STORAGE_KEY = "huaqiu.dsh.auth";
|
|
12
|
+
function createAuthStorage(storage, key = DEFAULT_STORAGE_KEY) {
|
|
13
|
+
return {
|
|
14
|
+
get() {
|
|
15
|
+
const raw = storage.getItem(key);
|
|
16
|
+
if (!raw) return null;
|
|
17
|
+
try {
|
|
18
|
+
const parsed = JSON.parse(raw);
|
|
19
|
+
if (!parsed || typeof parsed.token !== "string" || typeof parsed.id !== "string") return null;
|
|
20
|
+
if (parsed.expiresAt !== void 0 && parsed.expiresAt * 1e3 <= Date.now()) {
|
|
21
|
+
storage.removeItem(key);
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
return parsed;
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
set(info) {
|
|
30
|
+
storage.setItem(key, JSON.stringify(info));
|
|
31
|
+
},
|
|
32
|
+
clear() {
|
|
33
|
+
storage.removeItem(key);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
//#endregion
|
|
38
|
+
//#region src/client/transport.ts
|
|
39
|
+
function createWebServerAuthTransport(base = "/api/v1/huaqiu/auth", doFetch = globalThis.fetch.bind(globalThis)) {
|
|
40
|
+
return {
|
|
41
|
+
async pushSession(info) {
|
|
42
|
+
const res = await doFetch(`${base}/session`, {
|
|
43
|
+
method: "POST",
|
|
44
|
+
headers: { "content-type": "application/json" },
|
|
45
|
+
body: JSON.stringify({
|
|
46
|
+
token: info.token,
|
|
47
|
+
userId: info.id,
|
|
48
|
+
...info.nickname !== void 0 ? { nickname: info.nickname } : {}
|
|
49
|
+
})
|
|
50
|
+
});
|
|
51
|
+
if (!res.ok) throw new Error(`auth push failed: HTTP ${res.status}`);
|
|
52
|
+
},
|
|
53
|
+
async pushLogout() {
|
|
54
|
+
const res = await doFetch(`${base}/logout`, { method: "POST" });
|
|
55
|
+
if (!res.ok) throw new Error(`auth logout push failed: HTTP ${res.status}`);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**「Go to profile」destination: the eda.cn account page. */
|
|
60
|
+
const PROFILE_URL = "https://www.eda.cn/account/profile";
|
|
61
|
+
/**
|
|
62
|
+
* Build the「Go to profile」URL: the eda.cn account page WITH the access token
|
|
63
|
+
* in the query, mirroring `hq-eda-ai`'s `UserMenu`
|
|
64
|
+
* (`/account/profile?token=…&phone=…`).
|
|
65
|
+
*
|
|
66
|
+
* The token is always attached: eda.cn consumes it to establish the session
|
|
67
|
+
* and strips it from the address bar / history itself, so there is nothing to
|
|
68
|
+
* leak beyond the target site. `encodeURIComponent` is required (not cosmetic):
|
|
69
|
+
* tokens are base64-ish and may contain `+`, `/` or `=`, and a raw `+` in a
|
|
70
|
+
* query string decodes to a space, which would corrupt the credential.
|
|
71
|
+
*/
|
|
72
|
+
function buildProfileUrl(options) {
|
|
73
|
+
const phone = options.phone === void 0 || options.phone === null ? "" : String(options.phone);
|
|
74
|
+
return `${PROFILE_URL}?token=${encodeURIComponent(options.token)}&phone=${encodeURIComponent(phone)}`;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Contract version of the auth.eda.cn embed, shared with the web app
|
|
78
|
+
* (`hq-eda-ai` LoginDialog) so both send the same cache-busting `v=`.
|
|
79
|
+
*/
|
|
80
|
+
const AUTH_IFRAME_VERSION = "20260409";
|
|
81
|
+
/**
|
|
82
|
+
* auth.eda.cn's own language ids, keyed by our locale id.
|
|
83
|
+
*
|
|
84
|
+
* The embed reads `?locale=`, NOT `lang`: `eda-cn-login/app/layout.tsx` reads
|
|
85
|
+
* `urlParams.get('locale')` and `components/ui/LanguageContext.tsx`
|
|
86
|
+
* (`getLangFromUrl`) only accepts the ids in `locales/index.ts` — `cn` and
|
|
87
|
+
* `en` (`zh` / `zh_CN` are aliased to `cn` there, but we send the canonical
|
|
88
|
+
* id outright).
|
|
89
|
+
*
|
|
90
|
+
* NOTE: `hq-eda-ai`'s `LoginDialog.tsx` sends `lang=zh`, which the embed
|
|
91
|
+
* IGNORES, so its login card always falls back to whatever the browser asks
|
|
92
|
+
* for. We send `locale` (what is actually read) and keep `lang` alongside it
|
|
93
|
+
* for parity with the web app and forward compatibility.
|
|
94
|
+
*/
|
|
95
|
+
const AUTH_LOCALE_ID = {
|
|
96
|
+
zh: "cn",
|
|
97
|
+
en: "en"
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* Build the auth.eda.cn embed URL.
|
|
101
|
+
*
|
|
102
|
+
* The URL switches between two rendering modes based on `options.fill`:
|
|
103
|
+
* - `fill: 'full'` (or `true`) → `fill=full` is sent; the embed's
|
|
104
|
+
* `DialogContent` becomes `w-full h-full … rounded-none` and the wrapper
|
|
105
|
+
* drops `bg-transparent`, so the embed fills the iframe viewport with
|
|
106
|
+
* its own `bg-background`. Use this when the iframe is the surface (e.g.
|
|
107
|
+
* the toolview card).
|
|
108
|
+
* - any other value (including unset) → no `fill` is sent; the embed stays
|
|
109
|
+
* in transparent card mode. The host is responsible for painting a card
|
|
110
|
+
* around the iframe so Blink's white base canvas never reaches the user.
|
|
111
|
+
*/
|
|
112
|
+
function buildLoginUrl(options = {}) {
|
|
113
|
+
const url = new URL(options.baseUrl ?? `https://auth.eda.cn/`);
|
|
114
|
+
url.searchParams.set("v", AUTH_IFRAME_VERSION);
|
|
115
|
+
if (options.closeOnOutsideClick !== false) url.searchParams.set("clickOutsideToClose", "true");
|
|
116
|
+
if (options.fill === "full" || options.fill === true) url.searchParams.set("fill", "full");
|
|
117
|
+
url.searchParams.set("transparent", "true");
|
|
118
|
+
const lang = options.lang ?? "zh";
|
|
119
|
+
url.searchParams.set("locale", AUTH_LOCALE_ID[lang]);
|
|
120
|
+
url.searchParams.set("lang", lang);
|
|
121
|
+
url.searchParams.set("theme", options.theme ?? "light");
|
|
122
|
+
return url.toString();
|
|
123
|
+
}
|
|
124
|
+
/** Coerce an id field (string or number, as auth.eda.cn sends) to a string. */
|
|
125
|
+
function stringifyId(value) {
|
|
126
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
127
|
+
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
function parseAuthMessage(raw) {
|
|
131
|
+
let envelope = null;
|
|
132
|
+
if (typeof raw === "string") try {
|
|
133
|
+
envelope = JSON.parse(raw);
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
else if (raw !== null && typeof raw === "object") envelope = raw;
|
|
138
|
+
if (!envelope || envelope.category !== 1) return null;
|
|
139
|
+
const data = envelope.data;
|
|
140
|
+
if (!data || typeof data !== "object") return null;
|
|
141
|
+
switch (data.type) {
|
|
142
|
+
case "update_access_token": {
|
|
143
|
+
const d = data.data;
|
|
144
|
+
if (!d || typeof d !== "object") return null;
|
|
145
|
+
const record = d;
|
|
146
|
+
const token = typeof record.token === "string" && record.token.length > 0 ? record.token : null;
|
|
147
|
+
const id = stringifyId(record.userId) ?? stringifyId(record.id);
|
|
148
|
+
if (!token || !id) return null;
|
|
149
|
+
const nickname = typeof record.nickname === "string" && record.nickname.length > 0 ? record.nickname : void 0;
|
|
150
|
+
const avatar = typeof record.headimage === "string" && record.headimage.length > 0 ? record.headimage : typeof record.avatar === "string" && record.avatar.length > 0 ? record.avatar : void 0;
|
|
151
|
+
const phone = stringifyId(record.phone) ?? void 0;
|
|
152
|
+
const expiresAt = typeof record.expires_at === "number" ? record.expires_at : void 0;
|
|
153
|
+
return {
|
|
154
|
+
kind: "token",
|
|
155
|
+
info: {
|
|
156
|
+
id,
|
|
157
|
+
token,
|
|
158
|
+
...nickname !== void 0 ? { nickname } : {},
|
|
159
|
+
...avatar !== void 0 ? { avatar } : {},
|
|
160
|
+
...phone !== void 0 ? { phone } : {},
|
|
161
|
+
...expiresAt !== void 0 ? { expiresAt } : {}
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
case "logout": return { kind: "logout" };
|
|
166
|
+
case "close_dialog": return { kind: "close" };
|
|
167
|
+
default: return null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/** Origin-agnostic envelope parsing. The ONLY entry point for window message events. */
|
|
171
|
+
function handleAuthMessage(event) {
|
|
172
|
+
return parseAuthMessage(event.data);
|
|
173
|
+
}
|
|
174
|
+
//#endregion
|
|
175
|
+
//#region src/client/ui/common.tsx
|
|
176
|
+
/** Best-effort JSON.parse of the tool's text output blocks. */
|
|
177
|
+
function parseToolResult(block) {
|
|
178
|
+
if (!block || !Array.isArray(block.content)) return null;
|
|
179
|
+
const text = block.content.filter((c) => !!c && c.type === "text" && typeof c.text === "string").map((c) => c.text).join("");
|
|
180
|
+
if (!text) return null;
|
|
181
|
+
try {
|
|
182
|
+
const parsed = JSON.parse(text);
|
|
183
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
184
|
+
} catch {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/** True when the parsed result is the auth-gate signal. */
|
|
189
|
+
function isNeedsAuthResult(result) {
|
|
190
|
+
return !!result && result.status === "needs_auth";
|
|
191
|
+
}
|
|
192
|
+
const LIGHT_CARD_PALETTE = {
|
|
193
|
+
surface: "var(--dsw-alias-bg-layer-1, #ffffff)",
|
|
194
|
+
border: "var(--dsw-alias-border-l1, #e4e7ec)",
|
|
195
|
+
text: "var(--dsw-alias-label-primary, inherit)",
|
|
196
|
+
muted: "var(--dsw-alias-label-secondary, #5b6472)",
|
|
197
|
+
success: "var(--dsw-alias-state-success-primary, #1677ff)",
|
|
198
|
+
danger: "var(--dsw-alias-state-error-primary, #d4380d)"
|
|
199
|
+
};
|
|
200
|
+
const DARK_CARD_PALETTE = {
|
|
201
|
+
surface: "var(--dsw-alias-bg-layer-1, #20242c)",
|
|
202
|
+
border: "var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.14))",
|
|
203
|
+
text: "var(--dsw-alias-label-primary, #e6eaf0)",
|
|
204
|
+
muted: "var(--dsw-alias-label-secondary, #8b95a5)",
|
|
205
|
+
success: "var(--dsw-alias-state-success-primary, #4cc38a)",
|
|
206
|
+
danger: "var(--dsw-alias-state-error-primary, #ff7875)"
|
|
207
|
+
};
|
|
208
|
+
function cardPalette(dark) {
|
|
209
|
+
return dark ? DARK_CARD_PALETTE : LIGHT_CARD_PALETTE;
|
|
210
|
+
}
|
|
211
|
+
function cardStyle(palette) {
|
|
212
|
+
return {
|
|
213
|
+
border: `1px solid ${palette.border}`,
|
|
214
|
+
borderRadius: 10,
|
|
215
|
+
padding: "12px 14px",
|
|
216
|
+
margin: "4px 0",
|
|
217
|
+
background: palette.surface,
|
|
218
|
+
color: palette.text,
|
|
219
|
+
fontFamily: "inherit"
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
const TITLE_STYLE = {
|
|
223
|
+
fontSize: 14,
|
|
224
|
+
fontWeight: 600,
|
|
225
|
+
margin: "0 0 6px"
|
|
226
|
+
};
|
|
227
|
+
const STATUS_STYLE = {
|
|
228
|
+
fontSize: 13,
|
|
229
|
+
margin: "0 0 10px",
|
|
230
|
+
lineHeight: 1.5
|
|
231
|
+
};
|
|
232
|
+
/**
|
|
233
|
+
* The embedded login iframe: painted with the same surface as the wrapping
|
|
234
|
+
* card so the login box blends in both schemes.
|
|
235
|
+
*
|
|
236
|
+
* Why not `background: transparent`? Blink's `BaseBackgroundColor()` falls
|
|
237
|
+
* back to WHITE whenever the embedded doc's root element has a transparent
|
|
238
|
+
* background (and auth.eda.cn's `data-iframe-mode` page is exactly that).
|
|
239
|
+
* That white canvas shows through wherever the document doesn't paint, which
|
|
240
|
+
* reads as a glaring white "frame" around the login card in dark mode. Light
|
|
241
|
+
* mode hid the bug because the white canvas happened to match the light
|
|
242
|
+
* host. Painting the iframe ELEMENT with the card's surface (DSH alias
|
|
243
|
+
* `--dsw-alias-bg-layer-1` with a per-scheme fallback) puts a dark sheet in
|
|
244
|
+
* dark mode and a light sheet in light mode, so the login card sits on a
|
|
245
|
+
* surface that blends with the host in both schemes.
|
|
246
|
+
*/
|
|
247
|
+
function iframeStyle(palette) {
|
|
248
|
+
return {
|
|
249
|
+
width: "100%",
|
|
250
|
+
height: 440,
|
|
251
|
+
border: `1px solid ${palette.border}`,
|
|
252
|
+
borderRadius: 8,
|
|
253
|
+
background: palette.surface,
|
|
254
|
+
display: "block"
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
function StatusLine({ authenticated, nickname, palette, t }) {
|
|
258
|
+
if (authenticated) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
259
|
+
style: {
|
|
260
|
+
...STATUS_STYLE,
|
|
261
|
+
color: palette.success
|
|
262
|
+
},
|
|
263
|
+
children: t("card.loggedIn", { nickname: nickname ? t("card.nicknameSep", { nickname }) : "" })
|
|
264
|
+
});
|
|
265
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
266
|
+
style: {
|
|
267
|
+
...STATUS_STYLE,
|
|
268
|
+
color: palette.danger
|
|
269
|
+
},
|
|
270
|
+
children: t("card.loggedOut")
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
//#endregion
|
|
274
|
+
//#region src/client/ui-env.ts
|
|
275
|
+
/**
|
|
276
|
+
* Host theme + locale sensing for the client UI.
|
|
277
|
+
*
|
|
278
|
+
* The DSH slot system injects React components with PROPS, not the cordis ctx,
|
|
279
|
+
* so the cards cannot reach `ctx.theme` / `ctx.locale` the way a plugin body
|
|
280
|
+
* can. Both services do, however, publish their state into the DOM, and that
|
|
281
|
+
* is what this module reads:
|
|
282
|
+
*
|
|
283
|
+
* - THEME — `ui-layout`'s presenter switches `body[data-ds-dark-theme]` from
|
|
284
|
+
* the resolved snapshot (`packages/client/ui-layout/src/client/theme-presenter.ts`,
|
|
285
|
+
* `DARK_ATTRIBUTE`), so the attribute's presence IS the dark palette. Same
|
|
286
|
+
* signal the sibling packages already use
|
|
287
|
+
* (`dsh-tool-schematic-gen/src/client/theme.ts`). `prefers-color-scheme` is
|
|
288
|
+
* deliberately NOT consulted: DSH resolves `system` itself, and an OS-dark /
|
|
289
|
+
* DSH-light combination would then be misdetected.
|
|
290
|
+
* - LOCALE — `dsh-client-locale` writes `<html lang>` on every locale change
|
|
291
|
+
* (`syncDocumentLanguage`: `zh-CN` | `en`). Falling back to the browser's
|
|
292
|
+
* own `navigator.languages` keeps the UI usable on hosts without that
|
|
293
|
+
* plugin. Chinese is the last resort because this is a Chinese-first app
|
|
294
|
+
* (and `hq-eda-ai` defaults to zh: `languageMap[lang] || "zh"`).
|
|
295
|
+
*
|
|
296
|
+
* Both are exposed as `useSyncExternalStore` snapshots so every mounted card
|
|
297
|
+
* re-renders together when the user flips theme or language.
|
|
298
|
+
*/
|
|
299
|
+
/** DSH's dark-palette marker, written by ui-layout's theme presenter. */
|
|
300
|
+
const DARK_ATTRIBUTE = "data-ds-dark-theme";
|
|
301
|
+
function isDarkDocument() {
|
|
302
|
+
if (typeof document === "undefined") return false;
|
|
303
|
+
if (document.body?.hasAttribute("data-ds-dark-theme")) return true;
|
|
304
|
+
const root = document.documentElement;
|
|
305
|
+
if (!root) return false;
|
|
306
|
+
const dataTheme = root.getAttribute("data-theme");
|
|
307
|
+
if (dataTheme !== null) return dataTheme.toLowerCase() === "dark";
|
|
308
|
+
return root.classList.contains("dark");
|
|
309
|
+
}
|
|
310
|
+
/** `zh-CN`, `zh-Hans`, `en-GB`, … → our locale id (`undefined` = unknown). */
|
|
311
|
+
function localeFromTag(tag) {
|
|
312
|
+
if (!tag) return void 0;
|
|
313
|
+
const primary = tag.toLowerCase().split("-")[0];
|
|
314
|
+
return primary === "zh" || primary === "en" ? primary : void 0;
|
|
315
|
+
}
|
|
316
|
+
function detectLocale() {
|
|
317
|
+
if (typeof document !== "undefined") {
|
|
318
|
+
const fromDocument = localeFromTag(document.documentElement?.getAttribute("lang"));
|
|
319
|
+
if (fromDocument) return fromDocument;
|
|
320
|
+
}
|
|
321
|
+
if (typeof navigator !== "undefined" && typeof window !== "undefined") for (const tag of [...navigator.languages ?? [], navigator.language]) {
|
|
322
|
+
const match = localeFromTag(tag);
|
|
323
|
+
if (match) return match;
|
|
324
|
+
}
|
|
325
|
+
return "zh";
|
|
326
|
+
}
|
|
327
|
+
let dark = isDarkDocument();
|
|
328
|
+
let locale = detectLocale();
|
|
329
|
+
const listeners$1 = /* @__PURE__ */ new Set();
|
|
330
|
+
let darkObserver = null;
|
|
331
|
+
let localeObserver = null;
|
|
332
|
+
function notify() {
|
|
333
|
+
for (const listener of [...listeners$1]) try {
|
|
334
|
+
listener();
|
|
335
|
+
} catch {}
|
|
336
|
+
}
|
|
337
|
+
/** Re-read the DOM and notify only what actually changed. */
|
|
338
|
+
function syncUiEnv() {
|
|
339
|
+
let changed = false;
|
|
340
|
+
const nextDark = isDarkDocument();
|
|
341
|
+
if (nextDark !== dark) {
|
|
342
|
+
dark = nextDark;
|
|
343
|
+
changed = true;
|
|
344
|
+
}
|
|
345
|
+
const nextLocale = detectLocale();
|
|
346
|
+
if (nextLocale !== locale) {
|
|
347
|
+
locale = nextLocale;
|
|
348
|
+
changed = true;
|
|
349
|
+
}
|
|
350
|
+
if (changed) notify();
|
|
351
|
+
}
|
|
352
|
+
/** Start observing (idempotent; also re-reads so no change is missed). */
|
|
353
|
+
function watch() {
|
|
354
|
+
if (typeof document === "undefined" || typeof MutationObserver === "undefined") return;
|
|
355
|
+
if (!darkObserver && document.body) {
|
|
356
|
+
darkObserver = new MutationObserver(syncUiEnv);
|
|
357
|
+
darkObserver.observe(document.body, {
|
|
358
|
+
attributes: true,
|
|
359
|
+
attributeFilter: [DARK_ATTRIBUTE]
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
if (!localeObserver && document.documentElement) {
|
|
363
|
+
localeObserver = new MutationObserver(syncUiEnv);
|
|
364
|
+
localeObserver.observe(document.documentElement, {
|
|
365
|
+
attributes: true,
|
|
366
|
+
attributeFilter: [
|
|
367
|
+
"lang",
|
|
368
|
+
"data-theme",
|
|
369
|
+
"class"
|
|
370
|
+
]
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
syncUiEnv();
|
|
374
|
+
}
|
|
375
|
+
function subscribe(callback) {
|
|
376
|
+
watch();
|
|
377
|
+
listeners$1.add(callback);
|
|
378
|
+
return () => {
|
|
379
|
+
listeners$1.delete(callback);
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Imperative subscription for non-React consumers (e.g. the login dialog's
|
|
384
|
+
* backdrop/card DOM). The callback fires on every theme or locale flip.
|
|
385
|
+
*/
|
|
386
|
+
function subscribeUiEnv(callback) {
|
|
387
|
+
return subscribe(callback);
|
|
388
|
+
}
|
|
389
|
+
const getDark = () => dark;
|
|
390
|
+
const getLocale = () => locale;
|
|
391
|
+
/** Synchronous read of the current host UI locale. */
|
|
392
|
+
function getCurrentLocale() {
|
|
393
|
+
return locale;
|
|
394
|
+
}
|
|
395
|
+
/** Synchronous read of the current host surface color (matches the palette). */
|
|
396
|
+
function getCurrentSurfaceColor() {
|
|
397
|
+
return dark ? "var(--dsw-alias-bg-layer-1, #20242c)" : "var(--dsw-alias-bg-layer-1, #ffffff)";
|
|
398
|
+
}
|
|
399
|
+
/** `true` while the host renders the dark palette. */
|
|
400
|
+
function useIsDark() {
|
|
401
|
+
return (0, react.useSyncExternalStore)(subscribe, getDark, getDark);
|
|
402
|
+
}
|
|
403
|
+
/** The host UI language. */
|
|
404
|
+
function useLocale() {
|
|
405
|
+
return (0, react.useSyncExternalStore)(subscribe, getLocale, getLocale);
|
|
406
|
+
}
|
|
407
|
+
/** Release the observers (called from `apply()`'s disposer). */
|
|
408
|
+
function disposeUiEnv() {
|
|
409
|
+
darkObserver?.disconnect();
|
|
410
|
+
localeObserver?.disconnect();
|
|
411
|
+
darkObserver = null;
|
|
412
|
+
localeObserver = null;
|
|
413
|
+
listeners$1.clear();
|
|
414
|
+
}
|
|
415
|
+
//#endregion
|
|
416
|
+
//#region src/client/i18n.ts
|
|
417
|
+
/**
|
|
418
|
+
* zh / en copy for every user-visible string of the auth UI (sidebar trigger,
|
|
419
|
+
* account menu, login tool card).
|
|
420
|
+
*
|
|
421
|
+
* Kept self-contained rather than registered into DSH's `ctx.locale`
|
|
422
|
+
* namespace — same call the sibling packages made
|
|
423
|
+
* (`dsh-tool-symbol-footprint/src/client/i18n.ts`) — because the slot system
|
|
424
|
+
* hands components props, not ctx, and a missing namespace would leave the UI
|
|
425
|
+
* blank. `en` is typed as `Record<AuthCopyKey, string>`, so a key added to one
|
|
426
|
+
* language without the other is a COMPILE error (bilingual balance enforced at
|
|
427
|
+
* build time, mirroring DSH's own locale registry).
|
|
428
|
+
*
|
|
429
|
+
* The en「Go to profile」/「Log out」wording is the one the sidebar spec asks
|
|
430
|
+
* for; the zh side follows `hq-eda-ai`'s `locales/cn.ts` (个人中心 / 退出登录).
|
|
431
|
+
*/
|
|
432
|
+
const zh = {
|
|
433
|
+
"sidebar.login": "华秋EDA AI登录",
|
|
434
|
+
"sidebar.loginTitle": "登录华秋 EDA AI(eda.cn)账号",
|
|
435
|
+
"sidebar.accountTitle": "华秋 EDA AI 账号",
|
|
436
|
+
"sidebar.account": "华秋EDA AI · 已登录",
|
|
437
|
+
"menu.profile": "个人中心",
|
|
438
|
+
"menu.logout": "退出登录",
|
|
439
|
+
"card.title": "华秋 EDA AI(eda.cn)登录",
|
|
440
|
+
"card.desc": "工具「{tool}」需要登录华秋 EDA AI 账号才能继续。请在下方的登录框完成登录(或点击左侧「华秋EDA AI登录」按钮);登录完成后,回复助手「已登录,请重试」,助手会自动重新调用该工具。",
|
|
441
|
+
"card.loggedIn": "✓ 已登录{nickname} —— 现在可以回复助手「已登录,请重试」,助手会重新调用工具。",
|
|
442
|
+
"card.loggedOut": "未登录 —— 请在上方登录华秋 EDA AI(eda.cn)账号,或点击左侧「华秋EDA AI登录」按钮;登录完成后让助手重试。",
|
|
443
|
+
"card.tool": "工具:{tool}",
|
|
444
|
+
"card.empty": "(无输出)",
|
|
445
|
+
"card.nicknameSep": ":{nickname}",
|
|
446
|
+
"dialog.close": "关闭"
|
|
447
|
+
};
|
|
448
|
+
const COPY = {
|
|
449
|
+
zh,
|
|
450
|
+
en: {
|
|
451
|
+
"sidebar.login": "Huaqiu EDA AI login",
|
|
452
|
+
"sidebar.loginTitle": "Sign in to your Huaqiu EDA AI (eda.cn) account",
|
|
453
|
+
"sidebar.accountTitle": "Huaqiu EDA AI account",
|
|
454
|
+
"sidebar.account": "Huaqiu EDA AI · signed in",
|
|
455
|
+
"menu.profile": "Go to profile",
|
|
456
|
+
"menu.logout": "Log out",
|
|
457
|
+
"card.title": "Huaqiu EDA AI (eda.cn) login",
|
|
458
|
+
"card.desc": "Tool \"{tool}\" needs a Huaqiu EDA AI account. Complete the login below (or use the Huaqiu EDA AI button in the sidebar), then reply \"I have logged in, please retry\" so the assistant can retry the tool.",
|
|
459
|
+
"card.loggedIn": "✓ Logged in{nickname} — reply \"I have logged in, please retry\" and the assistant will retry the tool.",
|
|
460
|
+
"card.loggedOut": "Not logged in — sign in above, or use the Huaqiu EDA AI button in the sidebar, then ask the assistant to retry.",
|
|
461
|
+
"card.tool": "Tool: {tool}",
|
|
462
|
+
"card.empty": "(no output)",
|
|
463
|
+
"card.nicknameSep": ": {nickname}",
|
|
464
|
+
"dialog.close": "Close"
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
Object.keys(zh);
|
|
468
|
+
/**
|
|
469
|
+
* Look a key up, interpolating `{name}` placeholders.
|
|
470
|
+
*
|
|
471
|
+
* Chain: active locale → zh (the source of truth) → the key itself, so a
|
|
472
|
+
* missing translation stays VISIBLE instead of blanking the UI.
|
|
473
|
+
*/
|
|
474
|
+
function translate(locale, key, params) {
|
|
475
|
+
const template = COPY[locale]?.[key] ?? COPY.zh[key] ?? key;
|
|
476
|
+
if (!params) return template;
|
|
477
|
+
return template.replace(/\{(\w+)\}/g, (match, name) => name in params ? String(params[name]) : match);
|
|
478
|
+
}
|
|
479
|
+
/** Translate bound to one locale (stable for the lifetime of that locale). */
|
|
480
|
+
function createT(locale) {
|
|
481
|
+
return (key, params) => translate(locale, key, params);
|
|
482
|
+
}
|
|
483
|
+
/** Translate bound to the host UI language, re-created when it changes. */
|
|
484
|
+
function useT() {
|
|
485
|
+
const locale = useLocale();
|
|
486
|
+
return (0, react.useMemo)(() => createT(locale), [locale]);
|
|
487
|
+
}
|
|
488
|
+
//#endregion
|
|
489
|
+
//#region src/client/ui/login-dialog.ts
|
|
490
|
+
/**
|
|
491
|
+
* The sidebar login dialog — a real modal (backdrop + centered card + iframe).
|
|
492
|
+
*
|
|
493
|
+
* WHY A DIALOG AND NOT A FULL-VIEWPORT IFRAME
|
|
494
|
+
*
|
|
495
|
+
* The embed (`auth.eda.cn`) reads only two URL params: `fill` and
|
|
496
|
+
* `clickOutsideToClose`. With `fill !== 'full'` it sets
|
|
497
|
+
* `data-iframe-mode="true"` on `<html>` and the CSS rule
|
|
498
|
+
* `html[data-iframe-mode=true], html[data-iframe-mode=true] body { background: 0 0 !important }`
|
|
499
|
+
* makes its root transparent — but the page wrapper still uses a
|
|
500
|
+
* `grid-rows-[20px_1fr_20px]` layout, so the 20px strips above/below the
|
|
501
|
+
* card are empty and show through to whatever is behind the iframe. Behind
|
|
502
|
+
* the iframe element, with no background set, Blink falls back to a WHITE
|
|
503
|
+
* base background canvas. That white is what was reading as a "white frame
|
|
504
|
+
* around the login card in dark mode" (and was invisibly there in light
|
|
505
|
+
* mode, blending with the white host).
|
|
506
|
+
*
|
|
507
|
+
* The two ways out:
|
|
508
|
+
* 1. Paint the iframe element with a color → in a full-viewport iframe
|
|
509
|
+
* that blanks the whole app with that color (light surface = white
|
|
510
|
+
* blocks the light host; dark surface = dark blocks the dark host).
|
|
511
|
+
* 2. Make the iframe CARD-SIZED and put it inside a host-painted card,
|
|
512
|
+
* so the iframe's background can be `transparent` and the card's
|
|
513
|
+
* surface shows through wherever the embedded doc is transparent.
|
|
514
|
+
* This is the pattern the toolview card already uses
|
|
515
|
+
* (`needs-auth-toolview.tsx`) and the pattern `hq-eda-ai`'s
|
|
516
|
+
* `LoginDialog.tsx` uses.
|
|
517
|
+
*
|
|
518
|
+
* We use (2): a fixed full-viewport backdrop (semi-transparent black) +
|
|
519
|
+
* centered card (host surface bg) + the iframe (transparent inner doc,
|
|
520
|
+
* card surface as element bg so Blink's white canvas never reaches the
|
|
521
|
+
* user). Click on the backdrop, the × button, Escape, or the auth embed's
|
|
522
|
+
* own `close_dialog` postMessage closes the dialog.
|
|
523
|
+
*/
|
|
524
|
+
/** Aria / data attribute names — stable so tests and CSS can target them. */
|
|
525
|
+
const DIALOG_OVERLAY_ATTR = "data-hq-auth-dialog";
|
|
526
|
+
const DIALOG_CARD_ATTR = "data-hq-auth-dialog-card";
|
|
527
|
+
const DIALOG_IFRAME_ATTR = "data-hq-auth-dialog-iframe";
|
|
528
|
+
const DIALOG_CLOSE_ATTR = "data-hq-auth-dialog-close";
|
|
529
|
+
/**
|
|
530
|
+
* Iframe height. Tuned to the auth.eda.cn login form's actual painted height
|
|
531
|
+
* (≈390px at 768px width, measured with a magenta iframe element background
|
|
532
|
+
* so the embedded doc's transparent 20px top/bottom strips are obvious). The
|
|
533
|
+
* shared constant lives in `./common.jsx` so the dialog and the toolview
|
|
534
|
+
* card stay in lock-step.
|
|
535
|
+
*/
|
|
536
|
+
const IFRAME_HEIGHT = 440;
|
|
537
|
+
const CARD_MAX_WIDTH = 768;
|
|
538
|
+
let container = null;
|
|
539
|
+
let unsubscribe$1 = null;
|
|
540
|
+
let onCloseRequested = null;
|
|
541
|
+
/**
|
|
542
|
+
* Open the login dialog. Idempotent: a second call while open is a no-op
|
|
543
|
+
* (mirrors the client-side `if (iframe) return` guard). `onClose` fires
|
|
544
|
+
* whenever the dialog closes for ANY reason (backdrop click, Escape, close
|
|
545
|
+
* button, postMessage, programmatic close) — the auth client uses it to
|
|
546
|
+
* unblock its own `isOpen` state.
|
|
547
|
+
*/
|
|
548
|
+
function openLoginDialog(options = {}, onClose) {
|
|
549
|
+
if (container) return;
|
|
550
|
+
syncUiEnv();
|
|
551
|
+
const locale = options.lang ?? getCurrentLocale();
|
|
552
|
+
const root = document.createElement("div");
|
|
553
|
+
root.setAttribute(DIALOG_OVERLAY_ATTR, "");
|
|
554
|
+
root.style.cssText = [
|
|
555
|
+
"position:fixed",
|
|
556
|
+
"inset:0",
|
|
557
|
+
"width:100vw",
|
|
558
|
+
"height:100vh",
|
|
559
|
+
"border:0",
|
|
560
|
+
"z-index:2147483647",
|
|
561
|
+
"background:rgba(0, 0, 0, 0.55)",
|
|
562
|
+
"display:flex",
|
|
563
|
+
"align-items:center",
|
|
564
|
+
"justify-content:center",
|
|
565
|
+
"box-sizing:border-box"
|
|
566
|
+
].join(";");
|
|
567
|
+
const card = document.createElement("div");
|
|
568
|
+
card.setAttribute(DIALOG_CARD_ATTR, "");
|
|
569
|
+
const applyCardColors = () => {
|
|
570
|
+
const surface = getCurrentSurfaceColor();
|
|
571
|
+
card.style.cssText = [
|
|
572
|
+
`width:min(100vw, ${CARD_MAX_WIDTH}px)`,
|
|
573
|
+
`height:min(90vh, ${IFRAME_HEIGHT}px)`,
|
|
574
|
+
"border-radius:12px",
|
|
575
|
+
"box-shadow:0 24px 48px rgba(0, 0, 0, 0.32)",
|
|
576
|
+
"position:relative",
|
|
577
|
+
"box-sizing:border-box",
|
|
578
|
+
`background:${surface}`,
|
|
579
|
+
"display:flex",
|
|
580
|
+
"flex-direction:column"
|
|
581
|
+
].join(";");
|
|
582
|
+
};
|
|
583
|
+
applyCardColors();
|
|
584
|
+
const closeButton = document.createElement("button");
|
|
585
|
+
closeButton.setAttribute(DIALOG_CLOSE_ATTR, "");
|
|
586
|
+
closeButton.type = "button";
|
|
587
|
+
closeButton.setAttribute("aria-label", translate(locale, "dialog.close"));
|
|
588
|
+
closeButton.title = translate(locale, "dialog.close");
|
|
589
|
+
closeButton.textContent = "×";
|
|
590
|
+
closeButton.style.cssText = [
|
|
591
|
+
"position:absolute",
|
|
592
|
+
"top:6px",
|
|
593
|
+
"right:10px",
|
|
594
|
+
"width:28px",
|
|
595
|
+
"height:28px",
|
|
596
|
+
"border:0",
|
|
597
|
+
"background:transparent",
|
|
598
|
+
"color:var(--dsw-alias-label-secondary, #5b6472)",
|
|
599
|
+
"font-size:22px",
|
|
600
|
+
"line-height:1",
|
|
601
|
+
"cursor:pointer",
|
|
602
|
+
"border-radius:6px",
|
|
603
|
+
"padding:0"
|
|
604
|
+
].join(";");
|
|
605
|
+
closeButton.addEventListener("click", closeLoginDialog);
|
|
606
|
+
const iframe = document.createElement("iframe");
|
|
607
|
+
iframe.setAttribute(DIALOG_IFRAME_ATTR, "");
|
|
608
|
+
iframe.src = buildLoginUrl({
|
|
609
|
+
lang: options.lang,
|
|
610
|
+
theme: options.theme
|
|
611
|
+
});
|
|
612
|
+
iframe.title = translate(locale, "card.title");
|
|
613
|
+
iframe.allow = "clipboard-write";
|
|
614
|
+
iframe.style.cssText = [
|
|
615
|
+
"width:100%",
|
|
616
|
+
`height:${IFRAME_HEIGHT}px`,
|
|
617
|
+
"border:0",
|
|
618
|
+
"border-radius:8px",
|
|
619
|
+
`background:${getCurrentSurfaceColor()}`,
|
|
620
|
+
"display:block",
|
|
621
|
+
"flex:0 0 auto"
|
|
622
|
+
].join(";");
|
|
623
|
+
card.appendChild(closeButton);
|
|
624
|
+
card.appendChild(iframe);
|
|
625
|
+
root.appendChild(card);
|
|
626
|
+
document.body.appendChild(root);
|
|
627
|
+
root.addEventListener("mousedown", backdropMouseDown);
|
|
628
|
+
card.addEventListener("mousedown", stopPropagation);
|
|
629
|
+
document.addEventListener("keydown", onKeyDown);
|
|
630
|
+
unsubscribe$1 = subscribeUiEnv(() => {
|
|
631
|
+
if (!container) return;
|
|
632
|
+
applyCardColors();
|
|
633
|
+
iframe.style.background = getCurrentSurfaceColor();
|
|
634
|
+
closeButton.title = translate(getCurrentLocale(), "dialog.close");
|
|
635
|
+
closeButton.setAttribute("aria-label", closeButton.title);
|
|
636
|
+
});
|
|
637
|
+
container = root;
|
|
638
|
+
onCloseRequested = onClose ?? null;
|
|
639
|
+
}
|
|
640
|
+
/** Programmatic close (used by the auth client after a successful login). */
|
|
641
|
+
function closeLoginDialog() {
|
|
642
|
+
if (!container) return;
|
|
643
|
+
container.remove();
|
|
644
|
+
container = null;
|
|
645
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
646
|
+
unsubscribe$1?.();
|
|
647
|
+
unsubscribe$1 = null;
|
|
648
|
+
const cb = onCloseRequested;
|
|
649
|
+
onCloseRequested = null;
|
|
650
|
+
cb?.();
|
|
651
|
+
}
|
|
652
|
+
/** True while the dialog is mounted. */
|
|
653
|
+
function isLoginDialogOpen() {
|
|
654
|
+
return container !== null;
|
|
655
|
+
}
|
|
656
|
+
function backdropMouseDown(event) {
|
|
657
|
+
if (event.target === container) closeLoginDialog();
|
|
658
|
+
}
|
|
659
|
+
function stopPropagation(event) {
|
|
660
|
+
event.stopPropagation();
|
|
661
|
+
}
|
|
662
|
+
function onKeyDown(event) {
|
|
663
|
+
if (event.key === "Escape") {
|
|
664
|
+
event.stopPropagation();
|
|
665
|
+
closeLoginDialog();
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
//#endregion
|
|
669
|
+
//#region src/client/client.ts
|
|
670
|
+
/**
|
|
671
|
+
* Auth client core — the Phase 0A POC logic, factored as a testable factory.
|
|
672
|
+
* `apply()` in index.ts wires this to the real window/document/localStorage.
|
|
673
|
+
*/
|
|
674
|
+
function createAuthClient(deps) {
|
|
675
|
+
deps.trustedOrigin;
|
|
676
|
+
deps.loginUrl;
|
|
677
|
+
const { storage, transport } = deps;
|
|
678
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
679
|
+
const emit = (info) => {
|
|
680
|
+
for (const listener of listeners) listener(info);
|
|
681
|
+
};
|
|
682
|
+
const closeIframe = () => {
|
|
683
|
+
if (isLoginDialogOpen()) closeLoginDialog();
|
|
684
|
+
};
|
|
685
|
+
/**
|
|
686
|
+
* Open the login dialog (backdrop + centered card + auth.eda.cn iframe).
|
|
687
|
+
*
|
|
688
|
+
* The dialog is ALWAYS transparent (no `transparent` option exists): the
|
|
689
|
+
* embedded doc sets its own root to `background: transparent` (we never
|
|
690
|
+
* send `fill=full`), and the iframe sits inside a host-painted card so
|
|
691
|
+
* Blink's white base canvas never reaches the user. See the long header
|
|
692
|
+
* in `ui/login-dialog.ts` for the full why.
|
|
693
|
+
*/
|
|
694
|
+
const openIframe = (options = {}) => {
|
|
695
|
+
if (isLoginDialogOpen()) return;
|
|
696
|
+
openLoginDialog({
|
|
697
|
+
...options.lang ? { lang: options.lang } : {},
|
|
698
|
+
...options.theme ? { theme: options.theme } : {}
|
|
699
|
+
}, () => {});
|
|
700
|
+
};
|
|
701
|
+
const auth = {
|
|
702
|
+
isAuthenticated: () => storage.get() !== null,
|
|
703
|
+
getAccessToken: async () => storage.get()?.token ?? null,
|
|
704
|
+
getUserInfo: async () => storage.get(),
|
|
705
|
+
login: async (options) => openIframe(options ?? {}),
|
|
706
|
+
logout: async () => {
|
|
707
|
+
storage.clear();
|
|
708
|
+
try {
|
|
709
|
+
await transport.pushLogout();
|
|
710
|
+
} catch {}
|
|
711
|
+
emit(null);
|
|
712
|
+
closeIframe();
|
|
713
|
+
},
|
|
714
|
+
onAuthStateChanged: (listener) => {
|
|
715
|
+
listeners.add(listener);
|
|
716
|
+
return () => listeners.delete(listener);
|
|
717
|
+
}
|
|
718
|
+
};
|
|
719
|
+
const handleMessageEvent = (event) => {
|
|
720
|
+
const msg = handleAuthMessage(event);
|
|
721
|
+
if (!msg) return;
|
|
722
|
+
if (msg.kind === "token") {
|
|
723
|
+
storage.set(msg.info);
|
|
724
|
+
transport.pushSession(msg.info).catch(() => {});
|
|
725
|
+
emit(msg.info);
|
|
726
|
+
closeIframe();
|
|
727
|
+
} else if (msg.kind === "logout") {
|
|
728
|
+
storage.clear();
|
|
729
|
+
emit(null);
|
|
730
|
+
transport.pushLogout().catch(() => {});
|
|
731
|
+
closeIframe();
|
|
732
|
+
} else if (msg.kind === "close") closeIframe();
|
|
733
|
+
};
|
|
734
|
+
const onWindowMessage = (event) => {
|
|
735
|
+
handleMessageEvent({
|
|
736
|
+
origin: event.origin,
|
|
737
|
+
data: event.data
|
|
738
|
+
});
|
|
739
|
+
};
|
|
740
|
+
deps.windowLike.addEventListener("message", onWindowMessage);
|
|
741
|
+
const restore = async () => {
|
|
742
|
+
const restored = storage.get();
|
|
743
|
+
if (restored) try {
|
|
744
|
+
await transport.pushSession(restored);
|
|
745
|
+
} catch {}
|
|
746
|
+
};
|
|
747
|
+
return {
|
|
748
|
+
auth,
|
|
749
|
+
handleMessageEvent,
|
|
750
|
+
restore,
|
|
751
|
+
/**
|
|
752
|
+
* Re-push the persisted credential to the node half. Healing path: the
|
|
753
|
+
* node keeps auth in memory, so a `dsh web` restart (or a failed first
|
|
754
|
+
* push) drops it while the browser still has the token. Callers re-sync on
|
|
755
|
+
* focus / visibilitychange / login-card mount so the tool gate reflects
|
|
756
|
+
* the actual browser login without requiring a page reload.
|
|
757
|
+
*/
|
|
758
|
+
async syncNow() {
|
|
759
|
+
const info = storage.get();
|
|
760
|
+
if (!info) return;
|
|
761
|
+
try {
|
|
762
|
+
await transport.pushSession(info);
|
|
763
|
+
} catch {}
|
|
764
|
+
},
|
|
765
|
+
dispose() {
|
|
766
|
+
deps.windowLike.removeEventListener("message", onWindowMessage);
|
|
767
|
+
closeIframe();
|
|
768
|
+
}
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
//#endregion
|
|
772
|
+
//#region src/client/auth-state.ts
|
|
773
|
+
/** Snapshot for one credential payload (`null` = logged out). */
|
|
774
|
+
function stateOf(info) {
|
|
775
|
+
if (!info) return { authenticated: false };
|
|
776
|
+
return {
|
|
777
|
+
authenticated: true,
|
|
778
|
+
...info.nickname ? { nickname: info.nickname } : {},
|
|
779
|
+
...info.avatar ? { avatar: info.avatar } : {},
|
|
780
|
+
...info.token ? { token: info.token } : {},
|
|
781
|
+
...info.phone ? { phone: info.phone } : {}
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
let auth = null;
|
|
785
|
+
let state = { authenticated: false };
|
|
786
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
787
|
+
let unsubscribe = null;
|
|
788
|
+
let syncNow = null;
|
|
789
|
+
function setState(next) {
|
|
790
|
+
state = next;
|
|
791
|
+
for (const l of listeners) l();
|
|
792
|
+
}
|
|
793
|
+
/** Attach the singleton auth capability and push the initial snapshot. */
|
|
794
|
+
function registerAuth(a) {
|
|
795
|
+
auth = a;
|
|
796
|
+
unsubscribe = a.onAuthStateChanged((info) => {
|
|
797
|
+
setState(stateOf(info));
|
|
798
|
+
});
|
|
799
|
+
a.getUserInfo().then((info) => setState(stateOf(info))).catch(() => setState({ authenticated: false }));
|
|
800
|
+
}
|
|
801
|
+
/** The live auth capability (for login()/logout() from components). */
|
|
802
|
+
function getAuth() {
|
|
803
|
+
return auth;
|
|
804
|
+
}
|
|
805
|
+
/** Current snapshot, for `useSyncExternalStore`'s getSnapshot. */
|
|
806
|
+
function getAuthState() {
|
|
807
|
+
return state;
|
|
808
|
+
}
|
|
809
|
+
/** Subscribe, for `useSyncExternalStore`'s subscribe. */
|
|
810
|
+
function subscribeAuth(callback) {
|
|
811
|
+
listeners.add(callback);
|
|
812
|
+
return () => listeners.delete(callback);
|
|
813
|
+
}
|
|
814
|
+
/** Register the node re-sync hook (wired in apply(); called by the login card on mount). */
|
|
815
|
+
function registerAuthSync(fn) {
|
|
816
|
+
syncNow = fn;
|
|
817
|
+
}
|
|
818
|
+
/** Re-push the persisted credential to the node half, if one is available. */
|
|
819
|
+
function syncAuthNow() {
|
|
820
|
+
syncNow?.();
|
|
821
|
+
}
|
|
822
|
+
function disposeAuth() {
|
|
823
|
+
unsubscribe?.();
|
|
824
|
+
unsubscribe = null;
|
|
825
|
+
auth = null;
|
|
826
|
+
syncNow = null;
|
|
827
|
+
listeners.clear();
|
|
828
|
+
state = { authenticated: false };
|
|
829
|
+
}
|
|
830
|
+
//#endregion
|
|
831
|
+
//#region src/client/ui/needs-auth-toolview.tsx
|
|
832
|
+
/**
|
|
833
|
+
* Keyed `tool.call.toolview` renderer for the Huaqiu EDA tools.
|
|
834
|
+
*
|
|
835
|
+
* When a Huaqiu tool returns `status: "needs_auth"`, this card renders the
|
|
836
|
+
* login human-in-the-loop step: an embedded auth.eda.cn login iframe plus a
|
|
837
|
+
* live login-state line. The singleton auth client's `message` listener
|
|
838
|
+
* already receives the postMessage from this same-origin iframe, caches the
|
|
839
|
+
* credential and pushes it to the node service, so after the user logs in the
|
|
840
|
+
* card flips to「已登录」and the model can retry the tool.
|
|
841
|
+
*
|
|
842
|
+
* The embed is the second of the two FULL login surfaces (the other is the
|
|
843
|
+
* sidebar overlay): it uses the same `buildLoginUrl()` contract — always
|
|
844
|
+
* transparent — and passes the host's language and color scheme.
|
|
845
|
+
*
|
|
846
|
+
* For any other result it renders a faithful JSON fallback (the generic row
|
|
847
|
+
* that this keyed entry replaces), so nothing is lost for successful calls.
|
|
848
|
+
*/
|
|
849
|
+
const DESC_STYLE = {
|
|
850
|
+
fontSize: 13,
|
|
851
|
+
margin: "0 0 10px",
|
|
852
|
+
lineHeight: 1.5
|
|
853
|
+
};
|
|
854
|
+
function JsonFallback({ toolName, block }) {
|
|
855
|
+
const result = (0, react.useMemo)(() => parseToolResult(block), [block]);
|
|
856
|
+
const dark = useIsDark();
|
|
857
|
+
const t = useT();
|
|
858
|
+
const palette = cardPalette(dark);
|
|
859
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
860
|
+
style: cardStyle(palette),
|
|
861
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
862
|
+
style: TITLE_STYLE,
|
|
863
|
+
children: t("card.tool", { tool: toolName })
|
|
864
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("pre", {
|
|
865
|
+
style: {
|
|
866
|
+
margin: 0,
|
|
867
|
+
fontSize: 12,
|
|
868
|
+
whiteSpace: "pre-wrap",
|
|
869
|
+
wordBreak: "break-word",
|
|
870
|
+
maxHeight: 320,
|
|
871
|
+
overflow: "auto"
|
|
872
|
+
},
|
|
873
|
+
children: result ? JSON.stringify(result, null, 2) : t("card.empty")
|
|
874
|
+
})]
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
function LoginCard({ toolName }) {
|
|
878
|
+
const authState = (0, react.useSyncExternalStore)(subscribeAuth, getAuthState);
|
|
879
|
+
const iframeRef = (0, react.useRef)(null);
|
|
880
|
+
const dark = useIsDark();
|
|
881
|
+
const locale = useLocale();
|
|
882
|
+
const t = useT();
|
|
883
|
+
const palette = cardPalette(dark);
|
|
884
|
+
(0, react.useEffect)(() => {
|
|
885
|
+
syncAuthNow();
|
|
886
|
+
}, [toolName]);
|
|
887
|
+
const src = (0, react.useMemo)(() => buildLoginUrl({
|
|
888
|
+
fill: "full",
|
|
889
|
+
lang: locale,
|
|
890
|
+
theme: dark ? "dark" : "light"
|
|
891
|
+
}), [locale, dark]);
|
|
892
|
+
const remountKey = `${locale}|${dark ? "d" : "l"}`;
|
|
893
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
894
|
+
style: cardStyle(palette),
|
|
895
|
+
children: [
|
|
896
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
897
|
+
style: TITLE_STYLE,
|
|
898
|
+
children: t("card.title")
|
|
899
|
+
}),
|
|
900
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
901
|
+
style: {
|
|
902
|
+
...DESC_STYLE,
|
|
903
|
+
color: palette.muted
|
|
904
|
+
},
|
|
905
|
+
children: t("card.desc", { tool: toolName })
|
|
906
|
+
}),
|
|
907
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(StatusLine, {
|
|
908
|
+
authenticated: authState.authenticated,
|
|
909
|
+
nickname: authState.nickname,
|
|
910
|
+
palette,
|
|
911
|
+
t
|
|
912
|
+
}),
|
|
913
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("iframe", {
|
|
914
|
+
ref: iframeRef,
|
|
915
|
+
src,
|
|
916
|
+
title: t("card.title"),
|
|
917
|
+
style: iframeStyle(palette),
|
|
918
|
+
allow: "clipboard-write"
|
|
919
|
+
}, remountKey)
|
|
920
|
+
]
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
const HuaqiuToolView = (0, react.memo)(function HuaqiuToolView(props) {
|
|
924
|
+
const { toolName, block } = props;
|
|
925
|
+
if (isNeedsAuthResult((0, react.useMemo)(() => parseToolResult(block), [block]))) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LoginCard, { toolName });
|
|
926
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(JsonFallback, {
|
|
927
|
+
toolName,
|
|
928
|
+
block
|
|
929
|
+
});
|
|
930
|
+
});
|
|
931
|
+
//#endregion
|
|
932
|
+
//#region src/client/ui/hq-icon.tsx
|
|
933
|
+
function HQ_ICON({ size = 24, color = "#1a81c4", title }) {
|
|
934
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
|
|
935
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
936
|
+
viewBox: "0 0 40 40",
|
|
937
|
+
width: size,
|
|
938
|
+
height: size,
|
|
939
|
+
role: title ? "img" : void 0,
|
|
940
|
+
"aria-hidden": title ? void 0 : true,
|
|
941
|
+
focusable: "false",
|
|
942
|
+
style: {
|
|
943
|
+
display: "block",
|
|
944
|
+
flex: "0 0 auto"
|
|
945
|
+
},
|
|
946
|
+
children: [
|
|
947
|
+
title ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("title", { children: title }) : null,
|
|
948
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
|
|
949
|
+
fill: color,
|
|
950
|
+
fillRule: "evenodd",
|
|
951
|
+
d: "M29.71,30a2.75,2.75,0,1,0,2.75,2.74A2.74,2.74,0,0,0,29.71,30Z"
|
|
952
|
+
}),
|
|
953
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
|
|
954
|
+
fill: color,
|
|
955
|
+
fillRule: "evenodd",
|
|
956
|
+
d: "M26.59,10.49H13.41a5.93,5.93,0,0,0-5.91,5.9V29.58a5.93,5.93,0,0,0,5.91,5.91H26.85a4,4,0,0,1-1.13-2.78,4.43,4.43,0,0,1,.1-.9H13.41a2.23,2.23,0,0,1-2.22-2.22V16.39a2.23,2.23,0,0,1,2.22-2.21H26.59a2.23,2.23,0,0,1,2.22,2.21V28.81a4.43,4.43,0,0,1,.9-.1,4,4,0,0,1,2.78,1.13,2.26,2.26,0,0,0,0-.26V16.39A5.93,5.93,0,0,0,26.59,10.49Z"
|
|
957
|
+
}),
|
|
958
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
|
|
959
|
+
fill: color,
|
|
960
|
+
fillRule: "evenodd",
|
|
961
|
+
d: "M26.38,27.52V18.46a1.85,1.85,0,0,0-1.85-1.85h0a1.84,1.84,0,0,0-1.84,1.85v2.68H17.31V18.46a1.84,1.84,0,0,0-1.84-1.85h0a1.85,1.85,0,0,0-1.85,1.85v9.06a1.85,1.85,0,0,0,1.85,1.85h0a1.84,1.84,0,0,0,1.84-1.85V24.83h5.38v2.69a1.84,1.84,0,0,0,1.84,1.85h0A1.85,1.85,0,0,0,26.38,27.52Z"
|
|
962
|
+
}),
|
|
963
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
|
|
964
|
+
fill: color,
|
|
965
|
+
cx: "20",
|
|
966
|
+
cy: "5.04",
|
|
967
|
+
r: "2.86"
|
|
968
|
+
}),
|
|
969
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", {
|
|
970
|
+
fill: color,
|
|
971
|
+
x: "19",
|
|
972
|
+
y: "5.04",
|
|
973
|
+
width: "2",
|
|
974
|
+
height: "6.7"
|
|
975
|
+
}),
|
|
976
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
|
|
977
|
+
fill: color,
|
|
978
|
+
d: "M6.37,17.71a4.89,4.89,0,0,0,0,9.78Z"
|
|
979
|
+
}),
|
|
980
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
|
|
981
|
+
fill: color,
|
|
982
|
+
d: "M33.63,17.71a4.89,4.89,0,1,1,0,9.78Z"
|
|
983
|
+
})
|
|
984
|
+
]
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
//#endregion
|
|
988
|
+
//#region src/client/ui/sidebar-action.tsx
|
|
989
|
+
/**
|
|
990
|
+
* `sidebar.footer.action` entry: the Huaqiu EDA account trigger at the bottom
|
|
991
|
+
* of the DSH sidebar (beside Settings).
|
|
992
|
+
*
|
|
993
|
+
* - Not logged in: shows the HQ icon and opens the login dialog through
|
|
994
|
+
* `auth.login({ lang, theme })` — a real modal (backdrop + centered card +
|
|
995
|
+
* auth.eda.cn iframe) that is ALWAYS TRANSPARENT in the embed itself
|
|
996
|
+
* (`fill=full` is never sent, see `lib.ts#buildLoginUrl`), and the card
|
|
997
|
+
* surface masks Blink's white base canvas so the login card floats over
|
|
998
|
+
* the dimmed app in both light and dark themes. `lang`/`theme` follow the
|
|
999
|
+
* host UI. Click on the backdrop, the × button, or Escape closes it, and
|
|
1000
|
+
* auth.eda.cn's own `close_dialog` postMessage closes it as well.
|
|
1001
|
+
* - Logged in: the trigger becomes the user's AVATAR (`headimage` from the
|
|
1002
|
+
* auth.eda.cn payload, HQ icon while it is missing/fails to load) and a click
|
|
1003
|
+
* opens a context menu with「Go to profile」(the eda.cn account page, with
|
|
1004
|
+
* the access token) and「Log out」— the same shape as `hq-eda-ai`'s
|
|
1005
|
+
* `UserMenu`, portalled to `document.body` with fixed positioning so the
|
|
1006
|
+
* sidebar's `overflow: hidden` can never clip it.
|
|
1007
|
+
*
|
|
1008
|
+
* THEMING: colors prefer DSH's `--dsw-alias-*` tokens (so a custom host theme
|
|
1009
|
+
* is honored) and fall back to an explicit light/dark pair chosen from
|
|
1010
|
+
* `useIsDark()`; the two paths cannot disagree, because ui-layout's presenter
|
|
1011
|
+
* writes `body[data-ds-dark-theme]` from the very snapshot that installs those
|
|
1012
|
+
* tokens.
|
|
1013
|
+
*/
|
|
1014
|
+
const AVATAR_SIZE = 26;
|
|
1015
|
+
const ICON_SIZE = 22;
|
|
1016
|
+
const LIGHT_PALETTE = {
|
|
1017
|
+
surface: "var(--dsw-alias-bg-overlay, #ffffff)",
|
|
1018
|
+
border: "var(--dsw-alias-border-l1, #e4e7ec)",
|
|
1019
|
+
text: "var(--dsw-alias-label-primary, #3a4356)",
|
|
1020
|
+
muted: "var(--dsw-alias-label-secondary, #8a94a6)",
|
|
1021
|
+
hover: "var(--dsw-alias-interactive-bg-hover, #f5f7fa)",
|
|
1022
|
+
danger: "var(--dsw-alias-state-error-primary, #d4380d)",
|
|
1023
|
+
dangerHover: "rgba(216, 56, 13, 0.08)",
|
|
1024
|
+
avatarBg: "var(--dsw-alias-bg-layer-2, #eef2f7)",
|
|
1025
|
+
shadow: "0 12px 32px rgba(15, 23, 42, 0.16)"
|
|
1026
|
+
};
|
|
1027
|
+
const DARK_PALETTE = {
|
|
1028
|
+
surface: "var(--dsw-alias-bg-overlay, #20242c)",
|
|
1029
|
+
border: "var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.14))",
|
|
1030
|
+
text: "var(--dsw-alias-label-primary, #e6eaf0)",
|
|
1031
|
+
muted: "var(--dsw-alias-label-secondary, #8b95a5)",
|
|
1032
|
+
hover: "var(--dsw-alias-interactive-bg-hover, rgba(255, 255, 255, 0.08))",
|
|
1033
|
+
danger: "var(--dsw-alias-state-error-primary, #ff7875)",
|
|
1034
|
+
dangerHover: "rgba(255, 120, 117, 0.14)",
|
|
1035
|
+
avatarBg: "var(--dsw-alias-bg-layer-2, rgba(255, 255, 255, 0.10))",
|
|
1036
|
+
shadow: "0 12px 32px rgba(0, 0, 0, 0.46)"
|
|
1037
|
+
};
|
|
1038
|
+
const TRIGGER_BASE = {
|
|
1039
|
+
width: "100%",
|
|
1040
|
+
display: "flex",
|
|
1041
|
+
alignItems: "center",
|
|
1042
|
+
gap: 8,
|
|
1043
|
+
padding: "8px 12px",
|
|
1044
|
+
border: "none",
|
|
1045
|
+
borderRadius: 8,
|
|
1046
|
+
background: "transparent",
|
|
1047
|
+
fontSize: 13,
|
|
1048
|
+
fontWeight: 500,
|
|
1049
|
+
cursor: "pointer",
|
|
1050
|
+
textAlign: "left",
|
|
1051
|
+
whiteSpace: "nowrap",
|
|
1052
|
+
overflow: "hidden"
|
|
1053
|
+
};
|
|
1054
|
+
const MENU_BASE = {
|
|
1055
|
+
position: "fixed",
|
|
1056
|
+
zIndex: 2147483e3,
|
|
1057
|
+
minWidth: 184,
|
|
1058
|
+
padding: 6,
|
|
1059
|
+
borderWidth: 1,
|
|
1060
|
+
borderStyle: "solid",
|
|
1061
|
+
borderRadius: 12,
|
|
1062
|
+
fontFamily: "inherit",
|
|
1063
|
+
fontSize: 13
|
|
1064
|
+
};
|
|
1065
|
+
const MENU_HEADER_BASE = {
|
|
1066
|
+
padding: "6px 10px 8px",
|
|
1067
|
+
fontSize: 12,
|
|
1068
|
+
overflow: "hidden",
|
|
1069
|
+
textOverflow: "ellipsis",
|
|
1070
|
+
whiteSpace: "nowrap"
|
|
1071
|
+
};
|
|
1072
|
+
const MENU_ITEM_BASE = {
|
|
1073
|
+
display: "flex",
|
|
1074
|
+
alignItems: "center",
|
|
1075
|
+
gap: 8,
|
|
1076
|
+
width: "100%",
|
|
1077
|
+
padding: "8px 10px",
|
|
1078
|
+
border: "none",
|
|
1079
|
+
borderRadius: 8,
|
|
1080
|
+
background: "transparent",
|
|
1081
|
+
font: "inherit",
|
|
1082
|
+
fontSize: 13,
|
|
1083
|
+
textAlign: "left",
|
|
1084
|
+
cursor: "pointer"
|
|
1085
|
+
};
|
|
1086
|
+
/**
|
|
1087
|
+
* One menu row. Hover is tracked in state: the client bundle ships no CSS
|
|
1088
|
+
* file, so inline styles cannot express `:hover`.
|
|
1089
|
+
*/
|
|
1090
|
+
function MenuItem({ label, icon, danger, palette, onSelect }) {
|
|
1091
|
+
const [hovered, setHovered] = (0, react.useState)(false);
|
|
1092
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
1093
|
+
type: "button",
|
|
1094
|
+
role: "menuitem",
|
|
1095
|
+
style: {
|
|
1096
|
+
...MENU_ITEM_BASE,
|
|
1097
|
+
color: danger ? palette.danger : palette.text,
|
|
1098
|
+
background: hovered ? danger ? palette.dangerHover : palette.hover : "transparent"
|
|
1099
|
+
},
|
|
1100
|
+
onMouseEnter: () => setHovered(true),
|
|
1101
|
+
onMouseLeave: () => setHovered(false),
|
|
1102
|
+
onClick: onSelect,
|
|
1103
|
+
children: [icon, /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: label })]
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
function UserIcon() {
|
|
1107
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
|
|
1108
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
1109
|
+
width: 15,
|
|
1110
|
+
height: 15,
|
|
1111
|
+
viewBox: "0 0 24 24",
|
|
1112
|
+
fill: "none",
|
|
1113
|
+
stroke: "currentColor",
|
|
1114
|
+
strokeWidth: 2,
|
|
1115
|
+
strokeLinecap: "round",
|
|
1116
|
+
strokeLinejoin: "round",
|
|
1117
|
+
"aria-hidden": true,
|
|
1118
|
+
style: { flex: "0 0 auto" },
|
|
1119
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
|
|
1120
|
+
cx: "12",
|
|
1121
|
+
cy: "7",
|
|
1122
|
+
r: "4"
|
|
1123
|
+
})]
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
function LogoutIcon() {
|
|
1127
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
|
|
1128
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
1129
|
+
width: 15,
|
|
1130
|
+
height: 15,
|
|
1131
|
+
viewBox: "0 0 24 24",
|
|
1132
|
+
fill: "none",
|
|
1133
|
+
stroke: "currentColor",
|
|
1134
|
+
strokeWidth: 2,
|
|
1135
|
+
strokeLinecap: "round",
|
|
1136
|
+
strokeLinejoin: "round",
|
|
1137
|
+
"aria-hidden": true,
|
|
1138
|
+
style: { flex: "0 0 auto" },
|
|
1139
|
+
children: [
|
|
1140
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" }),
|
|
1141
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("polyline", { points: "16 17 21 12 16 7" }),
|
|
1142
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("line", {
|
|
1143
|
+
x1: "21",
|
|
1144
|
+
x2: "9",
|
|
1145
|
+
y1: "12",
|
|
1146
|
+
y2: "12"
|
|
1147
|
+
})
|
|
1148
|
+
]
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
const HuaqiuAuthSidebarAction = (0, react.memo)(function HuaqiuAuthSidebarAction({ wide }) {
|
|
1152
|
+
const authState = (0, react.useSyncExternalStore)(subscribeAuth, getAuthState);
|
|
1153
|
+
const auth = getAuth();
|
|
1154
|
+
const dark = useIsDark();
|
|
1155
|
+
const locale = useLocale();
|
|
1156
|
+
const t = useT();
|
|
1157
|
+
const [menuOpen, setMenuOpen] = (0, react.useState)(false);
|
|
1158
|
+
const [menuStyle, setMenuStyle] = (0, react.useState)(null);
|
|
1159
|
+
const [avatarBroken, setAvatarBroken] = (0, react.useState)(false);
|
|
1160
|
+
const [hovered, setHovered] = (0, react.useState)(false);
|
|
1161
|
+
const triggerRef = (0, react.useRef)(null);
|
|
1162
|
+
const menuRef = (0, react.useRef)(null);
|
|
1163
|
+
const palette = dark ? DARK_PALETTE : LIGHT_PALETTE;
|
|
1164
|
+
const authenticated = authState.authenticated;
|
|
1165
|
+
const avatar = authenticated && !avatarBroken ? authState.avatar : void 0;
|
|
1166
|
+
const showLabel = wide !== false;
|
|
1167
|
+
(0, react.useEffect)(() => {
|
|
1168
|
+
setAvatarBroken(false);
|
|
1169
|
+
}, [authState.avatar]);
|
|
1170
|
+
(0, react.useEffect)(() => {
|
|
1171
|
+
if (!authenticated) setMenuOpen(false);
|
|
1172
|
+
}, [authenticated]);
|
|
1173
|
+
(0, react.useLayoutEffect)(() => {
|
|
1174
|
+
if (!menuOpen || !triggerRef.current) return;
|
|
1175
|
+
const rect = triggerRef.current.getBoundingClientRect();
|
|
1176
|
+
setMenuStyle({
|
|
1177
|
+
...MENU_BASE,
|
|
1178
|
+
background: palette.surface,
|
|
1179
|
+
borderColor: palette.border,
|
|
1180
|
+
color: palette.text,
|
|
1181
|
+
boxShadow: palette.shadow,
|
|
1182
|
+
left: Math.max(8, Math.round(rect.left)),
|
|
1183
|
+
bottom: Math.max(8, Math.round(window.innerHeight - rect.top + 8)),
|
|
1184
|
+
...wide ? { width: Math.round(rect.width) } : {}
|
|
1185
|
+
});
|
|
1186
|
+
}, [
|
|
1187
|
+
menuOpen,
|
|
1188
|
+
wide,
|
|
1189
|
+
avatar,
|
|
1190
|
+
palette
|
|
1191
|
+
]);
|
|
1192
|
+
(0, react.useEffect)(() => {
|
|
1193
|
+
if (!menuOpen) return;
|
|
1194
|
+
const onPointerDown = (event) => {
|
|
1195
|
+
const target = event.target;
|
|
1196
|
+
if (triggerRef.current?.contains(target)) return;
|
|
1197
|
+
if (menuRef.current?.contains(target)) return;
|
|
1198
|
+
setMenuOpen(false);
|
|
1199
|
+
};
|
|
1200
|
+
const onKeyDown = (event) => {
|
|
1201
|
+
if (event.key === "Escape") setMenuOpen(false);
|
|
1202
|
+
};
|
|
1203
|
+
const dismiss = () => setMenuOpen(false);
|
|
1204
|
+
document.addEventListener("mousedown", onPointerDown);
|
|
1205
|
+
document.addEventListener("keydown", onKeyDown);
|
|
1206
|
+
window.addEventListener("resize", dismiss);
|
|
1207
|
+
window.addEventListener("scroll", dismiss, true);
|
|
1208
|
+
return () => {
|
|
1209
|
+
document.removeEventListener("mousedown", onPointerDown);
|
|
1210
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
1211
|
+
window.removeEventListener("resize", dismiss);
|
|
1212
|
+
window.removeEventListener("scroll", dismiss, true);
|
|
1213
|
+
};
|
|
1214
|
+
}, [menuOpen]);
|
|
1215
|
+
if (!auth) return null;
|
|
1216
|
+
/**
|
|
1217
|
+
*「Go to profile」always carries the token, so eda.cn can establish the
|
|
1218
|
+
* session in the opened tab (it hides the token itself — see
|
|
1219
|
+
* `lib.ts#buildProfileUrl`). The snapshot normally has it; fall back to the
|
|
1220
|
+
* client so a stale snapshot can never open an unauthenticated tab.
|
|
1221
|
+
*/
|
|
1222
|
+
const openProfile = () => {
|
|
1223
|
+
setMenuOpen(false);
|
|
1224
|
+
(async () => {
|
|
1225
|
+
const info = authState.token ? {
|
|
1226
|
+
token: authState.token,
|
|
1227
|
+
phone: authState.phone
|
|
1228
|
+
} : await auth.getUserInfo().then((i) => i ? {
|
|
1229
|
+
token: i.token,
|
|
1230
|
+
phone: i.phone
|
|
1231
|
+
} : null).catch(() => null);
|
|
1232
|
+
if (!info?.token) return;
|
|
1233
|
+
window.open(buildProfileUrl(info), "_blank", "noopener,noreferrer");
|
|
1234
|
+
})();
|
|
1235
|
+
};
|
|
1236
|
+
const label = authenticated ? authState.nickname ?? t("sidebar.account") : t("sidebar.login");
|
|
1237
|
+
const title = authenticated ? t("sidebar.accountTitle") : t("sidebar.loginTitle");
|
|
1238
|
+
const triggerBackground = menuOpen || hovered ? palette.hover : "transparent";
|
|
1239
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1240
|
+
style: {
|
|
1241
|
+
position: "relative",
|
|
1242
|
+
width: "100%"
|
|
1243
|
+
},
|
|
1244
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
1245
|
+
ref: triggerRef,
|
|
1246
|
+
type: "button",
|
|
1247
|
+
"aria-haspopup": "menu",
|
|
1248
|
+
"aria-expanded": menuOpen,
|
|
1249
|
+
onClick: () => {
|
|
1250
|
+
if (!authenticated) {
|
|
1251
|
+
auth.login({
|
|
1252
|
+
lang: locale,
|
|
1253
|
+
theme: dark ? "dark" : "light"
|
|
1254
|
+
});
|
|
1255
|
+
return;
|
|
1256
|
+
}
|
|
1257
|
+
setMenuOpen((open) => !open);
|
|
1258
|
+
},
|
|
1259
|
+
onMouseEnter: () => setHovered(true),
|
|
1260
|
+
onMouseLeave: () => setHovered(false),
|
|
1261
|
+
style: {
|
|
1262
|
+
...TRIGGER_BASE,
|
|
1263
|
+
color: palette.text,
|
|
1264
|
+
padding: wide ? "8px 12px" : "8px 6px",
|
|
1265
|
+
background: triggerBackground
|
|
1266
|
+
},
|
|
1267
|
+
title,
|
|
1268
|
+
children: [avatar ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1269
|
+
style: {
|
|
1270
|
+
flex: "0 0 auto",
|
|
1271
|
+
width: AVATAR_SIZE,
|
|
1272
|
+
height: AVATAR_SIZE,
|
|
1273
|
+
borderRadius: "50%",
|
|
1274
|
+
overflow: "hidden",
|
|
1275
|
+
background: palette.avatarBg,
|
|
1276
|
+
display: "block"
|
|
1277
|
+
},
|
|
1278
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
|
|
1279
|
+
src: avatar,
|
|
1280
|
+
alt: "",
|
|
1281
|
+
width: AVATAR_SIZE,
|
|
1282
|
+
height: AVATAR_SIZE,
|
|
1283
|
+
onError: () => setAvatarBroken(true),
|
|
1284
|
+
style: {
|
|
1285
|
+
width: "100%",
|
|
1286
|
+
height: "100%",
|
|
1287
|
+
objectFit: "cover",
|
|
1288
|
+
display: "block"
|
|
1289
|
+
}
|
|
1290
|
+
})
|
|
1291
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(HQ_ICON, { size: ICON_SIZE }), showLabel ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1292
|
+
style: {
|
|
1293
|
+
overflow: "hidden",
|
|
1294
|
+
textOverflow: "ellipsis"
|
|
1295
|
+
},
|
|
1296
|
+
children: label
|
|
1297
|
+
}) : null]
|
|
1298
|
+
}), menuOpen && menuStyle ? (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1299
|
+
ref: menuRef,
|
|
1300
|
+
role: "menu",
|
|
1301
|
+
style: menuStyle,
|
|
1302
|
+
children: [
|
|
1303
|
+
authState.nickname ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1304
|
+
style: {
|
|
1305
|
+
...MENU_HEADER_BASE,
|
|
1306
|
+
color: palette.muted
|
|
1307
|
+
},
|
|
1308
|
+
title: authState.nickname,
|
|
1309
|
+
children: authState.nickname
|
|
1310
|
+
}) : null,
|
|
1311
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(MenuItem, {
|
|
1312
|
+
label: t("menu.profile"),
|
|
1313
|
+
icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(UserIcon, {}),
|
|
1314
|
+
palette,
|
|
1315
|
+
onSelect: openProfile
|
|
1316
|
+
}),
|
|
1317
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(MenuItem, {
|
|
1318
|
+
label: t("menu.logout"),
|
|
1319
|
+
icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LogoutIcon, {}),
|
|
1320
|
+
danger: true,
|
|
1321
|
+
palette,
|
|
1322
|
+
onSelect: () => {
|
|
1323
|
+
setMenuOpen(false);
|
|
1324
|
+
auth.logout();
|
|
1325
|
+
}
|
|
1326
|
+
})
|
|
1327
|
+
]
|
|
1328
|
+
}), document.body) : null]
|
|
1329
|
+
});
|
|
1330
|
+
});
|
|
1331
|
+
//#endregion
|
|
1332
|
+
//#region src/client/index.tsx
|
|
1333
|
+
/**
|
|
1334
|
+
* `@huaqiu/dsh-auth` — browser half (the Phase 0A POC).
|
|
1335
|
+
*
|
|
1336
|
+
* Opens the auth.eda.cn login page in an overlay iframe, STRICTLY validates
|
|
1337
|
+
* the postMessage origin, caches credentials in localStorage (reload restore),
|
|
1338
|
+
* and pushes them to the node half over the plugin-owned webServer routes.
|
|
1339
|
+
* Provides the client-side `huaqiuAuth` service mirroring the node surface.
|
|
1340
|
+
*
|
|
1341
|
+
* On top of the credential flow it wires the two UI surfaces the login UX
|
|
1342
|
+
* needs:
|
|
1343
|
+
* - `sidebar.footer.action` — a persistent 华秋EDA login entrypoint at the
|
|
1344
|
+
* bottom of the sidebar (login/logout, live state).
|
|
1345
|
+
* - `tool.call.toolview` (keyed per Huaqiu tool) — when a node tool returns
|
|
1346
|
+
* `status: "needs_auth"` the tool card becomes the login HIT: an embedded
|
|
1347
|
+
* auth.eda.cn iframe + login-state line, so login is a step of the
|
|
1348
|
+
* conversation instead of a dead error the agent has to relay.
|
|
1349
|
+
*/
|
|
1350
|
+
/**
|
|
1351
|
+
* Client cordis inject: REAL service names only (the loader maps these to
|
|
1352
|
+
* `ctx.inject([...])` dependencies). The `slots` registry service comes from
|
|
1353
|
+
* `@deepseek-ai/dsh-client-ui-slots`; it is required to register the toolview
|
|
1354
|
+
* and sidebar entries. The PACKAGE-level `dsh.client.inject` in package.json
|
|
1355
|
+
* (graph ordering) stays as-is and is NOT this export.
|
|
1356
|
+
*/
|
|
1357
|
+
const inject = ["slots"];
|
|
1358
|
+
/**
|
|
1359
|
+
* Huaqiu tools that still surface the auth login card via this plugin.
|
|
1360
|
+
*
|
|
1361
|
+
* Currently EMPTY: all five Huaqiu tools now own their keyed HIT cards in
|
|
1362
|
+
* their own plugins (`@huaqiu/dsh-tool-symbol-footprint` for the three
|
|
1363
|
+
* symbol/footprint generators, `@huaqiu/dsh-tool-schematic-gen` for the two
|
|
1364
|
+
* schematic/system generators), and each renders its own inline login card for
|
|
1365
|
+
* `needs_auth`. Keeping the toolview keys here would double-register the same
|
|
1366
|
+
* `tool.call.toolview` slot with an ambiguous winner.
|
|
1367
|
+
*
|
|
1368
|
+
* The auth plugin remains the credential owner: the `huaqiuAuth` client
|
|
1369
|
+
* service, the sidebar login entrypoint and the webServer credential channel.
|
|
1370
|
+
*/
|
|
1371
|
+
const AUTH_TOOL_NAMES = [];
|
|
1372
|
+
function apply(ctx) {
|
|
1373
|
+
const client = createAuthClient({
|
|
1374
|
+
storage: createAuthStorage(localStorage),
|
|
1375
|
+
transport: createWebServerAuthTransport(),
|
|
1376
|
+
windowLike: window,
|
|
1377
|
+
documentLike: document
|
|
1378
|
+
});
|
|
1379
|
+
const disposers = [];
|
|
1380
|
+
const disposeProvide = ctx.provide?.("huaqiuAuth", { auth: client.auth });
|
|
1381
|
+
registerAuth(client.auth);
|
|
1382
|
+
registerAuthSync(() => {
|
|
1383
|
+
client.syncNow();
|
|
1384
|
+
});
|
|
1385
|
+
client.restore();
|
|
1386
|
+
disposers.push(client.auth.onAuthStateChanged((info) => {
|
|
1387
|
+
client.syncNow();
|
|
1388
|
+
}));
|
|
1389
|
+
const sync = () => {
|
|
1390
|
+
client.syncNow();
|
|
1391
|
+
};
|
|
1392
|
+
window.addEventListener("focus", sync);
|
|
1393
|
+
document.addEventListener("visibilitychange", sync);
|
|
1394
|
+
disposers.push(() => {
|
|
1395
|
+
window.removeEventListener("focus", sync);
|
|
1396
|
+
document.removeEventListener("visibilitychange", sync);
|
|
1397
|
+
});
|
|
1398
|
+
const slots = ctx.slots;
|
|
1399
|
+
if (slots && typeof slots.inject === "function" && typeof slots.register === "function") {
|
|
1400
|
+
for (const toolName of AUTH_TOOL_NAMES) disposers.push(slots.inject("tool.call.toolview", () => slots.register({
|
|
1401
|
+
name: "tool.call.toolview",
|
|
1402
|
+
key: toolName
|
|
1403
|
+
}, HuaqiuToolView)));
|
|
1404
|
+
disposers.push(slots.inject("sidebar.footer.action", () => slots.register({
|
|
1405
|
+
name: "sidebar.footer.action",
|
|
1406
|
+
id: "huaqiu-auth"
|
|
1407
|
+
}, HuaqiuAuthSidebarAction)));
|
|
1408
|
+
}
|
|
1409
|
+
return () => {
|
|
1410
|
+
for (const dispose of disposers) try {
|
|
1411
|
+
dispose();
|
|
1412
|
+
} catch {}
|
|
1413
|
+
disposeProvide?.();
|
|
1414
|
+
client.dispose();
|
|
1415
|
+
disposeAuth();
|
|
1416
|
+
disposeUiEnv();
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
//#endregion
|
|
1420
|
+
exports.AUTH_TOOL_NAMES = AUTH_TOOL_NAMES;
|
|
1421
|
+
exports.apply = apply;
|
|
1422
|
+
exports.inject = inject;
|
|
1423
|
+
return module.exports;
|
|
1424
|
+
}
|
|
1425
|
+
});
|
|
1426
|
+
|
|
1427
|
+
//# sourceMappingURL=client.js.map
|