8bd2d88d88
Detect the passport.qianwen.com bridge page after a successful SSO login and navigate the webContents back to the original returnUrl so account binding can proceed instead of stalling on the bridge. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
532 lines
18 KiB
TypeScript
532 lines
18 KiB
TypeScript
import type { Cookie, Session, WebContents } from 'electron/main'
|
|
|
|
import { normalizeRemoteUrl } from '../common'
|
|
import type { AccountHealthProfile, AuthProbeResult } from '../../auth-types'
|
|
import type { GenericAIPageState } from '../../generic-ai-auth'
|
|
|
|
// Qwen binding rules are deliberately isolated here.
|
|
// Qwen can keep guest chats and cached sidebar sessions before/without a real
|
|
// login. Geo Rankly must require the real signed-in Qwen account state before
|
|
// binding. Do not move these DOM selectors, window state probes, or cookie names
|
|
// into generic AI auth helpers, and do not infer the account name from recent
|
|
// conversation titles.
|
|
export const QWEN_AUTH_PROBE_URL = 'https://www.qianwen.com/'
|
|
|
|
const QWEN_LOGGED_IN_COOKIE_NAMES = new Set(['tongyi_sso_ticket', 'tongyi_sso_ticket_hash'])
|
|
const QWEN_LOGIN_BRIDGE_HOST = 'passport.qianwen.com'
|
|
const QWEN_LOGIN_BRIDGE_PATH = '/havanaone/window_page_end_bridge.htm'
|
|
|
|
const QWEN_ACCOUNT_TEXT_STOP_WORDS =
|
|
/^(千问|通义千问|Qwen|Qwen3\.5-千问|新建对话|我的空间|智能体|对话分组|新分组|最近对话|更多|设置|登录|注册|下载电脑端|API 服务|你好!初次对话)$/i
|
|
|
|
function normalizeText(value: unknown): string | null {
|
|
if (typeof value !== 'string') {
|
|
return null
|
|
}
|
|
const trimmed = value.trim().replace(/\s+/g, ' ')
|
|
return trimmed ? trimmed : null
|
|
}
|
|
|
|
function hasCredentialValue(value: string | null | undefined): boolean {
|
|
const normalized = normalizeText(value)
|
|
if (!normalized) {
|
|
return false
|
|
}
|
|
|
|
if (/^(true|false|null|undefined|0|1)$/i.test(normalized)) {
|
|
return false
|
|
}
|
|
|
|
return normalized.length >= 8
|
|
}
|
|
|
|
function hashText(value: string): string {
|
|
let hash = 0
|
|
for (let index = 0; index < value.length; index += 1) {
|
|
hash = ((hash << 5) - hash + value.charCodeAt(index)) | 0
|
|
}
|
|
return Math.abs(hash).toString(16).padStart(8, '0')
|
|
}
|
|
|
|
function boundedIdentity(prefix: string, raw: string): string {
|
|
const normalizedPrefix = prefix.trim().replace(/:+$/, '')
|
|
const normalizedRaw = raw.trim()
|
|
if (!normalizedRaw) {
|
|
return `${normalizedPrefix}:${hashText(prefix)}`
|
|
}
|
|
|
|
const candidate = `${normalizedPrefix}:${normalizedRaw}`
|
|
if (candidate.length <= 120) {
|
|
return candidate
|
|
}
|
|
|
|
return `${normalizedPrefix}:${hashText(normalizedRaw)}`
|
|
}
|
|
|
|
function delay(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
}
|
|
|
|
function decodeQwenBridgeParams(value: string): Record<string, unknown> | null {
|
|
const normalized = value.trim()
|
|
if (!normalized) {
|
|
return null
|
|
}
|
|
|
|
const decodeAs = (encoding: BufferEncoding) => {
|
|
try {
|
|
return JSON.parse(Buffer.from(normalized, encoding).toString('utf8')) as Record<
|
|
string,
|
|
unknown
|
|
>
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
return decodeAs('base64url') ?? decodeAs('base64')
|
|
}
|
|
|
|
function isQwenWebURL(value: unknown): value is string {
|
|
if (typeof value !== 'string') {
|
|
return false
|
|
}
|
|
|
|
try {
|
|
const parsed = new URL(value)
|
|
return parsed.protocol === 'https:' && parsed.hostname === 'www.qianwen.com'
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
export function resolveQwenLoginBridgeReturnURL(currentURL: string): string | null {
|
|
try {
|
|
const parsed = new URL(currentURL)
|
|
if (
|
|
parsed.protocol !== 'https:' ||
|
|
parsed.hostname !== QWEN_LOGIN_BRIDGE_HOST ||
|
|
parsed.pathname !== QWEN_LOGIN_BRIDGE_PATH
|
|
) {
|
|
return null
|
|
}
|
|
|
|
const params = decodeQwenBridgeParams(parsed.searchParams.get('params') ?? '')
|
|
const loginResult = typeof params?.loginResult === 'string' ? params.loginResult : null
|
|
const status = typeof params?.st === 'string' ? params.st : null
|
|
if (loginResult !== 'success' && status !== 'success') {
|
|
return null
|
|
}
|
|
|
|
return isQwenWebURL(params?.returnUrl) ? params.returnUrl : QWEN_AUTH_PROBE_URL
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
export async function redirectQwenSuccessfulLoginBridge(
|
|
webContents: WebContents | null | undefined,
|
|
): Promise<{ currentURL: string; returnURL: string } | null> {
|
|
if (!webContents || webContents.isDestroyed()) {
|
|
return null
|
|
}
|
|
|
|
const currentURL = webContents.getURL()
|
|
const returnURL = resolveQwenLoginBridgeReturnURL(currentURL)
|
|
if (!returnURL) {
|
|
return null
|
|
}
|
|
|
|
await webContents.loadURL(returnURL)
|
|
return { currentURL, returnURL }
|
|
}
|
|
|
|
function normalizeQwenPageStateSnapshot(state: unknown): GenericAIPageState | null {
|
|
if (!state || typeof state !== 'object') {
|
|
return null
|
|
}
|
|
|
|
const typed = state as Partial<GenericAIPageState>
|
|
return {
|
|
displayName: normalizeQwenDisplayName(typed.displayName),
|
|
avatarUrl: normalizeRemoteUrl(typed.avatarUrl),
|
|
fingerprint: normalizeText(typed.fingerprint),
|
|
credentialFingerprint: normalizeText(typed.credentialFingerprint),
|
|
currentPath: normalizeText(typed.currentPath),
|
|
strongAuthSignalCount:
|
|
typeof typed.strongAuthSignalCount === 'number' &&
|
|
Number.isFinite(typed.strongAuthSignalCount)
|
|
? typed.strongAuthSignalCount
|
|
: 0,
|
|
loginSignalCount:
|
|
typeof typed.loginSignalCount === 'number' && Number.isFinite(typed.loginSignalCount)
|
|
? typed.loginSignalCount
|
|
: 0,
|
|
loggedOutSignalCount:
|
|
typeof typed.loggedOutSignalCount === 'number' && Number.isFinite(typed.loggedOutSignalCount)
|
|
? typed.loggedOutSignalCount
|
|
: 0,
|
|
challengeSignalCount:
|
|
typeof typed.challengeSignalCount === 'number' && Number.isFinite(typed.challengeSignalCount)
|
|
? typed.challengeSignalCount
|
|
: 0,
|
|
}
|
|
}
|
|
|
|
export function normalizeQwenDisplayName(value: unknown): string | null {
|
|
const normalized = normalizeText(value)
|
|
if (!normalized) {
|
|
return null
|
|
}
|
|
|
|
if (/^(?:通义千问|千问|Qwen)\s*(?:会话|账号|account|session)$/i.test(normalized)) {
|
|
return null
|
|
}
|
|
|
|
if (normalized.length < 2 || normalized.length > 48) {
|
|
return null
|
|
}
|
|
|
|
if (QWEN_ACCOUNT_TEXT_STOP_WORDS.test(normalized)) {
|
|
return null
|
|
}
|
|
|
|
return normalized
|
|
}
|
|
|
|
export function qwenHasBoundAccountPageSignal(
|
|
pageState: GenericAIPageState | null | undefined,
|
|
): boolean {
|
|
return Boolean(
|
|
pageState &&
|
|
pageState.strongAuthSignalCount >= 2 &&
|
|
pageState.credentialFingerprint,
|
|
)
|
|
}
|
|
|
|
export function isQwenLoggedInCredentialCookieName(name: string): boolean {
|
|
return QWEN_LOGGED_IN_COOKIE_NAMES.has(name.trim().toLowerCase())
|
|
}
|
|
|
|
export function isQwenLoggedInCredentialCookie(
|
|
cookie: Pick<Cookie, 'name' | 'value'>,
|
|
pageState: GenericAIPageState | null | undefined,
|
|
): boolean {
|
|
return (
|
|
qwenHasBoundAccountPageSignal(pageState) &&
|
|
isQwenLoggedInCredentialCookieName(cookie.name) &&
|
|
hasCredentialValue(cookie.value)
|
|
)
|
|
}
|
|
|
|
export async function buildQwenSessionCredentialEvidence(
|
|
session: Session,
|
|
pageState: GenericAIPageState | null | undefined,
|
|
): Promise<string | null> {
|
|
if (!qwenHasBoundAccountPageSignal(pageState)) {
|
|
return null
|
|
}
|
|
|
|
const parts: string[] = []
|
|
const credentialFingerprint = normalizeText(pageState?.credentialFingerprint)
|
|
if (credentialFingerprint) {
|
|
parts.push(`page:${credentialFingerprint}`)
|
|
}
|
|
|
|
const cookies = await session.cookies.get({ url: QWEN_AUTH_PROBE_URL }).catch(() => [])
|
|
const authCookies = cookies.filter((cookie) => isQwenLoggedInCredentialCookie(cookie, pageState))
|
|
if (authCookies.length === 0) {
|
|
return null
|
|
}
|
|
|
|
const serialized = authCookies
|
|
.map((cookie) => `${cookie.domain}|${cookie.name}=${cookie.value}`)
|
|
.sort()
|
|
.join(';')
|
|
parts.push(`cookies:${serialized}`)
|
|
|
|
return parts.length > 0 ? parts.join('||') : null
|
|
}
|
|
|
|
export async function readQwenAuthPageState(
|
|
webContents: WebContents | null | undefined,
|
|
): Promise<GenericAIPageState | null> {
|
|
if (!webContents || webContents.isDestroyed()) {
|
|
return null
|
|
}
|
|
|
|
const state = await webContents
|
|
.executeJavaScript(
|
|
`(() => {
|
|
try {
|
|
const normalize = (value) => {
|
|
if (typeof value !== "string") return null;
|
|
const trimmed = value.trim().replace(/\\s+/g, " ");
|
|
return trimmed || null;
|
|
};
|
|
const isVisible = (node) => {
|
|
if (!(node instanceof Element)) return false;
|
|
const style = window.getComputedStyle(node);
|
|
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
|
|
return false;
|
|
}
|
|
const rect = node.getBoundingClientRect();
|
|
return rect.width > 0 && rect.height > 0;
|
|
};
|
|
const isInsideAuthSurface = (node) => {
|
|
let current = node instanceof Element ? node : null;
|
|
for (let depth = 0; current && depth < 8; depth += 1) {
|
|
const descriptor = [
|
|
current.getAttribute('role') || '',
|
|
current.getAttribute('aria-modal') || '',
|
|
current.getAttribute('data-testid') || '',
|
|
current.id || '',
|
|
current.className || '',
|
|
].join(' ');
|
|
if (/(dialog|modal|popup|popover|login|signin|auth|scan|qrcode|phone|captcha)/i.test(descriptor)) {
|
|
return true;
|
|
}
|
|
current = current.parentElement;
|
|
}
|
|
return false;
|
|
};
|
|
const stopWords = /^(千问|通义千问|Qwen|Qwen3\\.5-千问|新建对话|我的空间|智能体|对话分组|新分组|最近对话|更多|设置|登录|注册|下载电脑端|API 服务|你好!初次对话)$/i;
|
|
const normalizeName = (value) => {
|
|
const text = normalize(value || "");
|
|
if (!text || /^(?:通义千问|千问|Qwen)\\s*(?:会话|账号|account|session)$/i.test(text)) return null;
|
|
if (text.length < 2 || text.length > 48 || stopWords.test(text)) return null;
|
|
return text;
|
|
};
|
|
const pickUserObject = () => {
|
|
const raw = window._USER_;
|
|
if (!raw || typeof raw !== 'object') return null;
|
|
const userId = normalize(String(raw.userId || raw.aliyunUid || raw.uid || raw.id || ""));
|
|
const displayName = normalizeName(raw.showName || raw.nickName || raw.nickname || raw.name || raw.displayName || "");
|
|
const avatarUrl = normalize(raw.avatarUrl || raw.avatar || raw.headUrl || raw.picture || "");
|
|
if (!userId) return null;
|
|
return { userId, displayName, avatarUrl };
|
|
};
|
|
const isLoginState = () => {
|
|
try {
|
|
return window.__qianwenChatAPI?.sharedValues?.chatAPI?.state?.isLogin === true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
const findSidebar = () => {
|
|
const direct = document.querySelector('#new-nav-tab-wrapper');
|
|
if (direct instanceof HTMLElement && isVisible(direct)) return direct;
|
|
for (const node of Array.from(document.querySelectorAll('aside'))) {
|
|
if (node instanceof HTMLElement && isVisible(node)) return node;
|
|
}
|
|
return null;
|
|
};
|
|
const pickSidebarAccount = () => {
|
|
const sidebar = findSidebar();
|
|
if (!sidebar) return null;
|
|
const sidebarRect = sidebar.getBoundingClientRect();
|
|
const lowerBandTop = sidebarRect.top + sidebarRect.height * 0.86;
|
|
const candidates = [];
|
|
const triggers = Array.from(
|
|
sidebar.querySelectorAll('[aria-haspopup="menu"], [type="button"], button, [role="button"], .tongyiDI-view-container, [class*="desktop-no-drag"]'),
|
|
);
|
|
for (const node of triggers) {
|
|
if (!(node instanceof HTMLElement) || !isVisible(node) || isInsideAuthSurface(node)) continue;
|
|
const rect = node.getBoundingClientRect();
|
|
if (rect.top < lowerBandTop || rect.left > sidebarRect.right || rect.width > sidebarRect.width * 0.9) {
|
|
continue;
|
|
}
|
|
const image = node.querySelector('img');
|
|
const avatarUrl = image instanceof HTMLImageElement
|
|
? normalize(image.currentSrc || image.src || "")
|
|
: null;
|
|
const leafNames = Array.from(node.querySelectorAll('div, span, p'))
|
|
.filter((child) => child instanceof HTMLElement && isVisible(child) && child.children.length === 0)
|
|
.map((child) => normalizeName(child.textContent || ""))
|
|
.filter(Boolean);
|
|
const displayName = leafNames[0] || normalizeName(node.textContent || "");
|
|
if (!displayName && !avatarUrl) continue;
|
|
candidates.push({ displayName, avatarUrl, top: rect.top, hasMenu: node.getAttribute('aria-haspopup') === 'menu' });
|
|
}
|
|
candidates.sort((left, right) => Number(right.hasMenu) - Number(left.hasMenu) || right.top - left.top);
|
|
const candidate = candidates[0];
|
|
return candidate
|
|
? { displayName: candidate.displayName || null, avatarUrl: candidate.avatarUrl || null }
|
|
: null;
|
|
};
|
|
const bodyText = normalize(document.body?.innerText || "") || "";
|
|
const user = pickUserObject();
|
|
const loggedInState = isLoginState();
|
|
const sidebarAccount = pickSidebarAccount();
|
|
const credentialParts = [];
|
|
if (user?.userId) credentialParts.push('user_id=' + user.userId);
|
|
if (loggedInState) credentialParts.push('is_login=true');
|
|
const strongSignals = [];
|
|
if (user?.userId) strongSignals.push('qwenUserObject');
|
|
if (loggedInState) strongSignals.push('qwenIsLogin');
|
|
if (sidebarAccount?.displayName) strongSignals.push('sidebarDisplayName');
|
|
if (sidebarAccount?.avatarUrl || user?.avatarUrl) strongSignals.push('avatar');
|
|
const loginSignalCount = /(登录|注册|扫码登录|手机号登录|请先登录|立即登录|登录可同步历史对话)/.test(bodyText)
|
|
? 1
|
|
: 0;
|
|
const loggedOutSignalCount = /(未登录|登录后继续|请先登录|扫码登录|手机号登录|登录可同步历史对话)/.test(bodyText)
|
|
? 1
|
|
: 0;
|
|
const challengeSignalCount = /(验证码|滑块|人机验证|安全验证|短信验证|captcha|verification)/i.test(bodyText)
|
|
? 1
|
|
: 0;
|
|
return {
|
|
displayName: sidebarAccount?.displayName || user?.displayName || null,
|
|
avatarUrl: sidebarAccount?.avatarUrl || user?.avatarUrl || null,
|
|
fingerprint: null,
|
|
credentialFingerprint: credentialParts.length ? credentialParts.join('||') : null,
|
|
currentPath: normalize(window.location.pathname || ''),
|
|
strongAuthSignalCount: strongSignals.length,
|
|
loginSignalCount,
|
|
loggedOutSignalCount: loggedOutSignalCount && !user?.userId && !loggedInState ? loggedOutSignalCount : 0,
|
|
challengeSignalCount,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
})();`,
|
|
true,
|
|
)
|
|
.catch(() => null)
|
|
|
|
return normalizeQwenPageStateSnapshot(state)
|
|
}
|
|
|
|
export async function detectQwenAccountFromSession(
|
|
session: Session,
|
|
pageState: GenericAIPageState | null,
|
|
hints: {
|
|
displayName?: string | null
|
|
avatarUrl?: string | null
|
|
} = {},
|
|
): Promise<{ platformUid: string; displayName: string; avatarUrl: string | null } | null> {
|
|
const credentialEvidence = await buildQwenSessionCredentialEvidence(session, pageState)
|
|
if (!credentialEvidence) {
|
|
return null
|
|
}
|
|
|
|
const displayName =
|
|
normalizeQwenDisplayName(hints.displayName) ?? normalizeQwenDisplayName(pageState?.displayName)
|
|
if (!displayName) {
|
|
return null
|
|
}
|
|
|
|
return {
|
|
platformUid: boundedIdentity('qwen-session', credentialEvidence),
|
|
displayName,
|
|
avatarUrl: hints.avatarUrl ?? pageState?.avatarUrl ?? null,
|
|
}
|
|
}
|
|
|
|
export async function probeQwenAccountSession(input: {
|
|
session: Session
|
|
webContents: WebContents | null | undefined
|
|
currentURL: string
|
|
fallbackDisplayName: string
|
|
expectedPlatformUid?: string | null
|
|
}): Promise<AuthProbeResult> {
|
|
const startedAt = Date.now()
|
|
let pageState: GenericAIPageState | null = null
|
|
let credentialEvidence: string | null = null
|
|
|
|
while (Date.now() - startedAt < 8_000) {
|
|
pageState = await readQwenAuthPageState(input.webContents).catch(() => null)
|
|
if ((pageState?.challengeSignalCount ?? 0) > 0) {
|
|
break
|
|
}
|
|
if ((pageState?.loggedOutSignalCount ?? 0) > 0) {
|
|
break
|
|
}
|
|
if (qwenHasBoundAccountPageSignal(pageState)) {
|
|
const nextCredentialEvidence = await buildQwenSessionCredentialEvidence(
|
|
input.session,
|
|
pageState,
|
|
)
|
|
if (nextCredentialEvidence) {
|
|
credentialEvidence = nextCredentialEvidence
|
|
}
|
|
if (credentialEvidence && normalizeQwenDisplayName(pageState?.displayName)) {
|
|
break
|
|
}
|
|
}
|
|
await delay(250)
|
|
}
|
|
|
|
if ((pageState?.challengeSignalCount ?? 0) > 0) {
|
|
return {
|
|
verdict: 'challenge_required',
|
|
reason: 'captcha_gate',
|
|
profile: null,
|
|
sessionFingerprint: null,
|
|
evidence: {
|
|
url: input.currentURL,
|
|
challengeSignalCount: pageState?.challengeSignalCount ?? 0,
|
|
},
|
|
}
|
|
}
|
|
|
|
if ((pageState?.loggedOutSignalCount ?? 0) > 0) {
|
|
return {
|
|
verdict: 'expired',
|
|
reason: 'login_redirect',
|
|
profile: null,
|
|
sessionFingerprint: null,
|
|
evidence: {
|
|
url: input.currentURL,
|
|
loggedOutSignalCount: pageState?.loggedOutSignalCount ?? 0,
|
|
},
|
|
}
|
|
}
|
|
|
|
if (!qwenHasBoundAccountPageSignal(pageState)) {
|
|
return {
|
|
verdict: 'expired',
|
|
reason: 'login_redirect',
|
|
profile: null,
|
|
sessionFingerprint: null,
|
|
evidence: {
|
|
url: input.currentURL,
|
|
code: 'qwen_account_state_missing',
|
|
strongAuthSignalCount: pageState?.strongAuthSignalCount ?? 0,
|
|
loginSignalCount: pageState?.loginSignalCount ?? 0,
|
|
loggedOutSignalCount: pageState?.loggedOutSignalCount ?? 0,
|
|
},
|
|
}
|
|
}
|
|
|
|
if (!credentialEvidence) {
|
|
return {
|
|
verdict: 'expired',
|
|
reason: 'login_redirect',
|
|
profile: null,
|
|
sessionFingerprint: null,
|
|
evidence: {
|
|
url: input.currentURL,
|
|
code: 'qwen_login_cookie_missing',
|
|
},
|
|
}
|
|
}
|
|
|
|
const sessionFingerprint = boundedIdentity('qwen-session', credentialEvidence)
|
|
const profile: AccountHealthProfile = {
|
|
subjectUid: sessionFingerprint,
|
|
displayName:
|
|
normalizeQwenDisplayName(pageState?.displayName) ??
|
|
normalizeQwenDisplayName(input.fallbackDisplayName) ??
|
|
null,
|
|
avatarUrl: pageState?.avatarUrl ?? null,
|
|
}
|
|
|
|
return {
|
|
verdict: 'active',
|
|
reason: 'probe_success',
|
|
profile,
|
|
sessionFingerprint,
|
|
}
|
|
}
|