Skip to content

Commit 509cdc2

Browse files
authored
feat: add Safari browser support with history extraction (#564)
* feat: add Safari browser support with history extraction * fix: use correlated subquery to ensure title matches latest visit
1 parent 26817b4 commit 509cdc2

11 files changed

Lines changed: 639 additions & 0 deletions

browser/browser.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88

99
"github.com/moond4rk/hackbrowserdata/browser/chromium"
1010
"github.com/moond4rk/hackbrowserdata/browser/firefox"
11+
"github.com/moond4rk/hackbrowserdata/browser/safari"
1112
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever"
1213
"github.com/moond4rk/hackbrowserdata/log"
1314
"github.com/moond4rk/hackbrowserdata/types"
@@ -150,6 +151,17 @@ func newBrowsers(cfg types.BrowserConfig) ([]Browser, error) {
150151
}
151152
return result, nil
152153

154+
case types.Safari:
155+
found, err := safari.NewBrowsers(cfg)
156+
if err != nil {
157+
return nil, err
158+
}
159+
result := make([]Browser, len(found))
160+
for i, b := range found {
161+
result[i] = b
162+
}
163+
return result, nil
164+
153165
default:
154166
return nil, fmt.Errorf("unknown browser kind: %d", cfg.Kind)
155167
}

browser/browser_darwin.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,5 +91,11 @@ func platformBrowsers() []types.BrowserConfig {
9191
Kind: types.Firefox,
9292
UserDataDir: homeDir + "/Library/Application Support/Firefox/Profiles",
9393
},
94+
{
95+
Key: "safari",
96+
Name: safariName,
97+
Kind: types.Safari,
98+
UserDataDir: homeDir + "/Library/Safari",
99+
},
94100
}
95101
}

