Compare commits

...

3 Commits

Author SHA1 Message Date
root d27be641b9 chore(desktop): bump kimi query timeout to 250s
Kimi's reasoning mode now needs noticeably longer to respond, so raise
the per-query timeout from 120s to 250s to avoid premature failures.
2026-05-06 21:34:19 +08:00
root df467b63b4 feat(desktop): refresh publish tasks instantly on lease activation
Emit a 'publish-task-lease' runtime invalidation event whenever the
scheduler activates a publish task, and have PublishManagementView
listen for it to trigger an immediate refresh. The view also switches
to a 5s active-task poll only while a task is in_progress, replacing
the unconditional 20s timer.
2026-05-06 21:34:13 +08:00
root ddce8b3d8d refactor(desktop): extract qwen auth rules into adapter module
Move the Qwen page-state probe, session detection, and silent probe
helpers out of account-binder into apps/desktop-client/src/main/adapters/qwen,
mirroring the Kimi adapter layout. Generic AI auth no longer special-cases
qwen since the platform now owns its own credential rules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 21:33:40 +08:00
11 changed files with 673 additions and 192 deletions
+24 -172
View File
@@ -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<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)/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<DetectedAccount | null> {
const cookies = await session.cookies.get({ url: 'https://www.qianwen.com/' }).catch(() => [])
if (!cookies.length) {
return null
}
const cookieMap = new Map<string, string>()
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,
@@ -7,7 +7,8 @@ import { normalizeText } from './common'
const KIMI_BOOTSTRAP_URL = 'https://www.kimi.com/'
const KIMI_PAGE_READY_TIMEOUT_MS = 20_000
const KIMI_MODE_SWITCH_TIMEOUT_MS = 8_000
const KIMI_QUERY_TIMEOUT_MS = 120_000
// kimi 现在算力不够需要更多时间来生成回答了,尤其是思考模式,所以把默认的查询超时从 90s 提高到 250s。
const KIMI_QUERY_TIMEOUT_MS = 250_000
const KIMI_QUERY_POLL_INTERVAL_MS = 1_200
const KIMI_STABLE_POLLS_REQUIRED = 5
const KIMI_INCOMPLETE_ANSWER_GRACE_MS = 18_000
@@ -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> = {}): 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)
})
})
@@ -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<void> {
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<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,
}
}
@@ -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,
@@ -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
}
@@ -84,7 +84,7 @@ import {
notePublishTaskLeaseMiss,
selectNextPublishTask,
} from './publish-scheduler'
import { onRuntimeInvalidated } from './runtime-events'
import { emitRuntimeInvalidated, onRuntimeInvalidated } from './runtime-events'
import {
initScheduler,
noteSchedulerAccountsSync,
@@ -2426,6 +2426,7 @@ async function executeLeasedTask(
noteMonitorTaskLeased(task, routing)
} else {
notePublishTaskActivated(taskRecord.id, taskRecord.platform)
emitRuntimeInvalidated('publish-task-lease', { taskId: taskRecord.id })
}
syncSchedulerSurface()
queueMicrotask(() => pumpExecutionLoop())
@@ -1,16 +1,17 @@
type RuntimeInvalidationReason = 'account-health'
type RuntimeInvalidationReason = 'account-health' | 'publish-task-lease'
type RuntimeInvalidationListener = (event: {
reason: RuntimeInvalidationReason
at: number
accountId?: string
taskId?: string
}) => void
const listeners = new Set<RuntimeInvalidationListener>()
export function emitRuntimeInvalidated(
reason: RuntimeInvalidationReason,
details: { accountId?: string } = {},
details: { accountId?: string; taskId?: string } = {},
): void {
const event = {
reason,
+12 -2
View File
@@ -145,11 +145,21 @@ const desktopBridge = {
setWindowMode: (mode: 'login' | 'main', request?: WindowModeRequest) =>
ipcRenderer.invoke('desktop:set-window-mode', mode, request) as Promise<null>,
onRuntimeInvalidated: (
listener: (event: { reason: 'account-health'; at: number; accountId?: string }) => void,
listener: (event: {
reason: 'account-health' | 'publish-task-lease'
at: number
accountId?: string
taskId?: string
}) => void,
) => {
const wrapped = (
_event: IpcRendererEvent,
payload: { reason: 'account-health'; at: number; accountId?: string },
payload: {
reason: 'account-health' | 'publish-task-lease'
at: number
accountId?: string
taskId?: string
},
) => {
listener(payload)
}
+6 -1
View File
@@ -64,7 +64,12 @@ declare global {
},
): Promise<null>
onRuntimeInvalidated(
listener: (event: { reason: 'account-health'; at: number; accountId?: string }) => void,
listener: (event: {
reason: 'account-health' | 'publish-task-lease'
at: number
accountId?: string
taskId?: string
}) => void,
): () => void
onNetworkObserved(listener: (event: DesktopObservedRequest) => void): () => void
}
@@ -26,6 +26,7 @@ import {
const PAGE_SIZE = 10
const INLINE_ERROR_HEAD_CHARS = 48
const INLINE_ERROR_TAIL_CHARS = 10
const ACTIVE_TASK_POLL_INTERVAL_MS = 5_000
const publishPlatformIndex: Record<string, (typeof desktopPublishMediaCatalog)[number]> =
Object.fromEntries(desktopPublishMediaCatalog.map((item) => [item.id, item]))
@@ -52,6 +53,7 @@ interface PublishTaskItem {
}
let refreshTimer: ReturnType<typeof setInterval> | null = null
let runtimeInvalidationUnsubscribe: (() => void) | null = null
function platformMeta(platform: string) {
const key = (platform ?? '').toLowerCase()
@@ -104,6 +106,12 @@ function isPendingStatus(status: DesktopTaskInfo['status']): boolean {
return status === 'queued' || status === 'in_progress'
}
function hasActivePublishingTask(page: DesktopPublishTaskListResponse | null): boolean {
return Boolean(
page?.items.some((task) => normalizePublishTaskStatus(task.status) === 'in_progress'),
)
}
function statusTone(status: DesktopTaskInfo['status']): 'info' | 'warn' | 'success' | 'danger' {
switch (status) {
case 'queued':
@@ -359,13 +367,61 @@ async function refreshTasks(page = currentPage.value) {
taskPage.value = response
currentPage.value = response.page
syncActiveTaskPolling(response)
} catch (err) {
error.value = normalizeTaskErrorMessage(unwrapClientErrorMessage(err, '发布任务暂时不可用'))
if (!hasActivePublishingTask(taskPage.value)) {
stopActiveTaskPolling()
}
} finally {
loading.value = false
}
}
function startActiveTaskPolling() {
if (refreshTimer) {
return
}
refreshTimer = setInterval(() => {
void refreshTasks()
}, ACTIVE_TASK_POLL_INTERVAL_MS)
}
function stopActiveTaskPolling() {
if (!refreshTimer) {
return
}
clearInterval(refreshTimer)
refreshTimer = null
}
function syncActiveTaskPolling(page = taskPage.value) {
if (hasActivePublishingTask(page)) {
startActiveTaskPolling()
return
}
stopActiveTaskPolling()
}
function bindPublishLeaseListener() {
if (runtimeInvalidationUnsubscribe || !window.desktopBridge?.app?.onRuntimeInvalidated) {
return
}
runtimeInvalidationUnsubscribe = window.desktopBridge.app.onRuntimeInvalidated((event) => {
if (event.reason !== 'publish-task-lease') {
return
}
void refreshTasks(1)
})
}
function unbindPublishLeaseListener() {
runtimeInvalidationUnsubscribe?.()
runtimeInvalidationUnsubscribe = null
}
async function retryTask(taskId: string) {
actionPendingTaskId.value = taskId
@@ -480,17 +536,13 @@ watch(searchTitle, (value) => {
})
onMounted(() => {
bindPublishLeaseListener()
void refreshTasks()
refreshTimer = setInterval(() => {
void refreshTasks()
}, 20_000)
})
onUnmounted(() => {
if (refreshTimer) {
clearInterval(refreshTimer)
refreshTimer = null
}
unbindPublishLeaseListener()
stopActiveTaskPolling()
})
const accountNameMap = computed(() => {