Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit bd83037

Browse files
committed
feat(browser): add browser discovery for main browser flow
1 parent 9593d59 commit bd83037

10 files changed

Lines changed: 400 additions & 60 deletions

File tree

src/browser/detect.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { existsSync } from 'node:fs'
2+
import type { BrowserDiscovery, BrowserDiscoveryEntry, BrowserDiscoveryKind } from './types.ts'
3+
4+
type CandidateDefinition = {
5+
id: string
6+
name: string
7+
kind: BrowserDiscoveryKind
8+
darwin: string[]
9+
win32: string[]
10+
linux: string[]
11+
}
12+
13+
const CANDIDATES: CandidateDefinition[] = [
14+
{
15+
id: 'chrome',
16+
name: 'Google Chrome',
17+
kind: 'chrome',
18+
darwin: ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'],
19+
win32: [
20+
'%LOCALAPPDATA%\\Google\\Chrome\\Application\\chrome.exe',
21+
'%ProgramFiles%\\Google\\Chrome\\Application\\chrome.exe',
22+
'%ProgramFiles(x86)%\\Google\\Chrome\\Application\\chrome.exe',
23+
],
24+
linux: ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable'],
25+
},
26+
{
27+
id: 'edge',
28+
name: 'Microsoft Edge',
29+
kind: 'edge',
30+
darwin: ['/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'],
31+
win32: [
32+
'%LOCALAPPDATA%\\Microsoft\\Edge\\Application\\msedge.exe',
33+
'%ProgramFiles%\\Microsoft\\Edge\\Application\\msedge.exe',
34+
'%ProgramFiles(x86)%\\Microsoft\\Edge\\Application\\msedge.exe',
35+
],
36+
linux: ['/usr/bin/microsoft-edge', '/usr/bin/microsoft-edge-stable'],
37+
},
38+
{
39+
id: 'brave',
40+
name: 'Brave',
41+
kind: 'brave',
42+
darwin: ['/Applications/Brave Browser.app/Contents/MacOS/Brave Browser'],
43+
win32: [
44+
'%LOCALAPPDATA%\\BraveSoftware\\Brave-Browser\\Application\\brave.exe',
45+
'%ProgramFiles%\\BraveSoftware\\Brave-Browser\\Application\\brave.exe',
46+
'%ProgramFiles(x86)%\\BraveSoftware\\Brave-Browser\\Application\\brave.exe',
47+
],
48+
linux: ['/usr/bin/brave-browser', '/usr/bin/brave-browser-stable'],
49+
},
50+
{
51+
id: 'chromium',
52+
name: 'Chromium',
53+
kind: 'chromium',
54+
darwin: ['/Applications/Chromium.app/Contents/MacOS/Chromium'],
55+
win32: [
56+
'%LOCALAPPDATA%\\Chromium\\Application\\chrome.exe',
57+
'%ProgramFiles%\\Chromium\\Application\\chrome.exe',
58+
'%ProgramFiles(x86)%\\Chromium\\Application\\chrome.exe',
59+
],
60+
linux: ['/usr/bin/chromium', '/usr/bin/chromium-browser', '/snap/bin/chromium'],
61+
},
62+
{
63+
id: 'vivaldi',
64+
name: 'Vivaldi',
65+
kind: 'vivaldi',
66+
darwin: ['/Applications/Vivaldi.app/Contents/MacOS/Vivaldi'],
67+
win32: [
68+
'%LOCALAPPDATA%\\Vivaldi\\Application\\vivaldi.exe',
69+
'%ProgramFiles%\\Vivaldi\\Application\\vivaldi.exe',
70+
'%ProgramFiles(x86)%\\Vivaldi\\Application\\vivaldi.exe',
71+
],
72+
linux: ['/usr/bin/vivaldi', '/usr/bin/vivaldi-stable'],
73+
},
74+
{
75+
id: 'arc',
76+
name: 'Arc',
77+
kind: 'arc',
78+
darwin: ['/Applications/Arc.app/Contents/MacOS/Arc'],
79+
win32: [],
80+
linux: [],
81+
},
82+
]
83+
84+
function expandWindowsPath(rawPath: string, env: NodeJS.ProcessEnv): string {
85+
return rawPath.replace(/%([^%]+)%/g, (_, key: string) => env[key] ?? '')
86+
}
87+
88+
function getCandidatePaths(
89+
candidate: CandidateDefinition,
90+
platform: NodeJS.Platform,
91+
env: NodeJS.ProcessEnv,
92+
): string[] {
93+
switch (platform) {
94+
case 'darwin':
95+
return candidate.darwin
96+
case 'win32':
97+
return candidate.win32.map((entry) => expandWindowsPath(entry, env))
98+
default:
99+
return candidate.linux
100+
}
101+
}
102+
103+
function guessRecommendedBrowserId(
104+
browsers: BrowserDiscoveryEntry[],
105+
env: NodeJS.ProcessEnv,
106+
): { recommendedBrowserId: string | null; recommendationSource: BrowserDiscovery['recommendationSource'] } {
107+
if (browsers.length === 0) {
108+
return { recommendedBrowserId: null, recommendationSource: 'none' }
109+
}
110+
111+
const browserHint = (env.BROWSER ?? '').toLowerCase()
112+
if (browserHint) {
113+
const hinted = browsers.find((browser) =>
114+
browserHint.includes(browser.id) ||
115+
browserHint.includes(browser.kind) ||
116+
browserHint.includes(browser.name.toLowerCase()),
117+
)
118+
if (hinted) {
119+
return { recommendedBrowserId: hinted.id, recommendationSource: 'env' }
120+
}
121+
}
122+
123+
return { recommendedBrowserId: browsers[0]!.id, recommendationSource: 'priority' }
124+
}
125+
126+
export function detectInstalledBrowsers(params?: {
127+
platform?: NodeJS.Platform
128+
env?: NodeJS.ProcessEnv
129+
exists?: (path: string) => boolean
130+
}): BrowserDiscovery {
131+
const platform = params?.platform ?? process.platform
132+
const env = params?.env ?? process.env
133+
const exists = params?.exists ?? existsSync
134+
135+
const browsers: BrowserDiscoveryEntry[] = []
136+
137+
for (const candidate of CANDIDATES) {
138+
const executablePath = getCandidatePaths(candidate, platform, env).find((entry) => entry && exists(entry))
139+
if (!executablePath) continue
140+
141+
browsers.push({
142+
id: candidate.id,
143+
name: candidate.name,
144+
kind: candidate.kind,
145+
executablePath,
146+
isRecommended: false,
147+
})
148+
}
149+
150+
const recommendation = guessRecommendedBrowserId(browsers, env)
151+
const nextBrowsers = browsers.map((browser) => ({
152+
...browser,
153+
isRecommended: browser.id === recommendation.recommendedBrowserId,
154+
}))
155+
156+
return {
157+
browsers: nextBrowsers,
158+
recommendedBrowserId: recommendation.recommendedBrowserId,
159+
recommendationSource: recommendation.recommendationSource,
160+
}
161+
}