browser/browser_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,9 @@ func TestNewBrowsersDispatch(t *testing.T) {
349349
firefoxDir := t.TempDir()
350350
mkFile(t, firefoxDir, "abc.default", "places.sqlite")
351351

352+
safariDir := t.TempDir()
353+
mkFile(t, safariDir, "History.db")
354+
352355
emptyDir := t.TempDir()
353356

354357
tests := []struct {
@@ -373,6 +376,13 @@ func TestNewBrowsersDispatch(t *testing.T) {
373376
wantName: "Firefox",
374377
wantProfile: "abc.default",
375378
},
379+
{
380+
name: "safari dispatch",
381+
cfg: types.BrowserConfig{Key: "safari", Name: "Safari", Kind: types.Safari, UserDataDir: safariDir},
382+
wantLen: 1,
383+
wantName: "Safari",
384+
wantProfile: "default",
385+
},
376386
{
377387
name: "unknown kind returns error",
378388
cfg: types.BrowserConfig{Key: "unknown", Name: "Unknown", Kind: types.BrowserKind(99)},

browser/consts.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,5 @@ const (
2626
sogouName = "Sogou"
2727
arcName = "Arc"
2828
duckduckgoName = "DuckDuckGo"
29+
safariName = "Safari"
2930
)

browser/safari/extract_history.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package safari
2+
3+
import (
4+
"database/sql"
5+
"sort"
6+
7+
"github.com/moond4rk/hackbrowserdata/types"
8+
"github.com/moond4rk/hackbrowserdata/utils/sqliteutil"
9+
)
10+
11+
const (
12+
// safariHistoryQuery joins each history item to its latest visit so
13+
// title and visit_time come from the same history_visits row.
14+
safariHistoryQuery = `SELECT hi.url, COALESCE(hv.title, ''), hi.visit_count,
15+
COALESCE(hv.visit_time, 0)
16+
FROM history_items hi
17+
LEFT JOIN history_visits hv ON hv.id = (
18+
SELECT hv2.id FROM history_visits hv2
19+
WHERE hv2.history_item = hi.id
20+
ORDER BY hv2.visit_time DESC LIMIT 1
21+
)`
22+
23+
safariCountHistoryQuery = `SELECT COUNT(*) FROM history_items`
24+
)
25+
26+
func extractHistories(path string) ([]types.HistoryEntry, error) {
27+
histories, err := sqliteutil.QueryRows(path, true, safariHistoryQuery,
28+
func(rows *sql.Rows) (types.HistoryEntry, error) {
29+
var (
30+
url, title string
31+
visitCount int
32+
visitTime float64
33+
)
34+
if err := rows.Scan(&url, &title, &visitCount, &visitTime); err != nil {
35+
return types.HistoryEntry{}, err
36+
}
37+
return types.HistoryEntry{
38+
URL: url,
39+
Title: title,
40+
VisitCount: visitCount,
41+
LastVisit: coredataTimestamp(visitTime),
42+
}, nil
43+
})
44+
if err != nil {
45+
return nil, err
46+
}
47+
48+
sort.Slice(histories, func(i, j int) bool {
49+
return histories[i].VisitCount > histories[j].VisitCount
50+
})
51+
return histories, nil
52+
}
53+
54+
func countHistories(path string) (int, error) {
55+
return sqliteutil.CountRows(path, true, safariCountHistoryQuery)
56+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package safari
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func setupSafariHistoryDB(t *testing.T) string {
11+
t.Helper()
12+
return createTestDB(t, "History.db",
13+
[]string{safariHistoryItemsSchema, safariHistoryVisitsSchema},
14+
insertHistoryItem(1, "https://github.com", "github.com", 100),
15+
insertHistoryItem(2, "https://go.dev", "go.dev", 50),
16+
insertHistoryItem(3, "https://example.com", "example.com", 200),
17+
// Item 1 has two visits — extractHistories must deduplicate.
18+
insertHistoryVisit(1, 1, 704067600.0, "GitHub"),
19+
insertHistoryVisit(2, 1, 705067600.0, "GitHub - Latest"),
20+
insertHistoryVisit(3, 2, 703067600.0, "The Go Programming Language"),
21+
insertHistoryVisit(4, 3, 700067600.0, "Example Domain"),
22+
)
23+
}
24+
25+
func TestExtractHistories(t *testing.T) {
26+
path := setupSafariHistoryDB(t)
27+
28+
got, err := extractHistories(path)
29+
require.NoError(t, err)
30+
require.Len(t, got, 3)
31+
32+
// Sorted by visit count descending (most visited first).
33+
assert.Equal(t, 200, got[0].VisitCount)
34+
assert.Equal(t, 100, got[1].VisitCount)
35+
assert.Equal(t, 50, got[2].VisitCount)
36+
37+
// Verify field mapping.
38+
assert.Equal(t, "https://example.com", got[0].URL)
39+
assert.Equal(t, "https://github.com", got[1].URL)
40+
assert.Equal(t, "https://go.dev", got[2].URL)
41+
assert.False(t, got[0].LastVisit.IsZero())
42+
}
43+
44+
func TestExtractHistories_Dedup(t *testing.T) {
45+
path := setupSafariHistoryDB(t)
46+
47+
got, err := extractHistories(path)
48+
require.NoError(t, err)
49+
// 3 history_items, not 4 visits.
50+
require.Len(t, got, 3)
51+
52+
// GitHub (item 1) should have the later visit_time and its title.
53+
for _, h := range got {
54+
if h.URL == "https://github.com" {
55+
// 705067600 + 978307200 = 1683374800 (unix)
56+
assert.Equal(t, int64(1683374800), h.LastVisit.Unix())
57+
// Title must come from the latest visit row, not an arbitrary one.
58+
assert.Equal(t, "GitHub - Latest", h.Title)
59+
return
60+
}
61+
}
62+
t.Fatal("expected https://github.com in results")
63+
}
64+
65+
func TestCountHistories(t *testing.T) {
66+
path := setupSafariHistoryDB(t)
67+
68+
count, err := countHistories(path)
69+
require.NoError(t, err)
70+
assert.Equal(t, 3, count)
71+
}
72+
73+
func TestCountHistories_Empty(t *testing.T) {
74+
path := createTestDB(t, "History.db",
75+
[]string{safariHistoryItemsSchema, safariHistoryVisitsSchema})
76+
77+
count, err := countHistories(path)
78+
require.NoError(t, err)
79+
assert.Equal(t, 0, count)
80+
}
81+
82+
func TestExtractHistories_NullTitle(t *testing.T) {
83+
path := createTestDB(t, "History.db",
84+
[]string{safariHistoryItemsSchema, safariHistoryVisitsSchema},
85+
insertHistoryItem(1, "https://null.test", "null.test", 1),
86+
// Visit with NULL title — COALESCE should return empty string.
87+
`INSERT INTO history_visits (id, history_item, visit_time) VALUES (1, 1, 700000000.0)`,
88+
)
89+
90+
got, err := extractHistories(path)
91+
require.NoError(t, err)
92+
require.Len(t, got, 1)
93+
assert.Equal(t, "https://null.test", got[0].URL)
94+
assert.Empty(t, got[0].Title)
95+
}
96+
97+
func TestCoredataTimestamp(t *testing.T) {
98+
// 0 Core Data epoch = 2001-01-01 00:00:00 UTC = Unix 978307200
99+
ts := coredataTimestamp(0)
100+
assert.Equal(t, int64(978307200), ts.Unix())
101+
102+
// Known value: 700000000 Core Data = 1678307200 Unix
103+
ts2 := coredataTimestamp(700000000)
104+
assert.Equal(t, int64(1678307200), ts2.Unix())
105+
}

0 commit comments

Comments
 (0)