Skip to content

Commit 9b22924

Browse files
committed
feat(desktop): suggest imported browser sites
1 parent 7ef8ae7 commit 9b22924

18 files changed

Lines changed: 1300 additions & 28 deletions

File tree

apps/desktop/src/main/browser-credentials/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { app, clipboard } from 'electron'
77
import { FillCoordinator, type FillCoordinatorDeps } from '@/main/browser-credentials/fill'
88
import { authorizeForSecret, revokeSecretAuthorization } from '@/main/browser-credentials/os-auth'
99
import { CredentialVault } from '@/main/browser-credentials/vault'
10+
import { clearSites } from '@/main/browser-sites'
1011

1112
/** How long a copied password may sit on the clipboard before Sim clears it. */
1213
const CLIPBOARD_CLEAR_MS = 30_000
@@ -125,6 +126,7 @@ export async function copyCredential(id: string): Promise<boolean> {
125126
*/
126127
export async function clearCredentials(): Promise<void> {
127128
await credentialVault().clear()
129+
await clearSites()
128130
revokeSecretAuthorization()
129131
await coordinatorInstance?.refreshAvailability()
130132
}

apps/desktop/src/main/browser-import/chromium-profiles.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ const COOKIE_DB_RELATIVE_PATHS = [join('Network', 'Cookies'), 'Cookies']
2424
const LOGIN_DB_RELATIVE_PATHS = ['Login Data']
2525
/** Site icons, used to give saved passwords a recognisable face. */
2626
const FAVICON_DB_RELATIVE_PATHS = ['Favicons']
27+
/** Page titles, read only to learn what the imported sites are called. */
28+
const HISTORY_DB_RELATIVE_PATHS = ['History']
2729
/** Internal profiles that never hold a user's browsing session. */
2830
const IGNORED_PROFILE_DIRS = new Set(['System Profile', 'Guest Profile'])
2931
const PROFILE_DIR_PATTERN = /^(Default|Profile \d{1,4})$/
@@ -141,6 +143,7 @@ export async function listBrowserProfiles(
141143
const cookiesPath = await resolveFirstReadable(profileDir, COOKIE_DB_RELATIVE_PATHS)
142144
const loginDataPath = await resolveFirstReadable(profileDir, LOGIN_DB_RELATIVE_PATHS)
143145
const faviconsPath = await resolveFirstReadable(profileDir, FAVICON_DB_RELATIVE_PATHS)
146+
const historyPath = await resolveFirstReadable(profileDir, HISTORY_DB_RELATIVE_PATHS)
144147
if (cookiesPath === null && loginDataPath === null) continue
145148
profiles.push({
146149
id: formatProfileId(source.id, dir),
@@ -152,6 +155,7 @@ export async function listBrowserProfiles(
152155
cookiesPath,
153156
loginDataPath,
154157
faviconsPath,
158+
historyPath,
155159
})
156160
}
157161
return profiles
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { mkdtemp, rm } from 'node:fs/promises'
2+
import { tmpdir } from 'node:os'
3+
import { join } from 'node:path'
4+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
5+
import { readBrowserSiteNames } from '@/main/browser-import/chromium-site-names'
6+
7+
const sqliteAvailable = await import('node:sqlite').then(
8+
() => true,
9+
() => false
10+
)
11+
12+
interface FixturePage {
13+
url: string
14+
title: string
15+
visitCount?: number
16+
}
17+
18+
let directory: string
19+
20+
beforeEach(async () => {
21+
directory = await mkdtemp(join(tmpdir(), 'sim-site-names-test-'))
22+
})
23+
24+
afterEach(async () => {
25+
await rm(directory, { recursive: true, force: true })
26+
})
27+
28+
async function writeHistoryDatabase(pages: FixturePage[]): Promise<string> {
29+
const { DatabaseSync } = await import('node:sqlite')
30+
const path = join(directory, 'History')
31+
const database = new DatabaseSync(path)
32+
database.exec(`
33+
CREATE TABLE urls (
34+
id INTEGER PRIMARY KEY AUTOINCREMENT,
35+
url LONGVARCHAR,
36+
title LONGVARCHAR,
37+
visit_count INTEGER DEFAULT 0 NOT NULL,
38+
typed_count INTEGER DEFAULT 0 NOT NULL,
39+
last_visit_time INTEGER NOT NULL,
40+
hidden INTEGER DEFAULT 0 NOT NULL
41+
);
42+
`)
43+
const insert = database.prepare(
44+
'INSERT INTO urls (url, title, visit_count, last_visit_time) VALUES (?, ?, ?, 0)'
45+
)
46+
for (const page of pages) insert.run(page.url, page.title, page.visitCount ?? 1)
47+
database.close()
48+
return path
49+
}
50+
51+
describe.skipIf(!sqliteAvailable)('readBrowserSiteNames', () => {
52+
it('learns a site’s name from the part of its titles that never changes', async () => {
53+
const path = await writeHistoryDatabase([
54+
{
55+
url: 'https://mail.google.com/mail/u/0/#inbox',
56+
title: 'Inbox (12) - ada@example.com - Gmail',
57+
},
58+
{ url: 'https://mail.google.com/mail/u/0/#sent', title: 'Sent - ada@example.com - Gmail' },
59+
{
60+
url: 'https://mail.google.com/mail/u/0/#drafts',
61+
title: 'Drafts (2) - ada@example.com - Gmail',
62+
},
63+
])
64+
65+
const names = await readBrowserSiteNames(path, new Set(['mail.google.com']))
66+
67+
// Nothing here hardcodes Gmail — it is the only segment on every page.
68+
expect(names.get('mail.google.com')).toBe('Gmail')
69+
})
70+
71+
it('finds a name that leads the title rather than trailing it', async () => {
72+
const path = await writeHistoryDatabase([
73+
{ url: 'https://github.com/', title: 'GitHub - Where the world builds software' },
74+
{ url: 'https://github.com/pulls', title: 'GitHub - Pull requests' },
75+
])
76+
77+
const names = await readBrowserSiteNames(path, new Set(['github.com']))
78+
79+
expect(names.get('github.com')).toBe('GitHub')
80+
})
81+
82+
it('handles a title with no separator at all', async () => {
83+
const path = await writeHistoryDatabase([{ url: 'https://example.com/', title: 'Example' }])
84+
85+
const names = await readBrowserSiteNames(path, new Set(['example.com']))
86+
87+
expect(names.get('example.com')).toBe('Example')
88+
})
89+
90+
it('prefers the shorter candidate when pages are split evenly', async () => {
91+
const path = await writeHistoryDatabase([
92+
{ url: 'https://linear.app/a', title: 'Linear - Issue tracking' },
93+
{ url: 'https://linear.app/b', title: 'Issue tracking - Linear' },
94+
])
95+
96+
const names = await readBrowserSiteNames(path, new Set(['linear.app']))
97+
98+
expect(names.get('linear.app')).toBe('Linear')
99+
})
100+
101+
it('names only the hosts being imported, never the rest of the history', async () => {
102+
const path = await writeHistoryDatabase([
103+
{ url: 'https://mail.google.com/', title: 'Gmail' },
104+
{ url: 'https://somewhere-private.example/', title: 'Private' },
105+
])
106+
107+
const names = await readBrowserSiteNames(path, new Set(['mail.google.com']))
108+
109+
expect([...names.keys()]).toEqual(['mail.google.com'])
110+
})
111+
112+
it('asks for nothing when there are no hosts to name', async () => {
113+
const path = await writeHistoryDatabase([{ url: 'https://example.com/', title: 'Example' }])
114+
115+
expect(await readBrowserSiteNames(path, new Set())).toEqual(new Map())
116+
})
117+
118+
it('ignores pages that are not on the web', async () => {
119+
const path = await writeHistoryDatabase([
120+
{ url: 'chrome-extension://abc/page.html', title: 'Extension' },
121+
{ url: 'file:///Users/ada/notes.html', title: 'Notes' },
122+
])
123+
124+
const names = await readBrowserSiteNames(path, new Set(['abc', '']))
125+
126+
expect(names.size).toBe(0)
127+
})
128+
129+
it('rejects a whole-title tagline as a name', async () => {
130+
const long = 'A very long marketing sentence that is plainly not what this site is called'
131+
const path = await writeHistoryDatabase([{ url: 'https://example.com/', title: long }])
132+
133+
const names = await readBrowserSiteNames(path, new Set(['example.com']))
134+
135+
expect(names.has('example.com')).toBe(false)
136+
})
137+
138+
it('survives an unreadable history rather than failing the import', async () => {
139+
const names = await readBrowserSiteNames(join(directory, 'absent'), new Set(['example.com']))
140+
141+
expect(names).toEqual(new Map())
142+
})
143+
144+
it('imports the same name every time for the same profile', async () => {
145+
const pages = [
146+
{ url: 'https://example.com/a', title: 'Alpha - Example' },
147+
{ url: 'https://example.com/b', title: 'Beta - Sample' },
148+
]
149+
const path = await writeHistoryDatabase(pages)
150+
151+
const first = await readBrowserSiteNames(path, new Set(['example.com']))
152+
const second = await readBrowserSiteNames(path, new Set(['example.com']))
153+
154+
expect(first.get('example.com')).toBe(second.get('example.com'))
155+
})
156+
})
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { queryBrowserDatabase } from '@/main/browser-import/sqlite-source'
2+
import { toText } from '@/main/browser-import/types'
3+
4+
/**
5+
* Reads what each site calls itself out of a Chromium profile's `History`
6+
* database.
7+
*
8+
* Sim's browser keeps no history of its own, so it has no way to learn that
9+
* `mail.google.com` is called Gmail. The browser being imported from does
10+
* know, because it has the titles of pages the user actually opened, and that
11+
* is a per-user fact rather than a list of well-known sites hardcoded here.
12+
*
13+
* Only the hosts already being imported are read, and only their names come
14+
* back. A visit log is neither returned nor stored: this reduces a browsing
15+
* history to one string per site the user is bringing over anyway.
16+
*/
17+
18+
const MAX_ROWS = 50_000
19+
/** Longer than this is a page title, not what the site is called. */
20+
const MAX_NAME_LENGTH = 40
21+
22+
const TITLE_QUERY = `
23+
SELECT url, title
24+
FROM urls
25+
WHERE title IS NOT NULL AND title != ''
26+
ORDER BY visit_count DESC
27+
LIMIT ${MAX_ROWS}
28+
`
29+
30+
/** Separators sites put between the page and their own name. */
31+
const TITLE_SEPARATOR = /\s+[|·]\s+|\s+-\s+|\s+:\s+/
32+
33+
function hostnameOf(url: string): string | null {
34+
try {
35+
const { hostname, protocol } = new URL(url)
36+
// Extension and file pages are not sites the omnibox can offer.
37+
if (protocol !== 'https:' && protocol !== 'http:') return null
38+
return hostname || null
39+
} catch {
40+
return null
41+
}
42+
}
43+
44+
/**
45+
* The parts of a page title that could be the site's name.
46+
*
47+
* A title is typically the page, then the site: "Inbox (12) - Gmail". Which
48+
* end holds the name is not consistent enough to pick by position — "GitHub -
49+
* Where software is built" puts it first — so every segment is a candidate and
50+
* frequency decides between them.
51+
*/
52+
function nameCandidates(title: string): string[] {
53+
return title
54+
.split(TITLE_SEPARATOR)
55+
.map((segment) => segment.trim())
56+
.filter((segment) => segment.length > 0 && segment.length <= MAX_NAME_LENGTH)
57+
}
58+
59+
/**
60+
* The name of each host in `hostnames`, drawn from that host's page titles.
61+
*
62+
* The site's name is the part of its titles that does not change: every Gmail
63+
* page ends in "Gmail" while the rest of each title differs, so the segment
64+
* appearing across the most distinct pages of a host is its name. Ties go to
65+
* the shorter candidate, which prefers "GitHub" over a tagline, and then to
66+
* alphabetical order so the same profile always imports the same name.
67+
*
68+
* Never throws. A profile with no readable history simply contributes no
69+
* names, and the caller falls back to showing the hostname.
70+
*/
71+
export async function readBrowserSiteNames(
72+
historyPath: string,
73+
hostnames: ReadonlySet<string>
74+
): Promise<Map<string, string>> {
75+
if (hostnames.size === 0) return new Map()
76+
77+
let rows: Record<string, unknown>[]
78+
try {
79+
rows = await queryBrowserDatabase(historyPath, 'History', TITLE_QUERY)
80+
} catch {
81+
// A name is a nicety. Failing to read one must not fail the import.
82+
return new Map()
83+
}
84+
85+
/** host -> candidate -> the distinct page titles that produced it. */
86+
const tally = new Map<string, Map<string, Set<string>>>()
87+
for (const row of rows) {
88+
const hostname = hostnameOf(toText(row.url))
89+
if (hostname === null || !hostnames.has(hostname)) continue
90+
91+
const title = toText(row.title)
92+
const candidates = tally.get(hostname) ?? new Map<string, Set<string>>()
93+
for (const candidate of nameCandidates(title)) {
94+
const seenIn = candidates.get(candidate) ?? new Set<string>()
95+
seenIn.add(title)
96+
candidates.set(candidate, seenIn)
97+
}
98+
tally.set(hostname, candidates)
99+
}
100+
101+
const names = new Map<string, string>()
102+
for (const [hostname, candidates] of tally) {
103+
let best: { name: string; pages: number } | null = null
104+
for (const [candidate, seenIn] of candidates) {
105+
const pages = seenIn.size
106+
if (
107+
!best ||
108+
pages > best.pages ||
109+
(pages === best.pages &&
110+
(candidate.length < best.name.length ||
111+
(candidate.length === best.name.length && candidate < best.name)))
112+
) {
113+
best = { name: candidate, pages }
114+
}
115+
}
116+
if (best) names.set(hostname, best.name)
117+
}
118+
119+
return names
120+
}

apps/desktop/src/main/browser-import/import-service.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const PROFILES: BrowserProfile[] = [
2929
cookiesPath: '/chrome/Default/Cookies',
3030
loginDataPath: '/chrome/Default/Login Data',
3131
faviconsPath: '/chrome/Default/Favicons',
32+
historyPath: '/chrome/Default/History',
3233
},
3334
{
3435
id: 'arc:Profile 2',
@@ -38,6 +39,7 @@ const PROFILES: BrowserProfile[] = [
3839
cookiesPath: '/arc/Profile 2/Cookies',
3940
loginDataPath: '/arc/Profile 2/Login Data',
4041
faviconsPath: '/arc/Profile 2/Favicons',
42+
historyPath: '/arc/Profile 2/History',
4143
},
4244
]
4345

@@ -75,6 +77,8 @@ function createDeps(overrides: Partial<ImportServiceDeps> = {}): ImportServiceDe
7577
writeCookies: async (cookies) => ({ imported: cookies.length, failed: 0 }),
7678
readPasswords: async () => readPasswords(),
7779
readFavicons: async () => new Map<string, string>(),
80+
readSiteNames: async () => new Map<string, string>(),
81+
rememberSites: async () => {},
7882
vault: {
7983
isAvailable: () => true,
8084
importCredentials: async (candidates) => ({
@@ -97,6 +101,7 @@ describe('toDisplayProfiles', () => {
97101
cookiesPath: '/cookies',
98102
loginDataPath: null,
99103
faviconsPath: null,
104+
historyPath: null,
100105
}
101106
}
102107

@@ -329,6 +334,7 @@ describe('importChromeCookies', () => {
329334
cookiesPath: null,
330335
loginDataPath: '/chrome/Login Data',
331336
faviconsPath: null,
337+
historyPath: null,
332338
},
333339
],
334340
})
@@ -393,7 +399,7 @@ describe('importChromePasswords', () => {
393399
const importCredentials = vi.fn(async () => ({ added: 1, updated: 0, skipped: 0 }))
394400
const readFavicons = vi.fn()
395401
const deps = createDeps({
396-
listProfiles: async () => [{ ...PROFILES[0], faviconsPath: null }],
402+
listProfiles: async () => [{ ...PROFILES[0], faviconsPath: null, historyPath: null }],
397403
readFavicons,
398404
vault: { isAvailable: () => true, importCredentials },
399405
})

0 commit comments

Comments
 (0)