src/browser/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export { BrowserManager } from './manager.ts'
22
export { createBrowserRoutes } from './routes.ts'
33
export { createBrowserMcpServer, logBrowserToolRegistration } from './mcp.ts'
44
export { createBrowserActionRouter } from './router.ts'
5+
export { detectInstalledBrowsers } from './detect.ts'
56
export { disconnectAllBrowserSessions } from './pw-session.ts'
67
export {
78
createBrowserProfile,
@@ -21,6 +22,9 @@ export {
2122
DEFAULT_BROWSER_PROFILE_NAME,
2223
} from './types.ts'
2324
export type {
25+
BrowserDiscovery,
26+
BrowserDiscoveryEntry,
27+
BrowserDiscoveryKind,
2428
BrowserDriver,
2529
BrowserProfile,
2630
BrowserRefAction,

src/browser/routes.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Hono } from 'hono'
22
import { z } from 'zod/v4'
33
import type { AgentManager } from '../agent/index.ts'
44
import type { BrowserManager } from './manager.ts'
5+
import { detectInstalledBrowsers } from './detect.ts'
56
import { BrowserRelayTokenError } from './relay.ts'
67

78
const CreateProfileSchema = z.object({
@@ -43,6 +44,11 @@ function routeErrorStatus(err: unknown): 400 | 401 | 404 | 500 {
4344

4445
export function createBrowserRoutes(browserManager: BrowserManager, _agentManager?: AgentManager) {
4546
const app = new Hono()
47+
void _agentManager
48+
49+
app.get('/browser/discovery', (c) => {
50+
return c.json(detectInstalledBrowsers())
51+
})
4652

4753
app.get('/browser/profiles', (c) => {
4854
return c.json(browserManager.listProfiles())

src/browser/types.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export type BrowserDriver = 'managed' | 'remote-cdp' | 'extension-relay'
22
export type BrowserTarget = 'host' | 'sandbox'
33
export type BrowserRefAction = 'click' | 'type' | 'select' | 'check' | 'uncheck'
4+
export type BrowserDiscoveryKind = 'chrome' | 'edge' | 'brave' | 'chromium' | 'vivaldi' | 'arc'
45

56
export type BrowserRuntimeStatus = 'starting' | 'running' | 'stopped' | 'error'
67

@@ -83,6 +84,20 @@ export interface ChatBrowserState {
8384
updatedAt: string
8485
}
8586

87+
export interface BrowserDiscoveryEntry {
88+
id: string
89+
name: string
90+
kind: BrowserDiscoveryKind
91+
executablePath: string
92+
isRecommended: boolean
93+
}
94+
95+
export interface BrowserDiscovery {
96+
browsers: BrowserDiscoveryEntry[]
97+
recommendedBrowserId: string | null
98+
recommendationSource: 'env' | 'priority' | 'none'
99+
}
100+
86101
export interface CreateBrowserProfileInput {
87102
id?: string
88103
name: string
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { describe, expect, test } from 'bun:test'
2+
import { readFileSync } from 'node:fs'
3+
import path from 'node:path'
4+
5+
const repoRoot = process.cwd()
6+
7+
function read(relativePath: string) {
8+
return readFileSync(path.join(repoRoot, relativePath), 'utf8')
9+
}
10+
11+
describe('browser discovery wiring', () => {
12+
test('browser routes expose a discovery endpoint', () => {
13+
const routes = read('src/browser/routes.ts')
14+
15+
expect(routes).toContain("app.get('/browser/discovery'")
16+
expect(routes).toContain('detectInstalledBrowsers()')
17+
})
18+
19+
test('browser client and UI expose the main browser advanced flow', () => {
20+
const client = read('web/src/api/client.ts')
21+
const page = read('web/src/pages/BrowserProfiles.tsx')
22+
const en = read('web/src/i18n/en.ts')
23+
24+
expect(client).toContain('export async function getBrowserDiscovery()')
25+
expect(page).toContain('getBrowserDiscovery().then(setBrowserDiscovery)')
26+
expect(page).toContain('Main Browser (Advanced)')
27+
expect(page).toContain('showAdvancedRelay')
28+
expect(en).toContain("relayTitle: 'Main Browser (Advanced)'")
29+
})
30+
})

tests/browser-discovery.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { describe, expect, test } from 'bun:test'
2+
import { detectInstalledBrowsers } from '../src/browser/detect.ts'
3+
4+
describe('browser discovery', () => {
5+
test('marks BROWSER env match as the recommended browser', () => {
6+
const discovery = detectInstalledBrowsers({
7+
platform: 'linux',
8+
env: { BROWSER: 'brave-browser' },
9+
exists: (path) => ['/usr/bin/brave-browser', '/usr/bin/google-chrome'].includes(path),
10+
})
11+
12+
expect(discovery.recommendationSource).toBe('env')
13+
expect(discovery.recommendedBrowserId).toBe('brave')
14+
expect(discovery.browsers.find((browser) => browser.id === 'brave')?.isRecommended).toBe(true)
15+
})
16+
17+
test('falls back to priority ordering when no env hint is available', () => {
18+
const discovery = detectInstalledBrowsers({
19+
platform: 'darwin',
20+
env: {},
21+
exists: (path) => [
22+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
23+
'/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
24+
].includes(path),
25+
})
26+
27+
expect(discovery.recommendationSource).toBe('priority')
28+
expect(discovery.recommendedBrowserId).toBe('chrome')
29+
expect(discovery.browsers).toHaveLength(2)
30+
})
31+
})

web/src/api/client.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -868,10 +868,28 @@ export interface BrowserRelayDTO {
868868
updatedAt: string | null
869869
}
870870

871+
export interface BrowserDiscoveryEntryDTO {
872+
id: string
873+
name: string
874+
kind: 'chrome' | 'edge' | 'brave' | 'chromium' | 'vivaldi' | 'arc'
875+
executablePath: string
876+
isRecommended: boolean
877+
}
878+
879+
export interface BrowserDiscoveryDTO {
880+
browsers: BrowserDiscoveryEntryDTO[]
881+
recommendedBrowserId: string | null
882+
recommendationSource: 'env' | 'priority' | 'none'
883+
}
884+
871885
export async function getBrowserProfiles() {
872886
return apiFetch<BrowserProfileDTO[]>('/api/browser/profiles')
873887
}
874888

889+
export async function getBrowserDiscovery() {
890+
return apiFetch<BrowserDiscoveryDTO>('/api/browser/discovery')
891+
}
892+
875893
export async function createBrowserProfile(input: { name: string; driver?: 'managed' | 'remote-cdp' | 'extension-relay'; cdpUrl?: string | null }) {
876894
return apiFetch<BrowserProfileDTO>('/api/browser/profiles', {
877895
method: 'POST',

web/src/i18n/en.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -812,13 +812,19 @@ export const en = {
812812
managedBody: 'Recommended for most users. YouClaw launches an isolated browser window and stores login state in the profile data directory.',
813813
remoteTitle: 'Remote CDP',
814814
remoteBody: 'Advanced option for an existing browser automation setup that already exposes a CDP endpoint.',
815-
relayTitle: 'Extension Relay',
816-
relayBody: 'Advanced local attach mode. Today it securely attaches to a loopback CDP URL on the same machine, not directly to an ordinary browser window.',
817-
relayStepsTitle: 'To use Extension Relay',
818-
relayStep1: 'Start a local Chrome or Chromium instance with remote debugging enabled.',
819-
relayStep2: 'Verify http://127.0.0.1:9222/json/version returns a CDP response.',
820-
relayStep3: 'Create an Extension Relay profile, copy the token, then attach the loopback CDP URL.',
821-
relayWarning: 'If you do not already know what a CDP URL is, use Managed Chromium instead.',
815+
relayTitle: 'Main Browser (Advanced)',
816+
relayBody: 'Advanced mode for connecting an existing browser session. Today it uses a secure local relay flow and still relies on a loopback CDP endpoint under the hood.',
817+
relayStepsTitle: 'Current Main Browser flow',
818+
relayStep1: 'Choose a detected Chromium-based browser from this machine.',
819+
relayStep2: 'For now, connecting an existing browser still requires a loopback CDP endpoint and advanced setup.',
820+
relayStep3: 'Use the advanced section only if you intentionally want to attach an existing browser session.',
821+
relayWarning: 'If you want the simplest setup, use Managed Chromium instead.',
822+
detectedBrowsersTitle: 'Detected Browsers',
823+
detectedBrowsersEmpty: 'No supported Chromium-based browser was detected automatically.',
824+
detectedBrowsersHint: 'These browsers are good candidates for a future main-browser bridge flow.',
825+
detectedRecommended: 'Recommended',
826+
relayAdvancedTitle: 'Advanced Connection',
827+
relayAdvancedBody: 'Use this only if you already have a local loopback CDP endpoint and want to attach an existing browser session manually.',
822828
},
823829
channels: {
824830
title: 'Channels',

web/src/i18n/zh.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -814,13 +814,19 @@ export const zh: Translations = {
814814
managedBody: '适合大多数用户。YouClaw 会启动一个隔离浏览器窗口,并把登录状态保存在这个 Profile 的数据目录中。',
815815
remoteTitle: 'Remote CDP',
816816
remoteBody: '高级选项。适合你已经有一个会暴露 CDP 端点的浏览器自动化环境。',
817-
relayTitle: 'Extension Relay',
818-
relayBody: '高级本机附着模式。当前实现是安全附着到同机 loopback CDP URL,而不是直接接管一个普通浏览器窗口。',
819-
relayStepsTitle: '使用 Extension Relay 之前',
820-
relayStep1: '先启动一个开启 remote debugging 的本机 Chrome 或 Chromium。',
821-
relayStep2: '确认 http://127.0.0.1:9222/json/version 能返回 CDP 信息。',
822-
relayStep3: '创建 Extension Relay Profile,复制 token,然后附着这个 loopback CDP URL。',
823-
relayWarning: '如果你不知道什么是 CDP URL,请直接使用 Managed Chromium。',
817+
relayTitle: 'Main Browser(高级)',
818+
relayBody: '用于连接一个已经存在的浏览器会话。当前仍然走安全的本机 relay 流程,底层依赖 loopback CDP 端点。',
819+
relayStepsTitle: '当前 Main Browser 流程',
820+
relayStep1: '先从本机自动检测到的 Chromium 系浏览器中选择一个目标浏览器。',
821+
relayStep2: '当前版本下,连接已有浏览器仍然需要 loopback CDP 端点和高级配置。',
822+
relayStep3: '只有在你明确想手动附着一个现有浏览器会话时,才使用高级区域。',
823+
relayWarning: '如果你想用最简单的方式,请直接使用 Managed Chromium。',
824+
detectedBrowsersTitle: '检测到的浏览器',
825+
detectedBrowsersEmpty: '当前没有自动检测到受支持的 Chromium 系浏览器。',
826+
detectedBrowsersHint: '这些浏览器都是未来主浏览器桥接流程的候选目标。',
827+
detectedRecommended: '推荐',
828+
relayAdvancedTitle: '高级连接',
829+
relayAdvancedBody: '只有在你已经具备本机 loopback CDP 端点,并且明确要手动附着现有浏览器会话时,才使用这里。',
824830
},
825831
channels: {
826832
title: '渠道',

0 commit comments

Comments
 (0)