Skip to content

Commit 65a9a0c

Browse files
committed
improvement(desktop): make saved password autofill field-aware
1 parent 4868a9a commit 65a9a0c

29 files changed

Lines changed: 1953 additions & 566 deletions

File tree

‎apps/desktop/e2e/password-autofill.spec.ts‎

Lines changed: 429 additions & 0 deletions
Large diffs are not rendered by default.

‎apps/desktop/scripts/build.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ const renderer: BuildOptions = {
102102
server: 'src/renderer/server/index.tsx',
103103
offline: 'src/renderer/offline/index.tsx',
104104
dialog: 'src/renderer/dialog/index.tsx',
105+
'credential-picker': 'src/renderer/credential-picker/index.tsx',
105106
},
106107
outdir: 'dist/renderer',
107108
bundle: true,
@@ -146,6 +147,11 @@ async function run(): Promise<void> {
146147
entryPoints: ['src/preload/shell.ts'],
147148
outfile: 'dist/shell-preload.cjs',
148149
})
150+
const credentialPickerPreloadCtx = await context({
151+
...common,
152+
entryPoints: ['src/preload/credential-picker.ts'],
153+
outfile: 'dist/credential-picker-preload.cjs',
154+
})
149155
const mainCtx = await context({
150156
...common,
151157
entryPoints: ['src/main/index.ts'],
@@ -169,11 +175,17 @@ async function run(): Promise<void> {
169175
browserPreloadCtx.watch(),
170176
rendererCtx.watch(),
171177
shellPreloadCtx.watch(),
178+
credentialPickerPreloadCtx.watch(),
172179
])
173180
return
174181
}
175182
await Promise.all([
176183
build(renderer),
184+
build({
185+
...common,
186+
entryPoints: ['src/preload/credential-picker.ts'],
187+
outfile: 'dist/credential-picker-preload.cjs',
188+
}),
177189
build({ ...common, entryPoints: ['src/preload/shell.ts'], outfile: 'dist/shell-preload.cjs' }),
178190
build({ ...common, entryPoints: ['src/main/index.ts'], outfile: 'dist/main.cjs' }),
179191
build({ ...common, entryPoints: ['src/preload/index.ts'], outfile: 'dist/preload.cjs' }),

‎apps/desktop/src/main/browser-agent/driver.test.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1116,7 +1116,14 @@ describe('executeTool', () => {
11161116
| MenuItemConstructorOptions[]
11171117
| undefined
11181118
const labels = template?.filter((item) => item.type !== 'separator').map((item) => item.label)
1119-
expect(labels).toEqual(['Find in Page', 'Zoom (110%)', 'Import Passwords', 'Browser Settings'])
1119+
expect(labels).toEqual([
1120+
'Find in Page',
1121+
'Zoom (110%)',
1122+
'Fill Saved Password',
1123+
'Passwords',
1124+
'Import Passwords',
1125+
'Browser Settings',
1126+
])
11201127

11211128
const settings = template?.find((item) => item.label === 'Browser Settings')
11221129
const openSettings = settings?.click as (() => void) | undefined

‎apps/desktop/src/main/browser-agent/driver.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,13 @@ import {
6969
setFocusedInputValue,
7070
typeIntoElement,
7171
} from '@/main/browser-agent/page-functions'
72+
import { isPanelVisible, panelWindow } from '@/main/browser-agent/panel'
7273
import { withPostActionObservation } from '@/main/browser-agent/post-action-observation'
7374
import * as session from '@/main/browser-agent/session'
7475
import { checkAgentUrl } from '@/main/browser-agent/url-guard'
7576
import { clearCredentials, fillCoordinator, initFillCoordinator } from '@/main/browser-credentials'
7677
import type { ConfigStore } from '@/main/config'
78+
import { trackInputActivity } from '@/main/input-activity'
7779

7880
const logger = createLogger('BrowserAgentDriver')
7981

@@ -506,6 +508,7 @@ function pushTabsState(): void {
506508

507509
/** Instruments a fresh tab: CDP dialog handling + page-state pushes. */
508510
function instrumentTab(contents: WebContents): void {
511+
trackInputActivity(contents)
509512
const scopeId = session.browserScopeIdForContents(contents) ?? session.getBrowserScopeId()
510513
const inScope =
511514
<Args extends unknown[]>(fn: (...args: Args) => void) =>
@@ -625,6 +628,35 @@ export function initDriver(
625628
// leaving the old chain head in place would queue the new session's first
626629
// tool call behind a promise nothing can ever settle.
627630
initFillCoordinator({
631+
pickerHost: (contents, bounds) => {
632+
const scopeId = session.getActiveBrowserScopeId()
633+
const window = panelWindow()
634+
if (!scopeId || !window || window.isDestroyed() || !window.isVisible() || !isPanelVisible())
635+
return null
636+
return session.withBrowserScope(scopeId, () => {
637+
const tab = session.activeTab()
638+
if (!tab || tab.view.webContents !== contents || !tab.view.getVisible()) return null
639+
const panel = tab.view.getBounds()
640+
const content = window.getContentBounds()
641+
const zoom = contents.getZoomFactor()
642+
if (
643+
bounds.x + bounds.width <= 0 ||
644+
bounds.y + bounds.height <= 0 ||
645+
bounds.x * zoom >= panel.width ||
646+
bounds.y * zoom >= panel.height
647+
)
648+
return null
649+
return {
650+
window,
651+
anchor: {
652+
x: content.x + panel.x + bounds.x * zoom,
653+
y: content.y + panel.y + bounds.y * zoom,
654+
width: bounds.width * zoom,
655+
height: bounds.height * zoom,
656+
},
657+
}
658+
})
659+
},
628660
getActiveContents: (scopeId) => {
629661
const activeScopeId = session.getActiveBrowserScopeId()
630662
if (!activeScopeId) return null
@@ -646,6 +678,7 @@ export function initDriver(
646678
})
647679
session.initSession(
648680
{
681+
onPanelGeometryChanged: () => fillCoordinator()?.dismissPicker(),
649682
onSessionClosed: () => {
650683
driverCallbacks?.onSessionStatus(false, session.getBrowserScopeId())
651684
},
@@ -748,6 +781,14 @@ export function showToolbarMenu(
748781
],
749782
},
750783
{ type: 'separator' },
784+
{
785+
label: 'Fill Saved Password',
786+
enabled: pageAvailable,
787+
click: () => {
788+
void fillCoordinator()?.showChooser(ownerWindow, anchor, resolved)
789+
},
790+
},
791+
{ label: 'Passwords', click: () => sendCommand('passwords') },
751792
{ label: 'Import Passwords', click: () => sendCommand('import') },
752793
{ type: 'separator' },
753794
{ label: 'Browser Settings', click: () => sendCommand('browser-settings') },

‎apps/desktop/src/main/browser-agent/panel.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ export interface PanelHost {
4545
restoreActiveScope: () => void
4646
/** Lets the session drop focus tracking for a view that is no longer attached. */
4747
onViewDetached: (view: WebContentsView | null) => void
48+
/** Invalidates field-anchored UI when the page moves, hides, or detaches. */
49+
onGeometryChanged?: () => void
4850
}
4951

5052
let host: PanelHost = {
@@ -292,6 +294,7 @@ function detachAttachedView(): void {
292294
occludableFrame = null
293295
unbindHostResize()
294296
host.onViewDetached(view)
297+
host.onGeometryChanged?.()
295298

296299
if (!view || !win) return
297300
try {
@@ -428,11 +431,13 @@ export function layout(): void {
428431
lastAppliedBounds = boundsKey
429432
occludableFrame = null
430433
active.view.setBounds(bounds)
434+
host.onGeometryChanged?.()
431435
}
432436
const visible = !panelOccluded
433437
if (lastAppliedVisibility !== visible) {
434438
lastAppliedVisibility = visible
435439
active.view.setVisible(visible)
440+
host.onGeometryChanged?.()
436441
if (visible && !active.view.webContents.isDestroyed()) {
437442
// invalidate() recomposites the LAST frame — which is blank when the
438443
// page finished loading while this view was hidden and background

‎apps/desktop/src/main/browser-agent/session.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4014,8 +4014,9 @@ describe('browser-agent session', () => {
40144014
const retainedDownload = mockDownloadItem({ filename: 'retained.bin', totalBytes: 100 })
40154015

40164016
startMockDownload(suspendedContents, suspendedDownload)
4017-
startMockDownload(retainedContents, retainedDownload)
4017+
await vi.waitFor(() => expect(getFreeDiskBytes).toHaveBeenCalledOnce())
40184018
await vi.waitFor(() => expect(suspendedDownload.item.setSavePath).toHaveBeenCalledOnce())
4019+
startMockDownload(retainedContents, retainedDownload)
40194020
await vi.waitFor(() => expect(retainedDownload.item.resume).toHaveBeenCalledOnce())
40204021
onDownloadsChanged.mockClear()
40214022

‎apps/desktop/src/main/browser-agent/session.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,8 @@ export interface BrowserDownloadSettings {
139139
}
140140

141141
export interface AgentSessionEvents {
142+
/** The native page moved or its visibility changed. */
143+
onPanelGeometryChanged?: () => void
142144
/** The browser session ended (all tabs gone). */
143145
onSessionClosed: () => void
144146
/** A newly created tab's WebContents, for the driver to instrument. */
@@ -859,6 +861,7 @@ export function initSession(
859861
browserSessionPersistence = persistence ?? null
860862
browserDownloadSettings = downloadSettings ?? null
861863
initPanel({
864+
onGeometryChanged: () => events?.onPanelGeometryChanged?.(),
862865
getMainWindow: () => getMainWindow(),
863866
activeTab: () => {
864867
const scopeId = getActiveBrowserScopeId()

0 commit comments

Comments
 (0)