diff --git a/applets/weblite/README.md b/applets/weblite/README.md index 98eb09c82..78e3be0ad 100644 --- a/applets/weblite/README.md +++ b/applets/weblite/README.md @@ -24,6 +24,8 @@ Core 连接令牌 → Tinker 领取并消费令牌 Tinker 原样传入 `asset`、`account`、`platform` 等通用连接数据,不识别 Applet 名称、不组装 Web 专属参数。WebLite 优先使用 `asset.spec_info` 的登录配置,未设置代填模式时回退到对应平台协议配置;安全模式来自平台配置,匿名账号 `@ANON` 关闭代填。数据通过管道传入,不进入命令行或环境变量。主进程读取并校验启动管道,渲染进程只获得显示配置。凭据只允许在资产或脚本明确指定的 origin 领取一次,关闭、失败或完成登录时释放引用。每次启动使用独立临时 profile 和内存网站 session,可并发运行。 +基础代填的用户名、密码、提交、成功和交互区域选择器均可独立留空。未配置提交选择器时仅代填,保留页面输入并交还操作,用户可手动登录;此时不进入受保护的交互区域流程、不等待成功标记,也不会显示为已提交或登录成功。配置了提交选择器时仍按已配置的交互区域和成功标记执行自动登录。非空选择器仍需格式正确,已配置的登录元素仍需等待页面加载并通过校验。 + Website 资产的 `allowed_urls` 为可选访问白名单:留空允许所有站点;配置后只允许导航到资产地址所属站点和列表中的 HTTP/HTTPS origin(协议、主机、端口,不含路径或通配符)。同一策略用于页面链接、重定向、弹窗和登录脚本中的 `open`;子页面和图片、脚本等资源不受此导航策略限制。Tinker 通过启动管道传入该字段,桌面客户端通过资产详情传入,共用浏览器导航校验。 HTTPS 资产支持自签名证书和私有 CA。仅在当前网站的独立 session 中,对资产地址、访问白名单和登录脚本 origin 明确指定的 HTTPS 主机放行 `ERR_CERT_AUTHORITY_INVALID`;独立浏览模式也支持地址栏手动输入的 HTTPS 主机。其他主机及其他证书错误继续使用 Chromium 校验。信任按主机匹配,不区分端口,不随页面重定向自动扩大,也不修改系统证书库。自签名的 SSO 中转站或资源主机需显式配置,或在发布机安装可信 CA。 diff --git a/applets/weblite/tests/launch.test.ts b/applets/weblite/tests/launch.test.ts index 5c15795db..d4a5e8ea6 100644 --- a/applets/weblite/tests/launch.test.ts +++ b/applets/weblite/tests/launch.test.ts @@ -30,6 +30,40 @@ const applet = { account: { username: "tester", secret: "one-use-secret", secret_type: { value: "password" } } }; +test("basic autofill accepts independently omitted selectors in both launch formats", () => { + const fields = ["username", "password", "submit", "success", "interactive"]; + for (const missing of [undefined, null, ""]) { + for (const field of [...fields, "all"]) { + const config = { ...launch.login.config }; + for (const name of field === "all" ? fields : [field]) config[`${name}_selector`] = missing; + for (const data of [ + { ...launch, login: { ...launch.login, config } }, + { ...applet, asset: { ...applet.asset, spec_info: config } } + ]) { + const session = parseLaunch(data).localSession; + assert.equal(session.autofillAvailable, true); + for (const name of fields) + assert.equal(session.selectors[name], name === field || field === "all" ? "" : config[`${name}_selector`]); + } + } + } +}); + +test("optional selectors still reject malformed nonempty configuration", () => { + for (const field of ["username", "password", "submit", "success", "interactive"]) { + for (const value of [false, 0, {}, " ", "id=", "javascript=alert(1)"]) { + assert.throws( + () => + parseLaunch({ + ...launch, + login: { ...launch.login, config: { ...launch.login.config, [`${field}_selector`]: value } } + }), + /代填元素配置无效/ + ); + } + } +}); + test("generic AppletArgs opens the asset and fills credentials without an app-name dependency", async () => { const result = await readLaunch(Readable.from([JSON.stringify(applet)])); assert.equal(result.targetUrl, launch.target_url); @@ -254,7 +288,7 @@ test("rejects invalid configuration and oversized pipes", async () => { { safe_mode: "false" }, { recording_enabled: true, login: undefined }, { login: { config: { autofill: "invalid" } } }, - { login: { config: { autofill: "basic" }, password: "secret" } }, + { login: { config: { autofill: "basic", submit_selector: "invalid" }, password: "secret" } }, { login: { config: { autofill: "script", script: [{ step: 1, command: "select_frame", target: "id=login" }] } } } ]) assert.throws(() => parseLaunch({ ...launch, ...patch })); diff --git a/electron/tests/web-proxy-interaction-check.ts b/electron/tests/web-proxy-interaction-check.ts index 389fe0170..aad4cebd2 100644 --- a/electron/tests/web-proxy-interaction-check.ts +++ b/electron/tests/web-proxy-interaction-check.ts @@ -41,11 +41,69 @@ slider.onpointerup = () => { start = null; slider.style.cursor='grab'; }; `; export async function run() { + await runOptionalSelectorChecks(); for (const success of ["id=success", ""]) { for (const challenge of ["initial", "after-failed", "none"]) await runCase(success, challenge); } } +async function runOptionalSelectorChecks() { + const host = new BrowserWindow({ show: false, webPreferences: { sandbox: true, contextIsolation: true } }); + const evaluate = (code: string) => host.webContents.executeJavaScript(code, true); + try { + for (let mask = 0; mask < 12; mask++) { + const selectors = { + username: mask & 1 ? "id=username" : "", + password: mask & 2 ? "id=password" : "", + submit: mask & 4 ? "id=submit" : "", + interactive: mask & 8 ? "id=mfa" : "", + success: mask & 8 ? "id=success" : "" + }; + const html = `
+ ${selectors.username ? '' : ""} + ${selectors.password ? '' : ""} + +
Verification
`; + await host.loadURL(`data:text/html,${encodeURIComponent(html)}`); + assert.equal(await evaluate(buildAutofillProbeScript(selectors)), true, `optional selectors, mask ${mask}`); + assert.equal( + await evaluate(buildAutofillScript(selectors, { username: "managed-user", password: "managed-secret" })), + true + ); + assert.equal(await evaluate("document.querySelector('#untouched').value"), "user-entered"); + assert.equal(await evaluate("document.querySelector('[data-jms-secure-login]') === null"), true); + if (!selectors.submit) { + assert.equal(await evaluate("submissions.length"), 0, "fill-only must not submit implicitly"); + await evaluate("document.querySelector('#manual').click()"); + } + assert.deepEqual(await evaluate("submissions"), [ + { + username: selectors.username ? "managed-user" : "", + password: selectors.password ? "managed-secret" : "" + } + ]); + } + const guarded = (code: string) => host.webContents.executeJavaScriptInIsolatedWorld(INTERACTION_WORLD, [{ code }]); + await guarded( + buildInteractionGuardScript({ username: "id=username", submit: "id=submit", interactive: "id=missing" }, "null") + ); + assert.equal(await guarded("globalThis.__jmsVerification.advanceLogin(false)"), "waiting"); + await delay(600); + assert.equal( + await guarded("globalThis.__jmsVerification.advanceLogin(false)"), + "waiting", + "a username-only form must not be mistaken for a completed login" + ); + } finally { + host.destroy(); + } +} + async function runCase(successSelector: string, challenge: string) { const server = createServer((_req, res) => { res.setHeader("content-type", "text/html"); diff --git a/electron/tests/web-proxy-script-check.ts b/electron/tests/web-proxy-script-check.ts index 431fd3a75..11962e0a8 100644 --- a/electron/tests/web-proxy-script-check.ts +++ b/electron/tests/web-proxy-script-check.ts @@ -331,7 +331,7 @@ async function loginCase(crossOrigin: boolean) { { steps: timeoutSteps, accessToken: "" }, { active: () => true, state: () => {}, interaction: () => {}, frame: () => {} } ); - await assert.rejects(timeout.run(), /超时/); + await assert.rejects(timeout.run(), /WebProxy\.ScriptStepTimeout/); const cancelled = new WebProxyScript( view.webContents, { steps: timeoutSteps, accessToken: "" }, diff --git a/electron/tests/web-proxy.test.ts b/electron/tests/web-proxy.test.ts index 292fc6a37..9c5ae0762 100644 --- a/electron/tests/web-proxy.test.ts +++ b/electron/tests/web-proxy.test.ts @@ -588,7 +588,7 @@ async function setupAutofill() { autofillProbeId: 0, autofillFailure: "", autofillVisibilityBlocked: true, - credentialSession: { origin: "https://example.test", selectors: { success: "" } }, + credentialSession: { origin: "https://example.test", selectors: { success: "", submit: "id=submit" } }, recording: { setPaused: mock.fn() } }; const release = mock.fn(async () => ({ username: "managed-user", password: "secret" })); @@ -755,6 +755,40 @@ test("discards credentials arriving after timeout and keeps recording paused", a assert.equal(managed.autofillVisibilityBlocked, true); }); +test("fill-only login releases the page without claiming submission or waiting for a success marker", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout", "setInterval"] }); + for (const [success, interactive] of [ + ["", ""], + ["id=success", ""], + ["", "id=mfa"], + ["id=success", "id=mfa"] + ]) { + const { api, managed, events, release, webContents } = await setupAutofill(); + managed.credentialSession.selectors = { + username: "id=username", + password: "id=password", + submit: "", + success, + interactive + }; + const dispose = mock.fn(); + managed.credentialSession.dispose = dispose; + api.startWebProxyAutofillWait(managed); + await api.tryWebProxyAutofill(managed); + assert.equal(managed.autofillPending, false); + assert.equal(managed.autofillVisibilityBlocked, false); + assert.equal(managed.view.setVisible.mock.calls.at(-1).arguments[0], true); + assert.equal(release.mock.callCount(), 1); + assert.equal(dispose.mock.callCount(), 1); + assert.equal(webContents.executeJavaScriptInIsolatedWorld.mock.callCount(), 0); + assert.ok(events.some(({ state }) => state.status === "filled")); + assert.ok(!events.some(({ state }) => state.status === "submitted" || state.status === "success")); + t.mock.timers.tick(60_000); + assert.equal(webContents.stop.mock.callCount(), 0); + assert.equal(managed.autofillFailure, ""); + } +}); + test("does not send a late preview to a closed session", async () => { const { api, managed, webContents, events, image, views } = await setupAutofill(); const capture = deferred(); @@ -794,6 +828,7 @@ test("decrypts the Koko-compatible one-time credential envelope", async () => { let clientPublicKey: ReturnType | undefined; let requestCount = 0; let proxyUrl = ""; + let loginSelectors: Record = {}; const server = createServer((request, response) => { requestCount += 1; const chunks = []; @@ -822,9 +857,7 @@ test("decrypts the Koko-compatible one-time credential envelope", async () => { target_url: "https://example.com/login", origin: "https://example.com", autofill_available: true, - username_selector: "id=username", - password_selector: "id=password", - submit_selector: "id=submit", + ...loginSelectors, server_public_key: serverPublicKey.export({ type: "spki", format: "der" }).toString("base64") }) ); @@ -853,13 +886,14 @@ test("decrypts the Koko-compatible one-time credential envelope", async () => { proxyUrl = `http://127.0.0.1:${port}`; try { - for (const [successSelector, interactiveSelector] of [ - ["", ""], - ["css=.dashboard", ""], - ["", "id=mfa"], - ["css=.dashboard", "id=mfa"] - ]) { + for (const [successSelector, interactiveSelector, selectors] of [ + ["", "", { username_selector: "id=username", password_selector: "id=password", submit_selector: "id=submit" }], + ["css=.dashboard", "", {}], + ["", "id=mfa", { username_selector: null, password_selector: null, submit_selector: null }], + ["css=.dashboard", "id=mfa", { username_selector: "", password_selector: "", submit_selector: "" }] + ] as const) { requestCount = 0; + loginSelectors = selectors; const session = await createCredentialSession( proxyUrl, "https://example.com/login", @@ -872,6 +906,8 @@ test("decrypts the Koko-compatible one-time credential envelope", async () => { assert.equal(session.sessionId, "62a7496e-369d-4f3d-b3f9-a20b61a33980"); assert.equal(session.selectors.success, successSelector); assert.equal(session.selectors.interactive, interactiveSelector); + for (const name of ["username", "password", "submit"]) + assert.equal(session.selectors[name], loginSelectors[`${name}_selector`] ?? ""); const credentials = await releaseCredentials(session, "https://example.com/login"); assert.deepEqual(credentials, { username: "managed-user", password: "managed-password" }); assert.equal(session.accessToken, ""); @@ -975,7 +1011,7 @@ test("imports replay archives into scoped, decompressed offline entries", async test("interactive verification replaces both automatic login deadlines without exposing the page", async (t) => { t.mock.timers.enable({ apis: ["setTimeout", "setInterval"] }); const { api, managed, webContents } = await setupAutofill(); - managed.credentialSession.selectors = { success: "id=success", interactive: "id=mfa" }; + managed.credentialSession.selectors = { success: "id=success", interactive: "id=mfa", submit: "id=submit" }; api.startWebProxyAutofillWait(managed); await api.tryWebProxyAutofill(managed); const interaction = managed.interaction; @@ -997,7 +1033,7 @@ test("interactive verification replaces both automatic login deadlines without e test("interactive verification is torn down before the final successful session is exposed", async () => { const { api, managed, webContents } = await setupAutofill(); - managed.credentialSession.selectors = { success: "id=success", interactive: "id=mfa" }; + managed.credentialSession.selectors = { success: "id=success", interactive: "id=mfa", submit: "id=submit" }; await api.tryWebProxyAutofill(managed); await Promise.resolve(); const interaction = managed.interaction; @@ -1022,7 +1058,7 @@ test("interactive verification is torn down before the final successful session for (const success of ["", "id=success"]) { test(`optional verification allows a normal login without any verification frame (success selector: ${success || "empty"})`, async () => { const { api, managed, events, webContents, release } = await setupAutofill(); - managed.credentialSession.selectors = { success, interactive: "id=mfa" }; + managed.credentialSession.selectors = { success, interactive: "id=mfa", submit: "id=submit" }; await api.tryWebProxyAutofill(managed); const interaction = managed.interaction; // Drain the initial asynchronous page probe before simulating submission. @@ -1044,7 +1080,7 @@ for (const success of ["", "id=success"]) { test(`verification appearing after submission pauses the same login (success selector: ${success || "empty"})`, async (t) => { t.mock.timers.enable({ apis: ["setTimeout", "setInterval"] }); const { api, managed, release } = await setupAutofill(); - managed.credentialSession.selectors = { success, interactive: "id=mfa" }; + managed.credentialSession.selectors = { success, interactive: "id=mfa", submit: "id=submit" }; await api.tryWebProxyAutofill(managed); await Promise.resolve(); const interaction = managed.interaction; @@ -1063,7 +1099,7 @@ for (const success of ["", "id=success"]) { test(`verification submits before continuing the session (success selector: ${success || "empty"})`, async () => { const { api, managed, events, webContents } = await setupAutofill(); - managed.credentialSession.selectors = { success, interactive: "id=mfa" }; + managed.credentialSession.selectors = { success, interactive: "id=mfa", submit: "id=submit" }; await api.tryWebProxyAutofill(managed); const interaction = managed.interaction; assert.equal(await api.completeWebProxyVerification(managed), false); @@ -1111,7 +1147,7 @@ for (const success of ["", "id=success"]) { test(`late manual completion does not unlock a timed-out page (success selector: ${success || "empty"})`, async (t) => { t.mock.timers.enable({ apis: ["setTimeout", "setInterval"] }); const { api, managed } = await setupAutofill(); - managed.credentialSession.selectors = { success, interactive: "id=mfa" }; + managed.credentialSession.selectors = { success, interactive: "id=mfa", submit: "id=submit" }; await api.tryWebProxyAutofill(managed); managed.verificationReady(); const cleared = deferred(); diff --git a/packages/web-proxy/src/WebProxySurface.vue b/packages/web-proxy/src/WebProxySurface.vue index be913e78c..d946c61a7 100644 --- a/packages/web-proxy/src/WebProxySurface.vue +++ b/packages/web-proxy/src/WebProxySurface.vue @@ -21,7 +21,7 @@ interface WebProxyState { interface WebProxyAutofillState { label: string; - status: "ready" | "filling" | "submitted" | "interactive" | "success" | "unavailable" | "error"; + status: "ready" | "filling" | "filled" | "submitted" | "interactive" | "success" | "unavailable" | "error"; message: string; } @@ -135,6 +135,8 @@ const autofillLabel = computed(() => { return t("WebProxy.AwaitingAutofill"); case "filling": return t("WebProxy.SecureLogin"); + case "filled": + return t("WebProxy.AutofillCompleted"); case "interactive": return t("WebProxy.AwaitingVerification"); case "submitted": diff --git a/packages/web-proxy/src/credentials.ts b/packages/web-proxy/src/credentials.ts index 6a7caf9d3..d36efb007 100644 --- a/packages/web-proxy/src/credentials.ts +++ b/packages/web-proxy/src/credentials.ts @@ -107,6 +107,10 @@ export function validateWebSelector(selector) { return selector; } +export function optionalWebSelector(selector) { + return selector == null || selector === "" ? "" : validateWebSelector(selector); +} + export async function createCredentialSession( proxyUrl, targetUrl, @@ -163,17 +167,11 @@ export async function createCredentialSession( ] : [targetOrigin]; - const usernameSelector = data.username_selector || ""; - if (usernameSelector) validateWebSelector(usernameSelector); - const passwordSelector = - mode === "script" ? "" : validateWebSelector(required(data.password_selector, "密码元素配置")); - const submitSelector = mode === "script" ? "" : validateWebSelector(required(data.submit_selector, "提交元素配置")); - const successSelector = configuredSuccessSelector || data.success_selector || ""; - if (successSelector) validateWebSelector(successSelector); - const interactiveSelector = configuredInteractiveSelector || data.interactive_selector || ""; - if (interactiveSelector) { - validateWebSelector(interactiveSelector); - } + const usernameSelector = optionalWebSelector(data.username_selector); + const passwordSelector = mode === "script" ? "" : optionalWebSelector(data.password_selector); + const submitSelector = mode === "script" ? "" : optionalWebSelector(data.submit_selector); + const successSelector = optionalWebSelector(configuredSuccessSelector || data.success_selector); + const interactiveSelector = optionalWebSelector(configuredInteractiveSelector || data.interactive_selector); const serverPublicKey = Buffer.from(required(data.server_public_key, "Web 公钥"), "base64"); if ( serverPublicKey.length !== X25519_SPKI_PREFIX.length + 32 || @@ -321,8 +319,8 @@ const elementsReady = () => { const password = findElement(selectors.password); const submit = findElement(selectors.submit); const usernameReady = !selectors.username || (username instanceof HTMLInputElement && ["text", "email", "tel"].includes(username.type) && !username.disabled && visible(username)); - const passwordReady = password instanceof HTMLInputElement && password.type === "password" && !password.disabled && visible(password); - const submitReady = (submit instanceof HTMLButtonElement || submit instanceof HTMLAnchorElement || (submit instanceof HTMLInputElement && ["submit", "button"].includes(submit.type))) && (selectors.interactive || (!submit.disabled && visible(submit))); + const passwordReady = !selectors.password || (password instanceof HTMLInputElement && password.type === "password" && !password.disabled && visible(password)); + const submitReady = !selectors.submit || ((submit instanceof HTMLButtonElement || submit instanceof HTMLAnchorElement || (submit instanceof HTMLInputElement && ["submit", "button"].includes(submit.type))) && (selectors.interactive || (!submit.disabled && visible(submit)))); return usernameReady && passwordReady && submitReady; }; let readySince = 0; @@ -350,8 +348,8 @@ check(); export function buildAutofillScript(selectors, credentials) { const payload = { - usernameValue: credentials.username, - passwordValue: credentials.password, + usernameValue: selectors.username ? credentials.username : "", + passwordValue: selectors.password ? credentials.password : "", usernameSelector: selectors.username, passwordSelector: selectors.password, submitSelector: selectors.submit, @@ -365,15 +363,16 @@ let payload = ${JSON.stringify(payload)}; const username = payload.usernameSelector ? findElement(payload.usernameSelector) : null; const password = findElement(payload.passwordSelector); const submit = findElement(payload.submitSelector); -if ((payload.usernameSelector && !(username instanceof HTMLInputElement)) || !(password instanceof HTMLInputElement) || !submit) { +if ((payload.usernameSelector && !(username instanceof HTMLInputElement)) || (payload.passwordSelector && !(password instanceof HTMLInputElement)) || (payload.submitSelector && !submit)) { payload.usernameValue = ""; payload.passwordValue = ""; return false; } const overlay = document.createElement("div"); +const manualSubmit = !payload.submitSelector; overlay.setAttribute("data-jms-secure-login", "true"); Object.assign(overlay.style, { position: "fixed", inset: "0", zIndex: "2147483647", cursor: "wait", background: "transparent" }); -if (!payload.interactiveSelector) document.documentElement.appendChild(overlay); +if (!manualSubmit && !payload.interactiveSelector) document.documentElement.appendChild(overlay); let internalAction = false; const blockedEvents = ["pointerdown", "pointerup", "mousedown", "mouseup", "click", "dblclick", "keydown", "keyup", "keypress", "touchstart", "touchend"]; const blocker = (event) => { @@ -381,7 +380,7 @@ const blocker = (event) => { event.preventDefault(); event.stopImmediatePropagation(); }; -if (!payload.interactiveSelector) for (const name of blockedEvents) document.addEventListener(name, blocker, true); +if (!manualSubmit && !payload.interactiveSelector) for (const name of blockedEvents) document.addEventListener(name, blocker, true); let cleanedUp = false; const cleanup = () => { if (cleanedUp) return; @@ -390,7 +389,7 @@ const cleanup = () => { for (const name of blockedEvents) document.removeEventListener(name, blocker, true); overlay.remove(); if (username?.isConnected) setValue(username, ""); - if (password.isConnected) setValue(password, ""); + if (password?.isConnected) setValue(password, ""); }; const setValue = (element, value) => { const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set; @@ -399,9 +398,11 @@ const setValue = (element, value) => { element.dispatchEvent(new Event("change", { bubbles: true })); }; if (username) setValue(username, payload.usernameValue); -setValue(password, payload.passwordValue); +if (password) setValue(password, payload.passwordValue); payload.usernameValue = ""; payload.passwordValue = ""; +// Fill-only configurations retain the inputs so the user can submit the page. +if (manualSubmit) return true; const successObserver = !payload.interactiveSelector && payload.successSelector ? new MutationObserver(() => { if (findElement(payload.successSelector)) cleanup(); }) : null; diff --git a/packages/web-proxy/src/interaction.ts b/packages/web-proxy/src/interaction.ts index 0c9c4e835..99216c5cc 100644 --- a/packages/web-proxy/src/interaction.ts +++ b/packages/web-proxy/src/interaction.ts @@ -103,7 +103,7 @@ globalThis.__jmsVerification = { if (shouldSubmit) return this.submit() ? 'submitted' : 'waiting'; // ponytail: without a success selector, infer completion from the login form // staying gone for 500 ms. Sites retaining it need an explicit success selector. - if (document.readyState !== 'complete' || visible(findElement(selectors.password)) || visible(findElement(selectors.submit))) { + if (document.readyState !== 'complete' || visible(findElement(selectors.username)) || visible(findElement(selectors.password)) || visible(findElement(selectors.submit))) { loginSettledAt = 0; return 'waiting'; } diff --git a/packages/web-proxy/src/local-credentials.ts b/packages/web-proxy/src/local-credentials.ts index 2065e57fd..8eb59a815 100644 --- a/packages/web-proxy/src/local-credentials.ts +++ b/packages/web-proxy/src/local-credentials.ts @@ -1,4 +1,4 @@ -import { normalizedWebOrigin, validateWebScript, validateWebSelector } from "./credentials"; +import { normalizedWebOrigin, validateWebScript, optionalWebSelector } from "./credentials"; // Used only in the main process. The shell renderer never receives this object. export function createLocalCredentialSession(targetUrl: string, value: unknown) { @@ -16,18 +16,20 @@ export function createLocalCredentialSession(targetUrl: string, value: unknown) const password = data.password ?? ""; if (typeof username !== "string" || typeof password !== "string" || username.length > 4096 || password.length > 65536) throw new Error("Tinker 凭据格式无效"); + const selectors = { + username: optionalWebSelector(config.username_selector), + password: optionalWebSelector(config.password_selector), + submit: optionalWebSelector(config.submit_selector), + success: optionalWebSelector(config.success_selector), + interactive: optionalWebSelector(config.interactive_selector) + }; // Same-name/manual accounts may have an empty password; preserve it for autofill. if ( - (mode === "basic" || steps?.some((step) => step.value.includes("{SECRET}"))) && + ((mode === "basic" && selectors.password) || steps?.some((step) => step.value.includes("{SECRET}"))) && data.secret_type && data.secret_type !== "password" ) throw new Error("登录配置需要密码账号"); - const selector = (key: string, required = false) => { - const value = config[key] ?? ""; - if (required || value) validateWebSelector(value); - return value; - }; const credentialOrigins = steps ? [ ...new Set( @@ -44,13 +46,7 @@ export function createLocalCredentialSession(targetUrl: string, value: unknown) mode, steps, credentialOrigins, - selectors: { - username: selector("username_selector"), - password: selector("password_selector", mode === "basic"), - submit: selector("submit_selector", mode === "basic"), - success: selector("success_selector"), - interactive: selector("interactive_selector") - }, + selectors, release() { if (!credentials) throw new Error("凭据已经领取或会话已结束"); const released = credentials; diff --git a/packages/web-proxy/src/locales/en.json b/packages/web-proxy/src/locales/en.json index fdcb6294a..035d35fc5 100644 --- a/packages/web-proxy/src/locales/en.json +++ b/packages/web-proxy/src/locales/en.json @@ -7,6 +7,8 @@ "SecureLogin": "Secure login in progress", "AwaitingVerification": "Waiting for manual verification", "LoginSubmitted": "Login submitted", + "AutofillCompleted": "Autofill completed", + "CredentialsFilledManualSubmit": "Autofill completed; continue on the page to sign in", "LoginSuccess": "Login successful", "AutofillNotConfigured": "Autofill not configured", "AutofillFailed": "Autofill failed", diff --git a/packages/web-proxy/src/locales/fr.json b/packages/web-proxy/src/locales/fr.json index 662d80462..98a3077fb 100644 --- a/packages/web-proxy/src/locales/fr.json +++ b/packages/web-proxy/src/locales/fr.json @@ -7,6 +7,8 @@ "SecureLogin": "Connexion sécurisée en cours", "AwaitingVerification": "En attente de vérification manuelle", "LoginSubmitted": "Connexion envoyée", + "AutofillCompleted": "Remplissage terminé", + "CredentialsFilledManualSubmit": "Remplissage terminé ; poursuivez la connexion sur la page", "LoginSuccess": "Connexion réussie", "AutofillNotConfigured": "Remplissage automatique non configuré", "AutofillFailed": "Échec du remplissage automatique", diff --git a/packages/web-proxy/src/locales/zh.json b/packages/web-proxy/src/locales/zh.json index 1bbc610e4..fa45d1d5d 100644 --- a/packages/web-proxy/src/locales/zh.json +++ b/packages/web-proxy/src/locales/zh.json @@ -7,6 +7,8 @@ "SecureLogin": "安全登录中", "AwaitingVerification": "等待人工验证", "LoginSubmitted": "已提交登录", + "AutofillCompleted": "代填完成", + "CredentialsFilledManualSubmit": "代填完成,请在页面中继续登录", "LoginSuccess": "登录成功", "AutofillNotConfigured": "未配置代填", "AutofillFailed": "代填失败", diff --git a/packages/web-proxy/src/manager.ts b/packages/web-proxy/src/manager.ts index 490248c5a..9a7b27c70 100644 --- a/packages/web-proxy/src/manager.ts +++ b/packages/web-proxy/src/manager.ts @@ -377,14 +377,16 @@ export function createWebProxyManager({ emitWebProxyAutofillState( managed, "filling", - session.selectors.interactive ? "WebProxy.FillingCredentials" : "WebProxy.FillingAndSubmitting" + session.selectors.interactive || !session.selectors.submit + ? "WebProxy.FillingCredentials" + : "WebProxy.FillingAndSubmitting" ); if (managed.recording?.capturePending) await managed.recording.capturePending.catch(() => undefined); let credentials; try { if (!isCurrent()) return; - if (session.selectors.interactive) { + if (session.selectors.interactive && session.selectors.submit) { // Retain the original credential nodes before the site's submit handler can // rename or replace them. User input is still blocked by the hidden view. await managed.view.webContents.executeJavaScriptInIsolatedWorld(INTERACTION_WORLD, [ @@ -402,6 +404,10 @@ export function createWebProxyManager({ const filled = await managed.view.webContents.executeJavaScript(script, true); if (!isCurrent()) return; if (!filled) throw new Error("WebProxy.LoginElementsChanged"); + if (!session.selectors.submit) { + finishWebProxyAutofill(managed, "filled", "WebProxy.CredentialsFilledManualSubmit"); + return; + } if (!session.selectors.success && !session.selectors.interactive) { finishWebProxyAutofill(managed, "submitted", "WebProxy.CredentialsFilledLoginSubmitted"); return;