diff --git a/apps/desktop-client/src/main/account-binder.ts b/apps/desktop-client/src/main/account-binder.ts index 96c7e64..94d8e32 100644 --- a/apps/desktop-client/src/main/account-binder.ts +++ b/apps/desktop-client/src/main/account-binder.ts @@ -30,6 +30,12 @@ import { probeKimiAccountSession, readKimiAuthPageState, } from './adapters/kimi/auth-rules' +import { + detectQwenAccountFromSession, + probeQwenAccountSession, + QWEN_AUTH_PROBE_URL, + readQwenAuthPageState, +} from './adapters/qwen/auth-rules' import type { AccountHealthProfile, AuthProbeResult } from './auth-types' import { attachWindowDiagnostics, @@ -1263,134 +1269,6 @@ async function readGenericAIPageState( return normalizeGenericAIPageStateSnapshot(state) } -async function readQwenPageState( - webContents: WebContents | null | undefined, -): Promise { - 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)/i.test(descriptor)) { - return true; - } - current = current.parentElement; - } - return false; - }; - const bodyText = normalize(document.body?.innerText || "") || ""; - const loginSignalCount = /(登录|注册|扫码登录|手机号登录|请先登录|立即登录)/.test(bodyText) - ? 1 - : 0; - const loggedOutSignalCount = /(未登录|登录后继续|请先登录|扫码登录|手机号登录)/.test(bodyText) - ? 1 - : 0; - const challengeSignalCount = /(验证码|滑块|人机验证|安全验证|短信验证|captcha|verification)/i.test(bodyText) - ? 1 - : 0; - const stopWords = /^(千问|通义千问|Qwen|Qwen3\\.5-千问|新建对话|我的空间|智能体|新分组|最近对话|更多|设置|登录|注册)$/i; - const pickSidebarAccount = () => { - const lowerBandTop = window.innerHeight * 0.72; - const candidates = []; - for (const node of Array.from(document.querySelectorAll('aside *, nav *, [class*="side"] *, [class*="Sidebar"] *, body *'))) { - if (!(node instanceof HTMLElement) || !isVisible(node) || isInsideAuthSurface(node)) { - continue; - } - const rect = node.getBoundingClientRect(); - if (rect.top < lowerBandTop || rect.left > Math.max(360, window.innerWidth * 0.42)) { - continue; - } - const text = normalize(node.textContent || ""); - if (!text || text.length < 2 || text.length > 32 || stopWords.test(text)) { - continue; - } - if (!/(qwen|千问|通义|^[\\u4e00-\\u9fa5A-Za-z][\\u4e00-\\u9fa5A-Za-z0-9_-]{1,31}$)/i.test(text)) { - continue; - } - candidates.push({ text, top: rect.top, left: rect.left }); - } - candidates.sort((left, right) => right.top - left.top || left.left - right.left); - return candidates[0]?.text || null; - }; - const pickAvatar = () => { - const lowerBandTop = window.innerHeight * 0.72; - for (const node of Array.from(document.images)) { - if (!isVisible(node) || isInsideAuthSurface(node)) continue; - const rect = node.getBoundingClientRect(); - if (rect.top < lowerBandTop || rect.left > Math.max(360, window.innerWidth * 0.42)) { - continue; - } - const src = normalize(node.currentSrc || node.src || ""); - if (src && !/(logo|icon|favicon)/i.test(src)) { - return src; - } - } - return null; - }; - const displayName = pickSidebarAccount(); - const avatarUrl = pickAvatar(); - const credentialFingerprintParts = []; - try { - const state = window.__qianwenChatAPI?.sharedValues?.chatAPI?.state; - const userInfo = state?.userInfo || state?.user || state?.account || null; - if (userInfo && typeof userInfo === 'object') { - credentialFingerprintParts.push('qwen-state:' + JSON.stringify(userInfo).slice(0, 240)); - } - } catch {} - const strongSignals = []; - if (displayName) strongSignals.push('displayName'); - if (avatarUrl) strongSignals.push('avatar'); - return { - displayName, - avatarUrl, - fingerprint: null, - credentialFingerprint: credentialFingerprintParts.length - ? credentialFingerprintParts.join('||') - : null, - currentPath: normalize(window.location.pathname || ''), - strongAuthSignalCount: strongSignals.length, - loginSignalCount, - loggedOutSignalCount, - challengeSignalCount, - }; - } catch { - return null; - } - })();`, - true, - ) - .catch(() => null) - - return normalizeGenericAIPageStateSnapshot(state) -} - function isAIPlatformBinding(platformId: string): boolean { return aiPlatformCatalog.some((item) => item.id === platformId) } @@ -1525,33 +1403,8 @@ async function detectQwenFromSession( avatarUrl?: string | null } = {}, ): Promise { - const cookies = await session.cookies.get({ url: 'https://www.qianwen.com/' }).catch(() => []) - if (!cookies.length) { - return null - } - - const cookieMap = new Map() - for (const cookie of cookies) { - if (!cookieMap.has(cookie.name)) { - cookieMap.set(cookie.name, cookie.value) - } - } - - const ticketHash = normalizeText(cookieMap.get('tongyi_sso_ticket_hash')) - const ticket = normalizeText(cookieMap.get('tongyi_sso_ticket')) - const platformUid = ticketHash ?? ticket - if (!platformUid) { - return null - } - if (!genericAIHasVisibleAccountSignal(pageState)) { - return null - } - - return sanitizeDetectedAccount({ - platformUid, - displayName: hints.displayName ?? '通义千问账号', - avatarUrl: hints.avatarUrl ?? null, - }) + const detected = await detectQwenAccountFromSession(session, pageState, hints) + return detected ? sanitizeDetectedAccount(detected) : null } async function detectKimiFromSession( @@ -1585,7 +1438,7 @@ async function detectQwenPlatform( return null } - const pageState = await readQwenPageState(context.webContents).catch(() => null) + const pageState = await readQwenAuthPageState(context.webContents).catch(() => null) if ((pageState?.challengeSignalCount ?? 0) > 0) { return null } @@ -1594,27 +1447,14 @@ async function detectQwenPlatform( } const fromSession = await detectQwenFromSession(context.session, pageState, { - displayName: pageState?.displayName ?? `${label} 会话`, + displayName: pageState?.displayName ?? null, avatarUrl: pageState?.avatarUrl ?? null, }) if (fromSession) { return fromSession } - if (!genericAIHasVisibleAccountSignal(pageState)) { - return null - } - - const fingerprint = await buildGenericAISessionFingerprint(platformId, context.session, pageState) - if (!fingerprint) { - return null - } - - return sanitizeDetectedAccount({ - platformUid: fingerprint, - displayName: pageState?.displayName ?? `${label} 账号`, - avatarUrl: pageState?.avatarUrl ?? null, - }) + return null } async function detectDoubaoPlatform( @@ -2330,7 +2170,9 @@ export async function probeAIAccountSession( ? DOUBAO_AUTH_PROBE_URL : account.platform === 'kimi' ? KIMI_AUTH_PROBE_URL - : platformMeta.consoleUrl + : account.platform === 'qwen' + ? QWEN_AUTH_PROBE_URL + : platformMeta.consoleUrl const window = createBoundWindow( `${platformMeta.label} AI 静默探针`, @@ -2419,6 +2261,16 @@ export async function probeAIAccountSession( }) } + if (account.platform === 'qwen') { + return await probeQwenAccountSession({ + session: handle.session, + webContents: window.webContents, + currentURL, + fallbackDisplayName: account.displayName, + expectedPlatformUid: account.platformUid, + }) + } + if (definitionLooksLikeLoginRedirect(currentURL, { id: platformMeta.id, label: platformMeta.label, diff --git a/apps/desktop-client/src/main/adapters/qwen/auth-rules.test.ts b/apps/desktop-client/src/main/adapters/qwen/auth-rules.test.ts new file mode 100644 index 0000000..719e925 --- /dev/null +++ b/apps/desktop-client/src/main/adapters/qwen/auth-rules.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest' + +import type { GenericAIPageState } from '../../generic-ai-auth' +import { + isQwenLoggedInCredentialCookie, + isQwenLoggedInCredentialCookieName, + normalizeQwenDisplayName, + qwenHasBoundAccountPageSignal, +} from './auth-rules' + +function pageState(overrides: Partial = {}): GenericAIPageState { + return { + displayName: null, + avatarUrl: null, + fingerprint: null, + credentialFingerprint: null, + currentPath: '/', + strongAuthSignalCount: 0, + loginSignalCount: 0, + loggedOutSignalCount: 0, + challengeSignalCount: 0, + ...overrides, + } +} + +describe('qwen auth rules', () => { + it('does not treat sidebar text alone as a bound account', () => { + expect( + qwenHasBoundAccountPageSignal( + pageState({ + displayName: '合肥全屋定制品牌推荐', + avatarUrl: null, + credentialFingerprint: null, + strongAuthSignalCount: 1, + }), + ), + ).toBe(false) + }) + + it('normalizes account names without accepting Qwen navigation labels', () => { + expect(normalizeQwenDisplayName('Qwen8573')).toBe('Qwen8573') + expect(normalizeQwenDisplayName('最近对话')).toBe(null) + expect(normalizeQwenDisplayName('通义千问')).toBe(null) + expect(normalizeQwenDisplayName('通义千问 会话')).toBe(null) + expect(normalizeQwenDisplayName('通义千问账号')).toBe(null) + }) + + it('requires the signed-in Qwen user state before accepting SSO cookies', () => { + const anonymousState = pageState({ + displayName: '合肥全屋定制品牌推荐', + avatarUrl: null, + credentialFingerprint: null, + strongAuthSignalCount: 1, + }) + const boundState = pageState({ + displayName: 'Qwen8573', + avatarUrl: 'https://gw.alicdn.com/imgextra/i4/demo.jpg', + credentialFingerprint: 'user_id=1770341961223061452||is_login=true', + strongAuthSignalCount: 4, + }) + + expect(qwenHasBoundAccountPageSignal(anonymousState)).toBe(false) + expect(qwenHasBoundAccountPageSignal(boundState)).toBe(true) + expect( + isQwenLoggedInCredentialCookie( + { name: 'tongyi_sso_ticket_hash', value: '6ee8de3bbf91d218d7ad0e353641083c' }, + anonymousState, + ), + ).toBe(false) + expect( + isQwenLoggedInCredentialCookie( + { name: 'tongyi_sso_ticket_hash', value: '6ee8de3bbf91d218d7ad0e353641083c' }, + boundState, + ), + ).toBe(true) + }) + + it('keeps a logged-in Qwen user valid even if the display name temporarily disappears', () => { + expect( + qwenHasBoundAccountPageSignal( + pageState({ + displayName: null, + avatarUrl: null, + credentialFingerprint: 'user_id=1770341961223061452||is_login=true', + strongAuthSignalCount: 2, + }), + ), + ).toBe(true) + }) + + it('keeps Qwen login cookie names explicit', () => { + expect(isQwenLoggedInCredentialCookieName('tongyi_sso_ticket')).toBe(true) + expect(isQwenLoggedInCredentialCookieName('tongyi_sso_ticket_hash')).toBe(true) + expect(isQwenLoggedInCredentialCookieName('b-user-id')).toBe(false) + expect(isQwenLoggedInCredentialCookieName('_qk_bx_ck_v1')).toBe(false) + expect(isQwenLoggedInCredentialCookieName('XSRF-TOKEN')).toBe(false) + }) +}) diff --git a/apps/desktop-client/src/main/adapters/qwen/auth-rules.ts b/apps/desktop-client/src/main/adapters/qwen/auth-rules.ts new file mode 100644 index 0000000..e293055 --- /dev/null +++ b/apps/desktop-client/src/main/adapters/qwen/auth-rules.ts @@ -0,0 +1,455 @@ +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_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 { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function normalizeQwenPageStateSnapshot(state: unknown): GenericAIPageState | null { + if (!state || typeof state !== 'object') { + return null + } + + const typed = state as Partial + 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, + 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 { + 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 { + 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 { + 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, + } +} diff --git a/apps/desktop-client/src/main/generic-ai-auth.test.ts b/apps/desktop-client/src/main/generic-ai-auth.test.ts index 6a132e3..d29afc1 100644 --- a/apps/desktop-client/src/main/generic-ai-auth.test.ts +++ b/apps/desktop-client/src/main/generic-ai-auth.test.ts @@ -46,7 +46,7 @@ describe('generic ai auth detection', () => { expect(isLikelyGenericAICredentialName('passport_auth_token')).toBe(true) }) - it('requires visible account signals for platform cookies that can exist anonymously', () => { + it('keeps platform-specific Qwen cookies out of generic credential matching', () => { expect( isLikelyPlatformAICredentialCookie('qwen', 'tongyi_sso_ticket', 'anonymous-qwen-cookie', { displayName: '通义千问账号', @@ -59,7 +59,10 @@ describe('generic ai auth detection', () => { loggedOutSignalCount: 0, challengeSignalCount: 0, }), - ).toBe(true) + ).toBe(false) + }) + + it('allows generic credential cookies for platforms without isolated auth rules', () => { expect( isLikelyPlatformAICredentialCookie('deepseek', 'userToken', 'real-token-value', { displayName: null, diff --git a/apps/desktop-client/src/main/generic-ai-auth.ts b/apps/desktop-client/src/main/generic-ai-auth.ts index e93b8f4..eed7468 100644 --- a/apps/desktop-client/src/main/generic-ai-auth.ts +++ b/apps/desktop-client/src/main/generic-ai-auth.ts @@ -76,9 +76,8 @@ export function genericAIHasVisibleAccountSignal(pageState: GenericAIPageState | ) } -export function genericAIPlatformRequiresVisibleCredentialSignal(platformId: string): boolean { - const normalizedPlatformId = platformId.trim().toLowerCase() - return normalizedPlatformId === 'qwen' +export function genericAIPlatformRequiresVisibleCredentialSignal(_platformId: string): boolean { + return false } export function isLikelyPlatformAICredentialCookie( @@ -90,7 +89,11 @@ export function isLikelyPlatformAICredentialCookie( const normalizedPlatformId = platformId.trim().toLowerCase() const hasVisibleAccountSignal = genericAIHasVisibleAccountSignal(pageState ?? null) - if (normalizedPlatformId === 'doubao' || normalizedPlatformId === 'kimi') { + if ( + normalizedPlatformId === 'doubao' || + normalizedPlatformId === 'kimi' || + normalizedPlatformId === 'qwen' + ) { return false }