detached 245868a

merge: integrate WebKit2GTK migration and homepage API into main

commit
245868a9258613f2b2a6570197d74958c9448aa9
author
bamen · · 2025-09-09 20:06 UTC
merge of
5f1b0a5 + f6244eb · diff against the first parent
signature
unsigned
68 files · +3515 -1240
M .gitignore +2 -0
@@ -57,6 +57,8 @@ config.local.*
5757 # Build artifacts and caches
5858 .cache/
5959 *.prof
60+tmp/go-cache/
61+tmp/go-mod/
6062
6163 # Node/TypeScript related
6264 node_modules/
M CLAUDE.md +23 -14
@@ -1,25 +1,26 @@
11 # dumb-browser Development Guidelines
22
3-Auto-generated from all feature plans. Last updated: 2025-09-09
3+Auto-generated from all feature plans. Last updated: 2025-01-21
44
5-## Active Technologies
6-- Go 1.25.1 + Wails v3-alpha.28, Cobra, Viper, SQLite3 (002-browser-controls-ui)
5+## Active Technologies
6+- Go 1.25.1 + WebKit2GTK (native), Cobra, Viper, SQLite3 (003-webkit2gtk-migration)
7+- CGO bindings for WebKit2GTK C API integration
78
89 ## Project Structure
910 ```
11+pkg/
12+└── webkit/ # WebKit2GTK Go bindings (extractable)
13+
1014 src/
11-├── models/ # Data models and entities
12-├── services/ # Business logic services
13-├── cli/ # Command line interface
14-└── lib/ # Shared libraries
15+├── models/ # Data models and entities
16+├── services/ # Business logic services
17+├── cli/ # Command line interface
18+└── lib/ # Shared libraries
1519
1620 tests/
17-├── contract/ # Contract tests
18-├── integration/ # Integration tests
19-└── unit/ # Unit tests
20-
21-frontend/
22-└── dist/ # Embedded WebView assets
21+├── contract/ # Contract tests for WebKit bindings
22+├── integration/ # Integration tests with WebKit2GTK
23+└── unit/ # Unit tests
2324 ```
2425
2526 ## Commands
@@ -45,11 +46,19 @@ sqlite3 ~/.config/dumber/browser.db
4546 Go 1.25.1: Follow standard Go conventions with gofmt
4647 - Use structured logging (slog)
4748 - Proper error handling (no panics)
48-- SQLC for type-safe database queries
49+- SQLC for type-safe database queries
50+- CGO memory management with Go finalizers
4951 - Constitutional simplicity principles
5052
5153 ## Recent Changes
54+- 003-webkit2gtk-migration: Migrating from Wails to native WebKit2GTK with custom CGO bindings
5255 - 002-browser-controls-ui: Added keyboard/mouse controls, zoom persistence, clipboard integration
5356
57+## WebKit2GTK Integration
58+- Reference repository: /home/brice/dev/clone/webkit2gtk-reference
59+- pkg/webkit: Reusable WebKit2GTK Go bindings
60+- Direct GTK event handling for keyboard shortcuts
61+- WebKitUserContentManager for script injection
62+
5463 <!-- MANUAL ADDITIONS START -->
5564 <!-- MANUAL ADDITIONS END -->⏎̸
M Makefile +16 -8
@@ -1,6 +1,6 @@
11 # Makefile for dumber
22
3-.PHONY: build build-frontend test lint clean install-tools dev generate help
3+.PHONY: build build-frontend test lint clean install-tools dev generate help check init build-static
44
55 # Variables
66 BINARY_NAME=dumber
@@ -21,14 +21,14 @@ help: ## Show this help message
2121 @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
2222
2323 # Build targets
24-build: build-frontend ## Build the Wails app (builds frontend, then wails3 build)
25- @echo "Building $(BINARY_NAME) $(VERSION) with Wails..."
24+build: build-frontend ## Build the application (frontend assets, then Go binary)
25+ @echo "Building $(BINARY_NAME) $(VERSION)..."
2626 @mkdir -p $(DIST_DIR)
27- CGO_ENABLED=1 wails3 build
27+ CGO_ENABLED=1 go build $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)
2828
2929 build-frontend: ## Build TypeScript frontend
3030 @echo "Building TypeScript frontend..."
31- @cd frontend && npm ci --silent && npm run build
31+ @cd frontend && npm install --silent && npm run build
3232 @echo "Frontend build complete"
3333
3434 build-static: ## Build static binary (CGO disabled, CLI-only functionality)
@@ -36,6 +36,17 @@ build-static: ## Build static binary (CGO disabled, CLI-only functionality)
3636 @mkdir -p $(DIST_DIR)
3737 CGO_ENABLED=0 go build $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME)-static $(MAIN_PATH)
3838
39+# GUI build with WebKit2GTK
40+.PHONY: build-gui run-gui
41+build-gui: build-frontend ## Build GUI binary with native WebKit2GTK (requires dev packages)
42+ @echo "Building $(BINARY_NAME) (GUI, webkit_cgo)…"
43+ @mkdir -p $(DIST_DIR)
44+ CGO_ENABLED=1 go build $(LDFLAGS) -tags=webkit_cgo -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)
45+
46+run-gui: ## Run the GUI with native WebKit2GTK (requires dev packages)
47+ @echo "Running GUI (webkit_cgo)…"
48+ CGO_ENABLED=1 go run -tags=webkit_cgo $(MAIN_PATH)
49+
3950 # Development targets
4051 dev: ## Run in development mode
4152 @echo "Running in development mode..."
@@ -68,7 +79,6 @@ lint-fix: ## Run golangci-lint with --fix
6879 install-tools: ## Install development tools
6980 @echo "Installing development tools..."
7081 go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
71- go install github.com/wailsapp/wails/v3/cmd/wails3@latest
7282 go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
7383 @echo "Tools installed successfully"
7484
@@ -93,8 +103,6 @@ check: ## Check that all tools and dependencies are working
93103 @go version
94104 @echo "\nSQLC version:"
95105 @sqlc version
96- @echo "\nWails version:"
97- @wails3 version
98106 @echo "\nGolangci-lint version:"
99107 @golangci-lint version
100108 @echo "\nBuilding project..."
D Taskfile.yml +0 -49
@@ -1,49 +0,0 @@
1-version: '3'
2-
3-vars:
4- APP_NAME: dumber
5- VERSION: 0.1.0
6- COMMIT:
7- sh: git rev-parse --short HEAD 2>/dev/null || echo "dev"
8- BUILD_DATE:
9- sh: date -u +"%Y-%m-%dT%H:%M:%SZ"
10-
11-tasks:
12- build:
13- desc: Build the unified Dumber application (CLI + Browser)
14- cmds:
15- - go build -ldflags "-X main.version={{.VERSION}} -X main.commit={{.COMMIT}} -X main.buildDate={{.BUILD_DATE}}" -o dist/{{.APP_NAME}} main.go
16-
17- dev:
18- desc: Run the application in development mode (GUI)
19- cmds:
20- - go run main.go
21-
22- dev-cli:
23- desc: Run the application in CLI development mode
24- cmds:
25- - go run main.go version
26-
27- test:
28- desc: Run tests
29- cmds:
30- - go test ./...
31-
32- clean:
33- desc: Clean build artifacts
34- cmds:
35- - rm -rf dist/
36- - mkdir -p dist
37-
38- demo:
39- desc: Show different usage modes
40- cmds:
41- - echo "Building unified application..."
42- - task build
43- - echo ""
44- - echo "Usage examples:"
45- - echo " GUI mode{{":"}} ./dist/{{.APP_NAME}}"
46- - echo " CLI mode{{":"}} ./dist/{{.APP_NAME}} version"
47- - echo " History{{":"}} ./dist/{{.APP_NAME}} history"
48- - echo " Dmenu{{":"}} ./dist/{{.APP_NAME}} --dmenu"
49- - echo " Browse URL{{":"}} ./dist/{{.APP_NAME}} 'https://github.com'"⏎̸
D frontend/bindings/database/sql/models.ts +0 -121
@@ -1,121 +0,0 @@
1-// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
2-// This file is automatically generated. DO NOT EDIT
3-
4-// eslint-disable-next-line @typescript-eslint/ban-ts-comment
5-// @ts-ignore: Unused imports
6-import { Create as $Create } from "@wailsio/runtime";
7-
8-// eslint-disable-next-line @typescript-eslint/ban-ts-comment
9-// @ts-ignore: Unused imports
10-import * as time$0 from "../../time/models.js";
11-
12-/**
13- * NullInt64 represents an int64 that may be null.
14- * NullInt64 implements the [Scanner] interface so
15- * it can be used as a scan destination, similar to [NullString].
16- */
17-export class NullInt64 {
18- "Int64": number;
19-
20- /**
21- * Valid is true if Int64 is not NULL
22- */
23- "Valid": boolean;
24-
25- /** Creates a new NullInt64 instance. */
26- constructor($$source: Partial<NullInt64> = {}) {
27- if (!("Int64" in $$source)) {
28- this["Int64"] = 0;
29- }
30- if (!("Valid" in $$source)) {
31- this["Valid"] = false;
32- }
33-
34- Object.assign(this, $$source);
35- }
36-
37- /**
38- * Creates a new NullInt64 instance from a string or object.
39- */
40- static createFrom($$source: any = {}): NullInt64 {
41- let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
42- return new NullInt64($$parsedSource as Partial<NullInt64>);
43- }
44-}
45-
46-/**
47- * NullString represents a string that may be null.
48- * NullString implements the [Scanner] interface so
49- * it can be used as a scan destination:
50- *
51- * var s NullString
52- * err := db.QueryRow("SELECT name FROM foo WHERE id=?", id).Scan(&s)
53- * ...
54- * if s.Valid {
55- * // use s.String
56- * } else {
57- * // NULL value
58- * }
59- */
60-export class NullString {
61- "String": string;
62-
63- /**
64- * Valid is true if String is not NULL
65- */
66- "Valid": boolean;
67-
68- /** Creates a new NullString instance. */
69- constructor($$source: Partial<NullString> = {}) {
70- if (!("String" in $$source)) {
71- this["String"] = "";
72- }
73- if (!("Valid" in $$source)) {
74- this["Valid"] = false;
75- }
76-
77- Object.assign(this, $$source);
78- }
79-
80- /**
81- * Creates a new NullString instance from a string or object.
82- */
83- static createFrom($$source: any = {}): NullString {
84- let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
85- return new NullString($$parsedSource as Partial<NullString>);
86- }
87-}
88-
89-/**
90- * NullTime represents a [time.Time] that may be null.
91- * NullTime implements the [Scanner] interface so
92- * it can be used as a scan destination, similar to [NullString].
93- */
94-export class NullTime {
95- "Time": time$0.Time;
96-
97- /**
98- * Valid is true if Time is not NULL
99- */
100- "Valid": boolean;
101-
102- /** Creates a new NullTime instance. */
103- constructor($$source: Partial<NullTime> = {}) {
104- if (!("Time" in $$source)) {
105- this["Time"] = null;
106- }
107- if (!("Valid" in $$source)) {
108- this["Valid"] = false;
109- }
110-
111- Object.assign(this, $$source);
112- }
113-
114- /**
115- * Creates a new NullTime instance from a string or object.
116- */
117- static createFrom($$source: any = {}): NullTime {
118- let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
119- return new NullTime($$parsedSource as Partial<NullTime>);
120- }
121-}
D frontend/bindings/time/models.ts +0 -87
@@ -1,87 +0,0 @@
1-// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
2-// This file is automatically generated. DO NOT EDIT
3-
4-// eslint-disable-next-line @typescript-eslint/ban-ts-comment
5-// @ts-ignore: Unused imports
6-import { Create as $Create } from "@wailsio/runtime";
7-
8-/**
9- * A Duration represents the elapsed time between two instants
10- * as an int64 nanosecond count. The representation limits the
11- * largest representable duration to approximately 290 years.
12- */
13-export enum Duration {
14- /**
15- * The Go zero value for the underlying type of the enum.
16- */
17- $zero = 0,
18-
19- minDuration = -9223372036854775808,
20- maxDuration = 9223372036854775807,
21-
22- /**
23- * Common durations. There is no definition for units of Day or larger
24- * to avoid confusion across daylight savings time zone transitions.
25- *
26- * To count the number of units in a [Duration], divide:
27- *
28- * second := time.Second
29- * fmt.Print(int64(second/time.Millisecond)) // prints 1000
30- *
31- * To convert an integer number of units to a Duration, multiply:
32- *
33- * seconds := 10
34- * fmt.Print(time.Duration(seconds)*time.Second) // prints 10s
35- */
36- Nanosecond = 1,
37- Microsecond = 1000,
38- Millisecond = 1000000,
39- Second = 1000000000,
40- Minute = 60000000000,
41- Hour = 3600000000000,
42-};
43-
44-/**
45- * A Time represents an instant in time with nanosecond precision.
46- *
47- * Programs using times should typically store and pass them as values,
48- * not pointers. That is, time variables and struct fields should be of
49- * type [time.Time], not *time.Time.
50- *
51- * A Time value can be used by multiple goroutines simultaneously except
52- * that the methods [Time.GobDecode], [Time.UnmarshalBinary], [Time.UnmarshalJSON] and
53- * [Time.UnmarshalText] are not concurrency-safe.
54- *
55- * Time instants can be compared using the [Time.Before], [Time.After], and [Time.Equal] methods.
56- * The [Time.Sub] method subtracts two instants, producing a [Duration].
57- * The [Time.Add] method adds a Time and a Duration, producing a Time.
58- *
59- * The zero value of type Time is January 1, year 1, 00:00:00.000000000 UTC.
60- * As this time is unlikely to come up in practice, the [Time.IsZero] method gives
61- * a simple way of detecting a time that has not been initialized explicitly.
62- *
63- * Each time has an associated [Location]. The methods [Time.Local], [Time.UTC], and Time.In return a
64- * Time with a specific Location. Changing the Location of a Time value with
65- * these methods does not change the actual instant it represents, only the time
66- * zone in which to interpret it.
67- *
68- * Representations of a Time value saved by the [Time.GobEncode], [Time.MarshalBinary], [Time.AppendBinary],
69- * [Time.MarshalJSON], [Time.MarshalText] and [Time.AppendText] methods store the [Time.Location]'s offset,
70- * but not the location name. They therefore lose information about Daylight Saving Time.
71- *
72- * In addition to the required “wall clock” reading, a Time may contain an optional
73- * reading of the current process's monotonic clock, to provide additional precision
74- * for comparison or subtraction.
75- * See the “Monotonic Clocks” section in the package documentation for details.
76- *
77- * Note that the Go == operator compares not just the time instant but also the
78- * Location and the monotonic clock reading. Therefore, Time values should not
79- * be used as map or database keys without first guaranteeing that the
80- * identical Location has been set for all values, which can be achieved
81- * through use of the UTC or Local method, and that the monotonic clock reading
82- * has been stripped by setting t = t.Round(0). In general, prefer t.Equal(u)
83- * to t == u, since t.Equal uses the most accurate comparison available and
84- * correctly handles the case when only one of its arguments has a monotonic
85- * clock reading.
86- */
87-export type Time = any;
M frontend/package-lock.json +0 -9 generated file: collapsed
M frontend/package.json +1 -3
@@ -19,7 +19,5 @@
1919 "typescript": "^5.3.0",
2020 "typescript-eslint": "^8.43.0"
2121 },
22- "dependencies": {
23- "@wailsio/runtime": "^3.0.0-alpha.66"
24- }
22+ "dependencies": {}
2523 }
M frontend/src/injected-controls.ts +2 -2
@@ -9,7 +9,7 @@
99 * - URL copying
1010 * - Mouse navigation buttons
1111 *
12- * Zoom controls are handled natively by Wails in main.go
12+ * Zoom controls are handled natively by the GTK backend
1313 */
1414
1515 (function() {
@@ -121,4 +121,4 @@
121121 } catch (error) {
122122 console.error('❌ Failed to initialize Dumber Browser controls:', error);
123123 }
124-})();⏎̸
124+})();
M frontend/src/services/app.ts +13 -8
@@ -1,12 +1,17 @@
1-import { BrowserService, ConfigService, HistoryEntry } from '../../bindings/github.com/bnema/dumber/services/index';
1+type HistoryEntry = {
2+ id: number;
3+ url: string;
4+ title: string;
5+ visit_count: number;
6+ last_visited: string | null;
7+ created_at: string | null;
8+}
29
310 export class AppService {
411 private history: HistoryEntry[] = [];
512 private shortcuts: Record<string, any> = {};
613
7- constructor() {
8- // Using Wails v3 service bindings directly
9- }
14+ constructor() {}
1015
1116 async initialize(): Promise<void> {
1217 try {
@@ -21,7 +26,7 @@ export class AppService {
2126
2227 async loadHistory(): Promise<void> {
2328 try {
24- const recentHistory = await BrowserService.GetRecentHistory(50);
29+ const recentHistory: HistoryEntry[] = await fetch('/api/history/recent?limit=50').then(r => r.json());
2530 this.history = recentHistory;
2631 } catch (error) {
2732 console.error('Failed to load history:', error);
@@ -57,8 +62,8 @@ export class AppService {
5762
5863 async loadShortcuts(): Promise<void> {
5964 try {
60- const shortcutsData = await BrowserService.GetSearchShortcuts();
61- this.shortcuts = shortcutsData;
65+ const cfg = await fetch('/api/config').then(r => r.json());
66+ this.shortcuts = cfg?.search_shortcuts || {};
6267 } catch (error) {
6368 console.error('Failed to load shortcuts:', error);
6469 // Mock shortcuts for development/fallback
@@ -87,4 +92,4 @@ export class AppService {
8792 console.error('Failed to copy:', err);
8893 }
8994 }
90-}⏎̸
95+}
M frontend/src/services/browser-controls.ts +12 -145
@@ -1,18 +1,15 @@
1-import { BrowserService } from '../../bindings/github.com/bnema/dumber/services/index';
2-import { Window } from '@wailsio/runtime';
31 import { NotificationService } from './notifications.js';
42 import { DebugConsoleService } from './debug-console.js';
53
64 export class BrowserControlsService {
75 private currentURL = '';
8- private currentZoom = 1.0;
96 private notifications = new NotificationService();
107 private debugConsole = new DebugConsoleService();
118
129 constructor() {
1310 // Debug logging with enhanced details
1411 this.debugConsole.addLog('info', 'frontend', 'init', 'BrowserControlsService initializing...');
15- this.debugConsole.addLog('info', 'frontend', 'init', 'Using Wails v3 service bindings');
12+ this.debugConsole.addLog('info', 'frontend', 'init', 'Running without Wails bindings');
1613
1714 this.initializeEventListeners();
1815 this.debugConsole.addLog('info', 'frontend', 'init', 'BrowserControlsService initialized ✓');
@@ -42,26 +39,9 @@ export class BrowserControlsService {
4239 const { ctrlKey, metaKey, shiftKey, altKey, key } = event;
4340 const isCmd = ctrlKey || metaKey;
4441
45- // Zoom controls
46- if (isCmd && (key === '+' || key === '=')) {
47- this.debugConsole.addLog('info', 'frontend', 'action', 'Zoom In triggered');
48- event.preventDefault();
49- await this.zoomIn();
50- return;
51- }
52-
53- if (isCmd && key === '-') {
54- this.debugConsole.addLog('info', 'frontend', 'action', 'Zoom Out triggered');
55- event.preventDefault();
56- await this.zoomOut();
57- return;
58- }
59-
60- if (isCmd && key === '0') {
61- this.debugConsole.addLog('info', 'frontend', 'action', 'Zoom Reset triggered');
62- event.preventDefault();
63- await this.resetZoom();
64- return;
42+ // Zoom controls handled natively by GTK; don't intercept
43+ if (isCmd && (key === '+' || key === '=' || key === '-' || key === '0')) {
44+ return; // allow native handler to process
6545 }
6646
6747 // Copy URL
@@ -76,14 +56,14 @@ export class BrowserControlsService {
7656 if (altKey && key === 'ArrowLeft') {
7757 this.debugConsole.addLog('info', 'frontend', 'action', 'Go Back triggered');
7858 event.preventDefault();
79- await this.goBack();
59+ history.back();
8060 return;
8161 }
8262
8363 if (altKey && key === 'ArrowRight') {
8464 this.debugConsole.addLog('info', 'frontend', 'action', 'Go Forward triggered');
8565 event.preventDefault();
86- await this.goForward();
66+ history.forward();
8767 return;
8868 }
8969 }
@@ -114,82 +94,7 @@ export class BrowserControlsService {
11494 this.loadSavedZoom();
11595 }
11696
117- async zoomIn(): Promise<void> {
118- const url = this.getCurrentURL();
119- this.debugConsole.addLog('info', 'frontend', 'service', `Calling ZoomIn with URL: ${url}`);
120-
121- try {
122- const newZoom = await BrowserService.ZoomIn(url);
123- this.debugConsole.logServiceCall('BrowserService', 'ZoomIn', [url], newZoom);
124-
125- await this.applyZoom(newZoom);
126- this.notifications.showZoom(`Zoom: ${Math.round(newZoom * 100)}%`);
127-
128- this.debugConsole.addLog('info', 'frontend', 'zoom', `Zoom level changed to ${Math.round(newZoom * 100)}%`);
129- } catch (error) {
130- this.debugConsole.logServiceCall('BrowserService', 'ZoomIn', [url], null, error as Error);
131- console.error('Failed to zoom in:', error);
132- }
133- }
134-
135- async zoomOut(): Promise<void> {
136- const url = this.getCurrentURL();
137- this.debugConsole.addLog('info', 'frontend', 'service', `Calling ZoomOut with URL: ${url}`);
138-
139- try {
140- const newZoom = await BrowserService.ZoomOut(url);
141- this.debugConsole.logServiceCall('BrowserService', 'ZoomOut', [url], newZoom);
142-
143- await this.applyZoom(newZoom);
144- this.notifications.showZoom(`Zoom: ${Math.round(newZoom * 100)}%`);
145-
146- this.debugConsole.addLog('info', 'frontend', 'zoom', `Zoom level changed to ${Math.round(newZoom * 100)}%`);
147- } catch (error) {
148- this.debugConsole.logServiceCall('BrowserService', 'ZoomOut', [url], null, error as Error);
149- console.error('Failed to zoom out:', error);
150- }
151- }
152-
153- async resetZoom(): Promise<void> {
154- const url = this.getCurrentURL();
155- this.debugConsole.addLog('info', 'frontend', 'service', `Calling ResetZoom with URL: ${url}`);
156-
157- try {
158- const newZoom = await BrowserService.ResetZoom(url);
159- this.debugConsole.logServiceCall('BrowserService', 'ResetZoom', [url], newZoom);
160-
161- await this.applyZoom(newZoom);
162- this.notifications.showZoom(`Zoom: ${Math.round(newZoom * 100)}%`);
163-
164- this.debugConsole.addLog('info', 'frontend', 'zoom', `Zoom reset to ${Math.round(newZoom * 100)}%`);
165- } catch (error) {
166- this.debugConsole.logServiceCall('BrowserService', 'ResetZoom', [url], null, error as Error);
167- console.error('Failed to reset zoom:', error);
168- }
169- }
170-
171- private async applyZoom(zoomLevel: number): Promise<void> {
172- this.currentZoom = zoomLevel;
173- // Reset CSS zoom by default so we don't double-scale when native works
174- try { document.body.style.zoom = ''; } catch {}
175- try {
176- if (zoomLevel >= 1.0) {
177- await Window.SetZoom(zoomLevel);
178- return;
179- }
180- // Try native first for <1.0; if platform clamps (e.g., Linux/WebKitGTK),
181- // detect mismatch and fallback to CSS-based zoom for "unzoom" behavior.
182- await Window.SetZoom(zoomLevel);
183- const actual = await Window.GetZoom();
184- if (actual >= 1.0 && zoomLevel < 1.0 && Math.abs(actual - zoomLevel) > 0.01) {
185- await Window.SetZoom(1.0);
186- document.body.style.zoom = zoomLevel.toString();
187- }
188- } catch (e) {
189- console.warn('Failed to apply native zoom, falling back to CSS zoom', e);
190- document.body.style.zoom = zoomLevel.toString();
191- }
192- }
97+ // Zoom is handled natively; no JS-based zoom adjustments needed.
19398
19499 async copyCurrentURL(): Promise<void> {
195100 try {
@@ -253,60 +158,22 @@ export class BrowserControlsService {
253158
254159 document.title = title;
255160
256- // Notify backend service
257- try {
258- await BrowserService.UpdatePageTitle(url, title);
259- } catch (error) {
260- console.warn('Failed to update page title on backend:', error);
261- }
161+ // Backend title updates handled natively; no frontend call
262162 } catch (error) {
263163 console.error('Failed to update title:', error);
264164 }
265165 }
266166
267167 async loadSavedZoom(): Promise<void> {
268- try {
269- const savedZoom = await BrowserService.GetZoomLevel(this.getCurrentURL());
270- if (savedZoom && savedZoom !== 1.0) {
271- await this.applyZoom(savedZoom);
272- }
273- } catch (error) {
274- console.error('Failed to load saved zoom:', error);
275- }
168+ // Zoom persistence applied natively on page load
276169 }
277170
278171 async initialize(): Promise<void> {
279172 // Check if there's an initial URL from backend (direct navigation)
280- try {
281- const initialURL = await BrowserService.GetInitialURL();
282- if (initialURL && initialURL !== '') {
283- this.debugConsole.addLog('info', 'frontend', 'init', `Found initial URL from backend: ${initialURL}`);
284- this.debugConsole.addLog('info', 'frontend', 'navigation', `Auto-navigating to: ${initialURL}`);
285-
286- // Set the URL and navigate to it
287- this.setCurrentURL(initialURL);
288- // Apply saved zoom natively before navigation so it persists into the next page
289- try {
290- const savedZoom = await BrowserService.GetZoomLevel(initialURL);
291- if (savedZoom && savedZoom > 0) {
292- this.applyZoom(savedZoom);
293- }
294- } catch {}
295-
296- // Navigate to the URL in the browser
297- window.location.href = initialURL;
298- return; // Don't load zoom for homepage, we're navigating away
299- } else {
300- // Set initial URL from browser location (homepage)
301- this.setCurrentURL(window.location.href);
302- }
303- } catch (error) {
304- console.error('Failed to get initial URL from backend:', error);
305- // Fallback to browser location
306- this.setCurrentURL(window.location.href);
307- }
173+ // Set initial URL from browser location (homepage)
174+ this.setCurrentURL(window.location.href);
308175
309176 // Load saved zoom level (only if we're staying on homepage)
310- await this.loadSavedZoom();
177+ // Zoom handled natively
311178 }
312179 }
M frontend/src/services/debug-console.ts +1 -43
@@ -83,51 +83,9 @@ export class DebugConsoleService {
8383 event.preventDefault();
8484 this.toggle();
8585 }
86-
87- // F11 to inspect window.go in detail
88- if (event.key === 'F11') {
89- event.preventDefault();
90- this.inspectWailsServices();
91- }
9286 });
9387 }
9488
95- private inspectWailsServices(): void {
96- this.addLog('info', 'frontend', 'debug', '=== WAILS SERVICES INSPECTION ===');
97- this.addLog('info', 'frontend', 'debug', `typeof window: ${typeof window}`);
98- this.addLog('info', 'frontend', 'debug', `window.go exists: ${!!window.go}`);
99-
100- if (window.go) {
101- this.addLog('info', 'frontend', 'debug', `typeof window.go: ${typeof window.go}`, window.go);
102- this.addLog('info', 'frontend', 'debug', `window.go.services exists: ${!!window.go.services}`);
103-
104- if (window.go.services) {
105- const services = window.go.services;
106- this.addLog('info', 'frontend', 'debug', `typeof window.go.services: ${typeof services}`, services);
107-
108- const serviceNames = Object.keys(services);
109- this.addLog('info', 'frontend', 'debug', `Service count: ${serviceNames.length}`);
110- this.addLog('info', 'frontend', 'debug', `Service names: [${serviceNames.join(', ')}]`);
111-
112- serviceNames.forEach(name => {
113- const service = services[name];
114- this.addLog('info', 'frontend', 'debug', `${name}: ${typeof service}`, service);
115-
116- if (service && typeof service === 'object') {
117- const methods = Object.getOwnPropertyNames(service).filter(prop => typeof service[prop] === 'function');
118- this.addLog('info', 'frontend', 'debug', `${name} methods: [${methods.join(', ')}]`);
119- }
120- });
121- } else {
122- this.addLog('error', 'frontend', 'debug', 'window.go.services is null/undefined');
123- }
124- } else {
125- this.addLog('error', 'frontend', 'debug', 'window.go is null/undefined');
126- }
127-
128- this.addLog('info', 'frontend', 'debug', '=== END INSPECTION ===');
129- }
130-
13189 private interceptConsole(): void {
13290 const originalConsole = {
13391 log: console.log,
@@ -297,4 +255,4 @@ export class DebugConsoleService {
297255 public isDebugVisible(): boolean {
298256 return this.isVisible;
299257 }
300-}⏎̸
258+}
D frontend/src/types/wails.ts +0 -77
@@ -1,77 +0,0 @@
1-// Wails service interfaces matching Go backend
2-
3-export interface NavigationResult {
4- url: string;
5- title: string;
6- success: boolean;
7- error?: string;
8- load_time: number;
9- redirect_chain?: string[];
10-}
11-
12-export interface HistoryEntry {
13- id: number;
14- url: string;
15- title: string;
16- visit_count: number;
17- last_visited: string;
18- created_at: string;
19-}
20-
21-export interface SearchShortcut {
22- description: string;
23- url: string;
24-}
25-
26-export interface Config {
27- SearchShortcuts: Record<string, SearchShortcut>;
28-}
29-
30-export interface BrowserService {
31- Navigate(url: string): Promise<NavigationResult>;
32- UpdatePageTitle(url: string, title: string): Promise<void>;
33- GetRecentHistory(limit: number): Promise<HistoryEntry[]>;
34- SearchHistory(query: string, limit: number): Promise<HistoryEntry[]>;
35- DeleteHistoryEntry(id: number): Promise<void>;
36- ClearHistory(): Promise<void>;
37- GetHistoryStats(): Promise<Record<string, any>>;
38- GetConfig(): Promise<Config>;
39- UpdateConfig(config: Config): Promise<void>;
40- GetSearchShortcuts(): Promise<Record<string, SearchShortcut>>;
41-
42- // Zoom functionality
43- ZoomIn(url: string): Promise<number>;
44- ZoomOut(url: string): Promise<number>;
45- ResetZoom(url: string): Promise<number>;
46- GetZoomLevel(url: string): Promise<number>;
47- SetZoomLevel(url: string, zoomLevel: number): Promise<void>;
48-
49- // Navigation
50- GetCurrentURL(): Promise<string>;
51- CopyCurrentURL(url: string): Promise<void>;
52- GoBack(): Promise<void>;
53- GoForward(): Promise<void>;
54-}
55-
56-export interface ParserService {
57- ParseInput(input: string): Promise<any>;
58-}
59-
60-export interface ConfigService {
61- GetSearchShortcuts(): Promise<Record<string, SearchShortcut>>;
62-}
63-
64-export interface WailsServices {
65- BrowserService: BrowserService;
66- ParserService: ParserService;
67- ConfigService: ConfigService;
68-}
69-
70-// Global Wails interface
71-declare global {
72- interface Window {
73- go?: {
74- services?: WailsServices;
75- };
76- }
77-}⏎̸
M frontend/src/utils/dom.ts +14 -12
@@ -1,6 +1,5 @@
1-import type { HistoryEntry, SearchShortcut } from '../types/wails.js';
2-import { BrowserService } from '../../bindings/github.com/bnema/dumber/services/index';
3-import { Window } from '@wailsio/runtime';
1+type HistoryEntry = { id: number; url: string; title: string };
2+type SearchShortcut = { description: string; url: string };
43
54 export class DOMRenderer {
65 private historyListElement: HTMLElement | null = null;
@@ -73,9 +72,11 @@ export class DOMRenderer {
7372 private createHistoryItem(item: HistoryEntry): HTMLElement {
7473 const historyItem = document.createElement('div');
7574 historyItem.className = 'history-item';
75+ const parsed = this.safeParseURL(item.url);
76+ const title = item.title && item.title.trim() !== '' ? item.title : (parsed.host || 'Untitled');
7677 historyItem.innerHTML = `
7778 <div class="history-url">${this.escapeHtml(item.url)}</div>
78- <div class="history-title">${this.escapeHtml(item.title || 'Untitled')}</div>
79+ <div class="history-title">${this.escapeHtml(title)}</div>
7980 `;
8081
8182 historyItem.addEventListener('click', () => {
@@ -104,14 +105,6 @@ export class DOMRenderer {
104105
105106 private async navigateToUrl(url: string): Promise<void> {
106107 console.log('Navigating to:', url);
107- try {
108- const savedZoom = await BrowserService.GetZoomLevel(url);
109- if (savedZoom && savedZoom > 0) {
110- try { Window.SetZoom(savedZoom); } catch {}
111- }
112- } catch (e) {
113- // Ignore zoom retrieval errors
114- }
115108 window.location.href = url;
116109 }
117110
@@ -148,4 +141,13 @@ export class DOMRenderer {
148141 div.textContent = text;
149142 return div.innerHTML;
150143 }
144+
145+ private safeParseURL(raw: string): { host: string } {
146+ try {
147+ const u = new URL(raw);
148+ return { host: u.host };
149+ } catch {
150+ return { host: '' };
151+ }
152+ }
151153 }
M frontend/tsup.config.ts +2 -2
@@ -15,7 +15,7 @@ export default defineConfig([
1515 bundle: true,
1616 splitting: false,
1717 external: [],
18- noExternal: ['@wailsio/runtime'],
18+ // noExternal: [],
1919 },
2020 // Injectable controls script (minified for injection)
2121 {
@@ -51,4 +51,4 @@ export default defineConfig([
5151 console.log('✓ Frontend TypeScript compiled successfully')
5252 }
5353 }
54-])⏎̸
54+])
M go.mod +3 -39
@@ -8,60 +8,24 @@ require (
88 github.com/ncruces/go-sqlite3 v0.29.0
99 github.com/spf13/cobra v1.10.1
1010 github.com/spf13/viper v1.21.0
11- github.com/wailsapp/wails/v3 v3.0.0-alpha.28
1211 )
1312
1413 require (
15- dario.cat/mergo v1.0.1 // indirect
16- github.com/Microsoft/go-winio v0.6.2 // indirect
17- github.com/ProtonMail/go-crypto v1.1.6 // indirect
18- github.com/adrg/xdg v0.5.3 // indirect
19- github.com/bep/debounce v1.2.1 // indirect
20- github.com/cloudflare/circl v1.6.0 // indirect
21- github.com/cyphar/filepath-securejoin v0.4.1 // indirect
22- github.com/ebitengine/purego v0.8.2 // indirect
23- github.com/emirpasic/gods v1.18.1 // indirect
24- github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
25- github.com/go-git/go-billy/v5 v5.6.2 // indirect
26- github.com/go-git/go-git/v5 v5.13.2 // indirect
27- github.com/go-ole/go-ole v1.3.0 // indirect
2814 github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
29- github.com/godbus/dbus/v5 v5.1.0 // indirect
30- github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
31- github.com/google/uuid v1.6.0 // indirect
15+ github.com/google/go-cmp v0.7.0 // indirect
3216 github.com/inconshreveable/mousetrap v1.1.0 // indirect
33- github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
34- github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
35- github.com/kevinburke/ssh_config v1.2.0 // indirect
36- github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
37- github.com/leaanthony/u v1.1.1 // indirect
38- github.com/lmittmann/tint v1.0.7 // indirect
39- github.com/mattn/go-colorable v0.1.14 // indirect
40- github.com/mattn/go-isatty v0.0.20 // indirect
4117 github.com/ncruces/julianday v1.0.0 // indirect
4218 github.com/pelletier/go-toml/v2 v2.2.4 // indirect
43- github.com/pjbgf/sha1cd v0.3.2 // indirect
44- github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
45- github.com/pkg/errors v0.9.1 // indirect
46- github.com/rivo/uniseg v0.4.7 // indirect
19+ github.com/rogpeppe/go-internal v1.12.0 // indirect
4720 github.com/sagikazarmark/locafero v0.11.0 // indirect
48- github.com/samber/lo v1.49.1 // indirect
49- github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
50- github.com/skeema/knownhosts v1.3.1 // indirect
5121 github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
5222 github.com/spf13/afero v1.15.0 // indirect
5323 github.com/spf13/cast v1.10.0 // indirect
5424 github.com/spf13/pflag v1.0.10 // indirect
5525 github.com/subosito/gotenv v1.6.0 // indirect
5626 github.com/tetratelabs/wazero v1.9.0 // indirect
57- github.com/wailsapp/go-webview2 v1.0.21 // indirect
58- github.com/wailsapp/mimetype v1.4.1 // indirect
59- github.com/xanzy/ssh-agent v0.3.3 // indirect
6027 go.yaml.in/yaml/v3 v3.0.4 // indirect
61- golang.org/x/crypto v0.41.0 // indirect
62- golang.org/x/net v0.42.0 // indirect
6328 golang.org/x/sys v0.36.0 // indirect
6429 golang.org/x/text v0.29.0 // indirect
65- gopkg.in/ini.v1 v1.67.0 // indirect
66- gopkg.in/warnings.v0 v0.1.2 // indirect
30+ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
6731 )
M go.sum +1 -120
@@ -1,119 +1,38 @@
1-dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
2-dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
3-github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
4-github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
5-github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
6-github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
7-github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
8-github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
9-github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
10-github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
11-github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
12-github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
13-github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
14-github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
15-github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
16-github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk=
17-github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
181 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
19-github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
20-github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
21-github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
222 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
233 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
24-github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
25-github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
26-github.com/elazarl/goproxy v1.4.0 h1:4GyuSbFa+s26+3rmYNSuUVsx+HgPrV1bk1jXI0l9wjM=
27-github.com/elazarl/goproxy v1.4.0/go.mod h1:X/5W/t+gzDyLfHW4DrMdpjqYjpXsURlBt9lpBDxZZZQ=
28-github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
29-github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
304 github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
315 github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
326 github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
337 github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
34-github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
35-github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
36-github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
37-github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
38-github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
39-github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
40-github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
41-github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
42-github.com/go-git/go-git/v5 v5.13.2 h1:7O7xvsK7K+rZPKW6AQR1YyNhfywkv7B8/FsP3ki6Zv0=
43-github.com/go-git/go-git/v5 v5.13.2/go.mod h1:hWdW5P4YZRjmpGHwRH2v3zkWcNl6HeXaXQEMGb3NJ9A=
44-github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
45-github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
468 github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
479 github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
48-github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
49-github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
50-github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
51-github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
5210 github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
5311 github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
5412 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
5513 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
56-github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
57-github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
5814 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
5915 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
60-github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
61-github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
62-github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
63-github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
64-github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
65-github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
66-github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
16+github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
6717 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
6818 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
6919 github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
7020 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
7121 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
7222 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
73-github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
74-github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
75-github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
76-github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
77-github.com/lmittmann/tint v1.0.7 h1:D/0OqWZ0YOGZ6AyC+5Y2kD8PBEzBk6rFHVSfOqCkF9Y=
78-github.com/lmittmann/tint v1.0.7/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
79-github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
80-github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
81-github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
82-github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
83-github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
84-github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
85-github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
8623 github.com/ncruces/go-sqlite3 v0.29.0 h1:1tsLiagCoqZEfcHDeKsNSv5jvrY/Iu393pAnw2wLNJU=
8724 github.com/ncruces/go-sqlite3 v0.29.0/go.mod h1:r1hSvYKPNJ+OlUA1O3r8o9LAawzPAlqeZiIdxTBBBJ0=
8825 github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
8926 github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
90-github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
91-github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
9227 github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
9328 github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
94-github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
95-github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
96-github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
97-github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
98-github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
99-github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
10029 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
10130 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
102-github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
103-github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
104-github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
10531 github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
10632 github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
10733 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
10834 github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
10935 github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
110-github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
111-github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
112-github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
113-github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
114-github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
115-github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
116-github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
11736 github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
11837 github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
11938 github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
@@ -127,64 +46,33 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
12746 github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
12847 github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
12948 github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
130-github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
131-github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
132-github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
13349 github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
13450 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
13551 github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
13652 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
13753 github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
13854 github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
139-github.com/wailsapp/go-webview2 v1.0.21 h1:k3dtoZU4KCoN/AEIbWiPln3P2661GtA2oEgA2Pb+maA=
140-github.com/wailsapp/go-webview2 v1.0.21/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
141-github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
142-github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
143-github.com/wailsapp/wails/v3 v3.0.0-alpha.28 h1:N6U+o178bHOchBMt5IIy+O53+abzBhs+79qGRfMRwQM=
144-github.com/wailsapp/wails/v3 v3.0.0-alpha.28/go.mod h1:UZpnhYuju4saspCJrIHAvC0H5XjtKnqd26FRxJLrQ0M=
145-github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
146-github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
14755 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
14856 go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
14957 go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
15058 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
15159 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
152-golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
153-golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
154-golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
155-golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac h1:l5+whBCLH3iH2ZNHYLbAe58bo7yrN4mVcnkHDYz5vvs=
156-golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac/go.mod h1:hH+7mtFmImwwcMvScyxUhjuVHR3HGaDPMn9rMSUUbxo=
15760 golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
15861 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
15962 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
16063 golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
161-golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
162-golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
163-golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
164-golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
16564 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
16665 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
16766 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
16867 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
169-golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
170-golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
17168 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
172-golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
17369 golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
174-golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
17570 golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
176-golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
177-golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
178-golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
179-golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
18071 golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
18172 golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
18273 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
183-golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
184-golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
18574 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
18675 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
187-golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
18876 golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
18977 golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
19078 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -194,14 +82,7 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
19482 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
19583 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
19684 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
197-gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
19885 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
19986 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
200-gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
201-gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
202-gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
203-gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
204-gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
205-gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
20687 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
20788 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
M internal/cli/browse.go +1 -1
@@ -157,7 +157,7 @@ func openURL(url string) error {
157157 return openURLWithConfig(url, nil)
158158 }
159159
160-// openURLWithConfig opens a URL using our built-in Wails browser
160+// openURLWithConfig opens a URL using our built-in browser
161161 func openURLWithConfig(url string, cfg *config.Config) error {
162162 // Get the path to our own executable
163163 executable, err := os.Executable()
M internal/config/config.go +32 -15
@@ -16,11 +16,12 @@ import (
1616
1717 // Config represents the complete configuration for dumber.
1818 type Config struct {
19- Database DatabaseConfig `mapstructure:"database" yaml:"database"`
20- History HistoryConfig `mapstructure:"history" yaml:"history"`
21- SearchShortcuts map[string]SearchShortcut `mapstructure:"search_shortcuts" yaml:"search_shortcuts"`
22- Dmenu DmenuConfig `mapstructure:"dmenu" yaml:"dmenu"`
23- Logging LoggingConfig `mapstructure:"logging" yaml:"logging"`
19+ Database DatabaseConfig `mapstructure:"database" yaml:"database"`
20+ History HistoryConfig `mapstructure:"history" yaml:"history"`
21+ SearchShortcuts map[string]SearchShortcut `mapstructure:"search_shortcuts" yaml:"search_shortcuts"`
22+ Dmenu DmenuConfig `mapstructure:"dmenu" yaml:"dmenu"`
23+ Logging LoggingConfig `mapstructure:"logging" yaml:"logging"`
24+ Appearance AppearanceConfig `mapstructure:"appearance" yaml:"appearance"`
2425 }
2526
2627 // DatabaseConfig holds database-related configuration.
@@ -67,6 +68,16 @@ type LoggingConfig struct {
6768 Compress bool `mapstructure:"compress" yaml:"compress"`
6869 }
6970
71+// AppearanceConfig holds UI/rendering preferences.
72+type AppearanceConfig struct {
73+ // Default fonts for pages that do not specify fonts.
74+ SansFont string `mapstructure:"sans_font" yaml:"sans_font"`
75+ SerifFont string `mapstructure:"serif_font" yaml:"serif_font"`
76+ MonospaceFont string `mapstructure:"monospace_font" yaml:"monospace_font"`
77+ // Default font size in CSS pixels (approx).
78+ DefaultFontSize int `mapstructure:"default_font_size" yaml:"default_font_size"`
79+}
80+
7081 // Manager handles configuration loading, watching, and reloading.
7182 type Manager struct {
7283 config *Config
@@ -258,14 +269,14 @@ func (m *Manager) reload() error {
258269
259270 // setDefaults sets default configuration values in Viper.
260271 func (m *Manager) setDefaults() {
261- defaults := DefaultConfig()
272+ defaults := DefaultConfig()
262273
263274 // Database defaults
264275 m.viper.SetDefault("database.max_connections", defaults.Database.MaxConnections)
265276 m.viper.SetDefault("database.max_idle_time", defaults.Database.MaxIdleTime)
266277 m.viper.SetDefault("database.query_timeout", defaults.Database.QueryTimeout)
267278
268- // Note: Browser config removed - we build our own browser with Wails
279+ // Note: Browser config removed - we build our own browser
269280
270281 // History defaults
271282 m.viper.SetDefault("history.max_entries", defaults.History.MaxEntries)
@@ -285,14 +296,20 @@ func (m *Manager) setDefaults() {
285296 m.viper.SetDefault("dmenu.date_format", defaults.Dmenu.DateFormat)
286297 m.viper.SetDefault("dmenu.sort_by_visit_count", defaults.Dmenu.SortByVisitCount)
287298
288- // Logging defaults
289- m.viper.SetDefault("logging.level", defaults.Logging.Level)
290- m.viper.SetDefault("logging.format", defaults.Logging.Format)
291- m.viper.SetDefault("logging.filename", defaults.Logging.Filename)
292- m.viper.SetDefault("logging.max_size", defaults.Logging.MaxSize)
293- m.viper.SetDefault("logging.max_backups", defaults.Logging.MaxBackups)
294- m.viper.SetDefault("logging.max_age", defaults.Logging.MaxAge)
295- m.viper.SetDefault("logging.compress", defaults.Logging.Compress)
299+ // Logging defaults
300+ m.viper.SetDefault("logging.level", defaults.Logging.Level)
301+ m.viper.SetDefault("logging.format", defaults.Logging.Format)
302+ m.viper.SetDefault("logging.filename", defaults.Logging.Filename)
303+ m.viper.SetDefault("logging.max_size", defaults.Logging.MaxSize)
304+ m.viper.SetDefault("logging.max_backups", defaults.Logging.MaxBackups)
305+ m.viper.SetDefault("logging.max_age", defaults.Logging.MaxAge)
306+ m.viper.SetDefault("logging.compress", defaults.Logging.Compress)
307+
308+ // Appearance defaults
309+ m.viper.SetDefault("appearance.sans_font", defaults.Appearance.SansFont)
310+ m.viper.SetDefault("appearance.serif_font", defaults.Appearance.SerifFont)
311+ m.viper.SetDefault("appearance.monospace_font", defaults.Appearance.MonospaceFont)
312+ m.viper.SetDefault("appearance.default_font_size", defaults.Appearance.DefaultFontSize)
296313 }
297314
298315 // createDefaultConfig creates a default configuration file.
M internal/config/defaults.go +18 -12
@@ -7,7 +7,7 @@ import (
77
88 // DefaultConfig returns the default configuration values for dumber.
99 func DefaultConfig() *Config {
10- return &Config{
10+ return &Config{
1111 Database: DatabaseConfig{
1212 MaxConnections: 1,
1313 MaxIdleTime: time.Minute * 5,
@@ -46,16 +46,22 @@ func DefaultConfig() *Config {
4646 DateFormat: "2006-01-02 15:04",
4747 SortByVisitCount: true,
4848 },
49- Logging: LoggingConfig{
50- Level: "info",
51- Format: "text", // text or json
52- Filename: "", // empty means stdout
53- MaxSize: 100, // MB
54- MaxBackups: 3,
55- MaxAge: 7, // days
56- Compress: true,
57- },
58- }
49+ Logging: LoggingConfig{
50+ Level: "info",
51+ Format: "text", // text or json
52+ Filename: "", // empty means stdout
53+ MaxSize: 100, // MB
54+ MaxBackups: 3,
55+ MaxAge: 7, // days
56+ Compress: true,
57+ },
58+ Appearance: AppearanceConfig{
59+ SansFont: "Fira Sans",
60+ SerifFont: "Fira Sans",
61+ MonospaceFont: "Fira Code",
62+ DefaultFontSize: 16,
63+ },
64+ }
5965 }
6066
6167 // GetDefaultSearchShortcuts returns the default search shortcuts.
@@ -104,4 +110,4 @@ func GetDefaultSearchShortcuts() map[string]SearchShortcut {
104110 }
105111 }
106112
107-// Note: We build our own browser with Wails, so no external browser commands needed
113+// Note: We build our own browser, so no external browser commands needed
M main.go +390 -162
@@ -1,27 +1,27 @@
11 package main
22
33 import (
4- "context"
5- "embed"
6- "fmt"
7- "log"
8- "log/slog"
9- "os"
10- "time"
11-
12- "github.com/wailsapp/wails/v3/pkg/application"
13-
14- "github.com/bnema/dumber/internal/cli"
15- "github.com/bnema/dumber/internal/config"
16- "github.com/bnema/dumber/internal/db"
17- "github.com/bnema/dumber/services"
4+ "context"
5+ "embed"
6+ "encoding/json"
7+ "fmt"
8+ "mime"
9+ "log"
10+ "os"
11+ "runtime"
12+ neturl "net/url"
13+ "path/filepath"
14+ "strings"
15+ "strconv"
16+
17+
18+ "github.com/bnema/dumber/internal/cli"
19+ "github.com/bnema/dumber/internal/config"
20+ "github.com/bnema/dumber/internal/db"
21+ "github.com/bnema/dumber/services"
22+ "github.com/bnema/dumber/pkg/webkit"
1823 )
1924
20-// Embed the frontend assets
21-//
22-//go:embed frontend/dist
23-var assets embed.FS
24-
2525 // Build information set via ldflags
2626 var (
2727 version = "dev"
@@ -36,8 +36,8 @@ func main() {
3636 return
3737 }
3838
39- // Otherwise run the GUI browser
40- runBrowser()
39+ // Otherwise run the GUI browser
40+ runBrowser()
4141 }
4242
4343 // shouldRunCLI determines if we should run in CLI mode based on arguments
@@ -106,148 +106,376 @@ func runCLI() {
106106 if err := rootCmd.Execute(); err != nil {
107107 fmt.Fprintf(os.Stderr, "Error: %v\n", err)
108108 os.Exit(1)
109- }
109+ }
110110 }
111111
112-// runBrowser executes the browser GUI functionality
113-func runBrowser() {
114- // Initialize configuration
115- if err := config.Init(); err != nil {
116- log.Fatalf("Failed to initialize config: %v", err)
117- }
118- cfg := config.Get()
119-
120- // Initialize database
121- database, err := db.InitDB(cfg.Database.Path)
122- if err != nil {
123- log.Fatalf("Failed to initialize database: %v", err)
124- }
125- defer database.Close()
126-
127- queries := db.New(database)
128-
129- // Initialize services
130- parserService := services.NewParserService(cfg, queries)
131- browserService := services.NewBrowserService(cfg, queries)
132- configService := services.NewConfigService(cfg, "")
133-
134- // Load the injectable script into the browser service
135- if err := browserService.LoadInjectableScript(assets); err != nil {
136- log.Printf("Warning: Failed to load injectable script: %v", err)
137- }
138-
139- // Check if this is a browse command with URL
140- var directURL string
141- var initialZoom float64 = 1.0
142- if len(os.Args) >= 3 && os.Args[1] == "browse" {
143- // Parse the URL using our parser service
144- ctx := context.Background()
145- result, err := parserService.ParseInput(ctx, os.Args[2])
146- if err != nil {
147- log.Printf("Error parsing URL: %v", err)
148- } else {
149- directURL = result.URL
150- // Record the visit
151- browserService.Navigate(ctx, directURL)
152- // Load saved zoom level for the destination domain to apply natively
153- if z, zerr := browserService.GetZoomLevel(ctx, directURL); zerr == nil && z > 0 {
154- initialZoom = z
155- }
156- }
157- }
158-
159- // Create application with correct v3-alpha options
160- app := application.New(application.Options{
161- Name: "dumber-browser",
162- Description: "A smart URL launcher and browser with learning behavior",
163- LogLevel: slog.LevelInfo,
164-
165- Services: []application.Service{
166- application.NewService(parserService),
167- application.NewService(browserService),
168- application.NewService(configService),
169- },
170-
171- Assets: application.AssetOptions{
172- Handler: application.AssetFileServerFS(assets),
173- },
174-
175- Mac: application.MacOptions{
176- ApplicationShouldTerminateAfterLastWindowClosed: true,
177- },
178- })
179-
180- // Always start with the browser UI, never directly navigate to external URLs
181- startURL := "/"
182-
183- // Create the main window
184- window := app.Window.NewWithOptions(application.WebviewWindowOptions{
185- Title: "Dumber Browser",
186- URL: startURL,
187- Zoom: initialZoom,
188- // Ensure the window can take full available space in tilers like Hyprland
189- // by avoiding a too-small max size hint from GTK. Also start maximised.
190- StartState: application.WindowStateMaximised,
191- DisableResize: false,
192- MinWidth: 800,
193- MinHeight: 600,
194- // Use large max size hints so compositor isn't constrained by logical monitor size
195- MaxWidth: 10000,
196- MaxHeight: 10000,
197- // Enable devtools and add native keybindings so they work on any page
198- DevToolsEnabled: true,
199- KeyBindings: map[string]func(window application.Window){
200- // DevTools toggle (native; works on any page)
201- "F12": func(w application.Window) { w.OpenDevTools() },
202- // Native zoom shortcuts so they work on any origin
203- // Note: current implementation does not persist zoom changes made via these shortcuts.
204- // Zoom In variants
205- "cmdorctrl+plus": func(w application.Window) { w.ZoomIn() },
206- "cmdorctrl+=": func(w application.Window) { w.ZoomIn() },
207- "cmdorctrl+shift+=": func(w application.Window) { w.ZoomIn() },
208- "cmdorctrl+shift+plus": func(w application.Window) { w.ZoomIn() },
209- "cmdorctrl+-": func(w application.Window) { w.ZoomOut() },
210- "cmdorctrl+0": func(w application.Window) { w.ZoomReset() },
211- },
212- })
213-
214- // Connect the browser service to the window for title updates and script injection
215- browserService.SetWindowTitleUpdater(window)
216- browserService.SetScriptInjector(window)
217-
218- // Show the window
219- window.Show()
220-
221- // Set up navigation monitoring and script injection
222- // We'll inject the script periodically to ensure it's available on all pages
223- go func() {
224- ctx := context.Background()
225-
226- // Initial injection after a short delay to let the page load
227- time.Sleep(1 * time.Second)
228- if err := browserService.InjectControlScript(ctx); err != nil {
229- log.Printf("Initial script injection failed: %v", err)
230- }
231-
232- // More aggressive re-injection to handle navigation and CSP restrictions
233- ticker := time.NewTicker(1 * time.Second)
234- defer ticker.Stop()
235-
236- for {
237- select {
238- case <-ticker.C:
239- // Try to inject script - this will be ignored if already injected
240- if err := browserService.InjectControlScript(ctx); err != nil {
241- // Silently fail - this is expected on some pages with strict CSP
242- continue
243- }
244- }
245- }
246- }()
112+//go:embed frontend/dist
113+var assets embed.FS
247114
248- // Run the application (blocking call)
249- err = app.Run()
250- if err != nil {
251- log.Fatal(err)
252- }
115+// runBrowser launches a minimal native WebKit window and integrates services.
116+func runBrowser() {
117+ log.Printf("Starting GUI mode (webkit_cgo=%v)", webkit.IsNativeAvailable())
118+ // GTK requires all UI calls to run on the main OS thread
119+ if webkit.IsNativeAvailable() {
120+ runtime.LockOSThread()
121+ defer runtime.UnlockOSThread()
122+ }
123+
124+ if err := config.Init(); err != nil {
125+ log.Fatalf("Failed to initialize config: %v", err)
126+ }
127+ cfg := config.Get()
128+ log.Printf("Config initialized")
129+
130+ // Initialize database
131+ database, err := db.InitDB(cfg.Database.Path)
132+ if err != nil {
133+ log.Fatalf("Failed to initialize database: %v", err)
134+ }
135+ defer database.Close()
136+ queries := db.New(database)
137+ log.Printf("Database opened at %s", cfg.Database.Path)
138+
139+ // Initialize services
140+ parserService := services.NewParserService(cfg, queries)
141+ browserService := services.NewBrowserService(cfg, queries)
142+ // JS controls injection is disabled; using native shortcuts via GTK.
143+
144+ // Track current URL for zoom persistence
145+ var currentURL string
146+
147+ // Register custom scheme resolver for dumb://
148+ webkit.SetURISchemeResolver(func(uri string) (string, []byte, bool) {
149+ log.Printf("[scheme] request: %s", uri)
150+ // Known forms:
151+ // - dumb://homepage or dumb:homepage → index.html
152+ // - dumb://app/index.html, dumb://app/<path> → serve from frontend/dist/<path>
153+ // - dumb://<anything> without path → index.html
154+ u, err := neturl.Parse(uri)
155+ if err != nil || u.Scheme != "dumb" {
156+ return "", nil, false
157+ }
158+ // API routes
159+ if u.Host == "api" || strings.HasPrefix(u.Opaque, "api") || strings.HasPrefix(u.Path, "/api") {
160+ // Normalize path for opaque or hierarchical forms
161+ path := u.Path
162+ if path == "" && u.Opaque != "" {
163+ // e.g., dumb:api/config or dumb:api/history/recent
164+ parts := strings.SplitN(u.Opaque, ":", 2)
165+ if len(parts) == 2 {
166+ path = "/" + parts[1]
167+ }
168+ }
169+ if strings.HasPrefix(path, "/api/") { path = strings.TrimPrefix(path, "/api") }
170+ if path == "/api" { path = "/" }
171+ switch {
172+ case strings.HasPrefix(path, "/config"):
173+ log.Printf("[api] GET /config")
174+ // Build config info
175+ cfgPath, _ := config.GetConfigFile()
176+ info := struct {
177+ ConfigPath string `json:"config_path"`
178+ DatabasePath string `json:"database_path"`
179+ SearchShortcuts map[string]config.SearchShortcut `json:"search_shortcuts"`
180+ Appearance config.AppearanceConfig `json:"appearance"`
181+ }{
182+ ConfigPath: cfgPath,
183+ DatabasePath: cfg.Database.Path,
184+ SearchShortcuts: cfg.SearchShortcuts,
185+ Appearance: cfg.Appearance,
186+ }
187+ b, _ := json.Marshal(info)
188+ return "application/json; charset=utf-8", b, true
189+ case strings.HasPrefix(path, "/history/recent"):
190+ log.Printf("[api] GET /history/recent%s", u.RawQuery)
191+ // Parse limit
192+ q := u.Query()
193+ limit := 50
194+ if l := q.Get("limit"); l != "" {
195+ if n, err := strconv.Atoi(l); err == nil && n > 0 {
196+ limit = n
197+ }
198+ }
199+ ctx := context.Background()
200+ entries, err := browserService.GetRecentHistory(ctx, limit)
201+ if err != nil {
202+ return "application/json; charset=utf-8", []byte("[]"), true
203+ }
204+ b, _ := json.Marshal(entries)
205+ return "application/json; charset=utf-8", b, true
206+ case strings.HasPrefix(path, "/history/search"):
207+ log.Printf("[api] GET /history/search%s", u.RawQuery)
208+ q := u.Query()
209+ query := q.Get("q")
210+ limit := 50
211+ if l := q.Get("limit"); l != "" {
212+ if n, err := strconv.Atoi(l); err == nil && n > 0 {
213+ limit = n
214+ }
215+ }
216+ ctx := context.Background()
217+ entries, err := browserService.SearchHistory(ctx, query, limit)
218+ if err != nil {
219+ return "application/json; charset=utf-8", []byte("[]"), true
220+ }
221+ b, _ := json.Marshal(entries)
222+ return "application/json; charset=utf-8", b, true
223+ case strings.HasPrefix(path, "/history/stats"):
224+ log.Printf("[api] GET /history/stats")
225+ ctx := context.Background()
226+ stats, err := browserService.GetHistoryStats(ctx)
227+ if err != nil { return "application/json; charset=utf-8", []byte("{}"), true }
228+ b, _ := json.Marshal(stats)
229+ return "application/json; charset=utf-8", b, true
230+ default:
231+ return "application/json; charset=utf-8", []byte("{}"), true
232+ }
233+ }
234+ // Resolve target path inside embed FS
235+ var rel string
236+ if u.Opaque == "homepage" || (u.Host == "homepage" && (u.Path == "" || u.Path == "/")) || (u.Host == "" && (u.Path == "" || u.Path == "/")) {
237+ rel = "index.html"
238+ } else {
239+ host := u.Host
240+ p := strings.TrimPrefix(u.Path, "/")
241+ if host == "app" && p == "" {
242+ rel = "index.html"
243+ } else if host == "app" {
244+ rel = p
245+ } else if host == "homepage" && p != "" {
246+ // dumb://homepage/<asset>
247+ rel = p
248+ } else if p != "" {
249+ rel = p
250+ } else {
251+ rel = "index.html"
252+ }
253+ }
254+ log.Printf("[scheme] asset: rel=%s (host=%s path=%s)", rel, u.Host, u.Path)
255+ data, rerr := assets.ReadFile(filepath.ToSlash(filepath.Join("frontend", "dist", rel)))
256+ if rerr != nil {
257+ log.Printf("[scheme] not found: %s", rel)
258+ return "", nil, false
259+ }
260+ // Determine mime type
261+ mt := mime.TypeByExtension(strings.ToLower(filepath.Ext(rel)))
262+ if mt == "" {
263+ // Fallbacks
264+ switch strings.ToLower(filepath.Ext(rel)) {
265+ case ".js": mt = "application/javascript"
266+ case ".css": mt = "text/css"
267+ case ".svg": mt = "image/svg+xml"
268+ case ".ico": mt = "image/x-icon"
269+ default: mt = "text/html; charset=utf-8"
270+ }
271+ }
272+ return mt, data, true
273+ })
274+
275+ // Create WebKit view
276+ log.Printf("Creating WebView (native backend expected: %v)", webkit.IsNativeAvailable())
277+ dataDir, _ := config.GetDataDir()
278+ stateDir, _ := config.GetStateDir()
279+ webkitData := filepath.Join(dataDir, "webkit")
280+ webkitCache := filepath.Join(stateDir, "webkit-cache")
281+ _ = os.MkdirAll(webkitData, 0o755)
282+ _ = os.MkdirAll(webkitCache, 0o755)
283+ view, err := webkit.NewWebView(&webkit.Config{
284+ InitialURL: "dumb://homepage",
285+ ZoomDefault: 1.0,
286+ EnableDeveloperExtras: true,
287+ DataDir: webkitData,
288+ CacheDir: webkitCache,
289+ DefaultSansFont: cfg.Appearance.SansFont,
290+ DefaultSerifFont: cfg.Appearance.SerifFont,
291+ DefaultMonospaceFont: cfg.Appearance.MonospaceFont,
292+ DefaultFontSize: cfg.Appearance.DefaultFontSize,
293+ })
294+ if err != nil {
295+ log.Printf("Warning: failed to create WebView: %v", err)
296+ } else {
297+ browserService.AttachWebView(view)
298+ // Use native window as title updater
299+ if win := view.Window(); win != nil {
300+ browserService.SetWindowTitleUpdater(win)
301+ }
302+ // Persist page titles to DB when they change
303+ view.RegisterTitleChangedHandler(func(title string) {
304+ ctx := context.Background()
305+ url := view.GetCurrentURL()
306+ if url != "" && title != "" {
307+ if err := browserService.UpdatePageTitle(ctx, url, title); err != nil {
308+ log.Printf("Warning: failed to update page title: %v", err)
309+ }
310+ }
311+ })
312+ // Track current URL changes and apply saved zoom
313+ view.RegisterURIChangedHandler(func(u string) {
314+ currentURL = u
315+ if u == "" { return }
316+ ctx := context.Background()
317+ if z, err := browserService.GetZoomLevel(ctx, u); err == nil {
318+ _ = view.SetZoom(z)
319+ }
320+ })
321+ // Bridge UCM messages: navigate + history queries
322+ view.RegisterScriptMessageHandler(func(payload string) {
323+ type msg struct {
324+ Type string `json:"type"`
325+ URL string `json:"url"`
326+ Q string `json:"q"`
327+ Limit int `json:"limit"`
328+ // Wails fetch bridge
329+ ID string `json:"id"`
330+ Payload json.RawMessage `json:"payload"`
331+ }
332+ var m msg
333+ if err := json.Unmarshal([]byte(payload), &m); err != nil { return }
334+ switch m.Type {
335+ case "navigate":
336+ ctx := context.Background()
337+ res, err := parserService.ParseInput(ctx, m.URL)
338+ if err == nil {
339+ currentURL = res.URL
340+ browserService.Navigate(ctx, currentURL)
341+ _ = view.LoadURL(currentURL)
342+ if z, zerr := browserService.GetZoomLevel(ctx, currentURL); zerr == nil { _ = view.SetZoom(z) }
343+ }
344+ case "query":
345+ ctx := context.Background()
346+ lim := m.Limit
347+ if lim <= 0 || lim > 25 { lim = 10 }
348+ entries, err := browserService.SearchHistory(ctx, m.Q, lim)
349+ if err != nil { return }
350+ // Map to lightweight items
351+ type item struct { URL string `json:"url"`; Favicon string `json:"favicon"` }
352+ buildFavicon := func(raw string) string {
353+ u, err := parserService.ParseInput(ctx, raw)
354+ if err != nil || u.URL == "" { return "" }
355+ parsed, perr := neturl.Parse(u.URL)
356+ if perr != nil || parsed.Host == "" { return "" }
357+ scheme := parsed.Scheme
358+ if scheme == "" { scheme = "https" }
359+ return scheme + "://" + parsed.Host + "/favicon.ico"
360+ }
361+ items := make([]item, 0, len(entries))
362+ for _, e := range entries { items = append(items, item{URL: e.URL, Favicon: buildFavicon(e.URL)}) }
363+ b, _ := json.Marshal(items)
364+ // Update suggestions in page
365+ _ = view.InjectScript("window.__dumber_setSuggestions && window.__dumber_setSuggestions(" + string(b) + ")")
366+ case "wails":
367+ // Handle Wails runtime fetch bridge calls for homepage
368+ // Payload contains { methodID, methodName?, args }
369+ var p struct {
370+ MethodID uint32 `json:"methodID"`
371+ MethodName string `json:"methodName"`
372+ Args json.RawMessage `json:"args"`
373+ }
374+ if err := json.Unmarshal(m.Payload, &p); err != nil { return }
375+ // Only implement the IDs we need
376+ switch p.MethodID {
377+ case 3708519028: // BrowserService.GetRecentHistory(limit)
378+ var args []interface{}
379+ _ = json.Unmarshal(p.Args, &args)
380+ limit := 50
381+ if len(args) > 0 {
382+ if f, ok := args[0].(float64); ok { limit = int(f) }
383+ }
384+ ctx := context.Background()
385+ entries, err := browserService.GetRecentHistory(ctx, limit)
386+ if err != nil { return }
387+ resp, _ := json.Marshal(entries)
388+ _ = view.InjectScript("window.__dumber_wails_resolve('" + m.ID + "', " + string(resp) + ")")
389+ case 4078533762: // BrowserService.GetSearchShortcuts()
390+ ctx := context.Background()
391+ shortcuts, err := browserService.GetSearchShortcuts(ctx)
392+ if err != nil { return }
393+ resp, _ := json.Marshal(shortcuts)
394+ _ = view.InjectScript("window.__dumber_wails_resolve('" + m.ID + "', " + string(resp) + ")")
395+ default:
396+ // Return empty JSON to avoid breaking UI
397+ _ = view.InjectScript("window.__dumber_wails_resolve('" + m.ID + "', '{}')")
398+ }
399+ case "theme":
400+ if m.URL != "" { log.Printf("[theme] page reported color-scheme: %s", m.URL) }
401+ }
402+ })
403+ log.Printf("Showing WebView window…")
404+ if err := view.Show(); err != nil {
405+ log.Printf("Warning: failed to show WebView: %v", err)
406+ } else {
407+ if !webkit.IsNativeAvailable() {
408+ log.Printf("Notice: running without webkit_cgo tag — no native window will be displayed.")
409+ }
410+ }
411+ }
412+
413+ // Parse browse argument if present
414+ if len(os.Args) >= 3 && os.Args[1] == "browse" {
415+ log.Printf("Browse command detected: %s", os.Args[2])
416+ ctx := context.Background()
417+ result, err := parserService.ParseInput(ctx, os.Args[2])
418+ if err == nil {
419+ currentURL = result.URL
420+ log.Printf("Parsed input → URL: %s", currentURL)
421+ browserService.Navigate(ctx, currentURL)
422+ if view != nil {
423+ log.Printf("Loading URL in WebView: %s", currentURL)
424+ if err := view.LoadURL(currentURL); err != nil {
425+ log.Printf("Warning: failed to load URL: %v", err)
426+ }
427+ if z, zerr := browserService.GetZoomLevel(ctx, currentURL); zerr == nil {
428+ log.Printf("Applying stored zoom: %.2f", z)
429+ if err := view.SetZoom(z); err != nil {
430+ log.Printf("Warning: failed to set zoom: %v", err)
431+ }
432+ }
433+ // No JS injection; native accelerators handle zoom/devtools.
434+ }
435+ }
436+ }
437+
438+ // Register basic keyboard shortcuts on the native view to preserve behavior
439+ if view != nil {
440+ ctx := context.Background()
441+ // DevTools
442+ _ = view.RegisterKeyboardShortcut("F12", func() { log.Printf("Shortcut: F12 (devtools)"); _ = view.ShowDevTools() })
443+ // Omnibox (Ctrl+L): rely on injected script listener
444+ _ = view.RegisterKeyboardShortcut("cmdorctrl+l", func() {
445+ log.Printf("Shortcut: Omnibox toggle")
446+ _ = view.InjectScript("window.__dumber_toggle && window.__dumber_toggle()")
447+ })
448+ // Zoom In
449+ _ = view.RegisterKeyboardShortcut("cmdorctrl+=", func() {
450+ log.Printf("Shortcut: Zoom In")
451+ if currentURL == "" { return }
452+ if z, err := browserService.ZoomIn(ctx, currentURL); err == nil { _ = view.SetZoom(z) }
453+ })
454+ _ = view.RegisterKeyboardShortcut("cmdorctrl+plus", func() {
455+ log.Printf("Shortcut: Zoom In")
456+ if currentURL == "" { return }
457+ if z, err := browserService.ZoomIn(ctx, currentURL); err == nil { _ = view.SetZoom(z) }
458+ })
459+ // Zoom Out
460+ _ = view.RegisterKeyboardShortcut("cmdorctrl+-", func() {
461+ log.Printf("Shortcut: Zoom Out")
462+ if currentURL == "" { return }
463+ if z, err := browserService.ZoomOut(ctx, currentURL); err == nil { _ = view.SetZoom(z) }
464+ })
465+ // Zoom Reset
466+ _ = view.RegisterKeyboardShortcut("cmdorctrl+0", func() {
467+ log.Printf("Shortcut: Zoom Reset")
468+ if currentURL == "" { return }
469+ if z, err := browserService.ResetZoom(ctx, currentURL); err == nil { _ = view.SetZoom(z) }
470+ })
471+ }
472+
473+ // Enter GTK main loop only when native backend is available.
474+ if webkit.IsNativeAvailable() {
475+ log.Printf("Entering GTK main loop…")
476+ webkit.RunMainLoop()
477+ log.Printf("GTK main loop exited")
478+ } else {
479+ log.Printf("Not entering GUI loop (non-CGO build)")
480+ }
253481 }
A pkg/webkit/build_cgo_stub.go +15 -0
@@ -0,0 +1,15 @@
1+//go:build webkit_cgo
2+
3+package webkit
4+
5+/*
6+#cgo pkg-config: webkit2gtk-4.0 gtk+-3.0
7+#include <webkit2/webkit2.h>
8+#include <gtk/gtk.h>
9+*/
10+import "C"
11+
12+// This file documents intended CGO linkage. It is excluded from builds unless
13+// the 'webkit_cgo' build tag is specified. Real implementations will move APIs
14+// from the stubs into CGO-backed functions and types.
15+
A pkg/webkit/build_flags_cgo.go +7 -0
@@ -0,0 +1,7 @@
1+//go:build webkit_cgo
2+
3+package webkit
4+
5+// IsNativeAvailable reports whether the native WebKit2GTK backend is compiled in.
6+func IsNativeAvailable() bool { return true }
7+
A pkg/webkit/build_flags_stub.go +8 -0
@@ -0,0 +1,8 @@
1+//go:build !webkit_cgo
2+
3+package webkit
4+
5+// IsNativeAvailable reports whether the native WebKit2GTK backend is compiled in.
6+// In non-CGO builds, this returns false and WebView methods are logical no-ops.
7+func IsNativeAvailable() bool { return false }
8+
A pkg/webkit/devtools_cgo.go +26 -0
@@ -0,0 +1,26 @@
1+//go:build webkit_cgo
2+
3+package webkit
4+
5+/*
6+#cgo pkg-config: webkit2gtk-4.0 gtk+-3.0
7+#include <webkit2/webkit2.h>
8+*/
9+import "C"
10+
11+func (w *WebView) ShowDevTools() error {
12+ if w == nil || w.native == nil || w.native.wv == nil { return ErrNotImplemented }
13+ insp := C.webkit_web_view_get_inspector(w.native.wv)
14+ if insp == nil { return ErrNotImplemented }
15+ C.webkit_web_inspector_show(insp)
16+ return nil
17+}
18+
19+func (w *WebView) CloseDevTools() error {
20+ if w == nil || w.native == nil || w.native.wv == nil { return ErrNotImplemented }
21+ insp := C.webkit_web_view_get_inspector(w.native.wv)
22+ if insp == nil { return ErrNotImplemented }
23+ C.webkit_web_inspector_close(insp)
24+ return nil
25+}
26+
A pkg/webkit/errors.go +7 -0
@@ -0,0 +1,7 @@
1+// Package webkit provides a WebKit2GTK-backed browser view and window.
2+package webkit
3+
4+import "errors"
5+
6+// ErrNotImplemented is returned by stub implementations pending WebKit2GTK migration.
7+var ErrNotImplemented = errors.New("webkit: not implemented")
A pkg/webkit/keyboard.go +34 -0
@@ -0,0 +1,34 @@
1+//go:build !webkit_cgo
2+
3+package webkit
4+
5+import "fmt"
6+
7+// RegisterKeyboardShortcut binds a global or window-scoped accelerator to a callback.
8+func (w *WebView) RegisterKeyboardShortcut(accel string, callback func()) error {
9+ if w == nil || w.destroyed {
10+ return ErrNotImplemented
11+ }
12+ if accel == "" || callback == nil {
13+ return fmt.Errorf("invalid shortcut registration")
14+ }
15+ // Placeholder: in real impl, bind GTK accelerator; for now, store for tests.
16+ if shortcuts := getShortcutRegistry(w); shortcuts != nil {
17+ shortcuts[accel] = callback
18+ }
19+ return nil
20+}
21+
22+// internal registry per view while no GTK binding exists.
23+type shortcutRegistry map[string]func()
24+
25+var viewShortcuts = make(map[*WebView]shortcutRegistry)
26+
27+func getShortcutRegistry(w *WebView) shortcutRegistry {
28+ reg, ok := viewShortcuts[w]
29+ if !ok {
30+ reg = make(shortcutRegistry)
31+ viewShortcuts[w] = reg
32+ }
33+ return reg
34+}
A pkg/webkit/keyboard_cgo.go +176 -0
@@ -0,0 +1,176 @@
1+//go:build webkit_cgo
2+
3+package webkit
4+
5+/*
6+#cgo pkg-config: gtk+-3.0
7+#include <gtk/gtk.h>
8+#include <gdk/gdk.h>
9+*/
10+import "C"
11+
12+import (
13+ "fmt"
14+ "log"
15+ "sync/atomic"
16+ "sync"
17+ "time"
18+)
19+
20+// Registry of accelerators per view id.
21+type shortcutRegistry map[string]func()
22+
23+var (
24+ regMu sync.RWMutex
25+ viewShortcuts = make(map[uintptr]shortcutRegistry)
26+ viewIDCounter uint64
27+ viewByID = make(map[uintptr]*WebView)
28+ lastKeyTime = make(map[uintptr]map[string]time.Time)
29+)
30+
31+// RegisterKeyboardShortcut registers a callback under an accelerator string.
32+func (w *WebView) RegisterKeyboardShortcut(accel string, callback func()) error {
33+ if w == nil || w.destroyed {
34+ return ErrNotImplemented
35+ }
36+ if accel == "" || callback == nil {
37+ return fmt.Errorf("invalid shortcut registration")
38+ }
39+ regMu.Lock()
40+ if _, ok := viewShortcuts[w.id]; !ok {
41+ viewShortcuts[w.id] = make(shortcutRegistry)
42+ }
43+ viewShortcuts[w.id][accel] = callback
44+ regMu.Unlock()
45+ return nil
46+}
47+
48+//export goOnKeyPress
49+func goOnKeyPress(id C.ulong, keyval C.guint, state C.GdkModifierType) {
50+ // Normalize and dispatch
51+ uid := uintptr(id)
52+ kv := uint(keyval)
53+ st := uint(state)
54+ dispatchAccelerator(uid, kv, st)
55+}
56+
57+func dispatchAccelerator(uid uintptr, keyval uint, state uint) {
58+ ctrl := (state & uint(C.GDK_CONTROL_MASK)) != 0
59+ // Map keyval to string names
60+ var keyName string
61+ switch keyval {
62+ case uint(C.GDK_KEY_plus), uint(C.GDK_KEY_KP_Add):
63+ keyName = "plus"
64+ case uint(C.GDK_KEY_equal):
65+ keyName = "="
66+ case uint(C.GDK_KEY_minus), uint(C.GDK_KEY_KP_Subtract):
67+ keyName = "-"
68+ case uint(C.GDK_KEY_0), uint(C.GDK_KEY_KP_0):
69+ keyName = "0"
70+ case uint(C.GDK_KEY_F12):
71+ keyName = "F12"
72+ default:
73+ return
74+ }
75+
76+ // Candidate accelerator strings in order
77+ candidates := []string{keyName}
78+ if ctrl {
79+ candidates = append([]string{"cmdorctrl+" + keyName, "ctrl+" + keyName}, candidates...)
80+ }
81+
82+ regMu.RLock()
83+ reg := viewShortcuts[uid]
84+ regMu.RUnlock()
85+ if reg == nil {
86+ return
87+ }
88+ for _, name := range candidates {
89+ if cb, ok := reg[name]; ok {
90+ // Debounce duplicates within 120ms for the same view+key
91+ regMu.Lock()
92+ if _, ok := lastKeyTime[uid]; !ok { lastKeyTime[uid] = make(map[string]time.Time) }
93+ last := lastKeyTime[uid][name]
94+ now := time.Now()
95+ if now.Sub(last) < 120*time.Millisecond {
96+ // Skip duplicate
97+ regMu.Unlock()
98+ return
99+ }
100+ lastKeyTime[uid][name] = now
101+ regMu.Unlock()
102+ log.Printf("[accelerator] %s", name)
103+ cb()
104+ break
105+ }
106+ }
107+}
108+
109+func nextViewID() uintptr {
110+ return uintptr(atomic.AddUint64(&viewIDCounter, 1))
111+}
112+
113+func registerView(id uintptr, w *WebView) {
114+ regMu.Lock()
115+ viewByID[id] = w
116+ regMu.Unlock()
117+}
118+
119+func unregisterView(id uintptr) {
120+ regMu.Lock()
121+ delete(viewByID, id)
122+ delete(viewShortcuts, id)
123+ regMu.Unlock()
124+}
125+
126+//export goOnUcmMessage
127+func goOnUcmMessage(id C.ulong, json *C.char) {
128+ uid := uintptr(id)
129+ regMu.RLock()
130+ vw := viewByID[uid]
131+ regMu.RUnlock()
132+ if vw == nil || json == nil { return }
133+ goPayload := C.GoString(json)
134+ vw.dispatchScriptMessage(goPayload)
135+}
136+
137+//export goOnTitleChanged
138+func goOnTitleChanged(id C.ulong, ctitle *C.char) {
139+ uid := uintptr(id)
140+ regMu.RLock()
141+ vw := viewByID[uid]
142+ regMu.RUnlock()
143+ if vw == nil || ctitle == nil { return }
144+ title := C.GoString(ctitle)
145+ vw.dispatchTitleChanged(title)
146+}
147+
148+//export goOnURIChanged
149+func goOnURIChanged(id C.ulong, curi *C.char) {
150+ uid := uintptr(id)
151+ regMu.RLock()
152+ vw := viewByID[uid]
153+ regMu.RUnlock()
154+ if vw == nil || curi == nil { return }
155+ uri := C.GoString(curi)
156+ vw.dispatchURIChanged(uri)
157+}
158+
159+//export goOnButtonPress
160+func goOnButtonPress(id C.ulong, button C.guint, state C.GdkModifierType) {
161+ uid := uintptr(id)
162+ btn := uint(button)
163+ log.Printf("[mouse] button=%d state=0x%x", btn, uint(state))
164+ regMu.RLock()
165+ vw := viewByID[uid]
166+ regMu.RUnlock()
167+ if vw == nil {
168+ return
169+ }
170+ switch btn {
171+ case 8: // Back button on many mice
172+ _ = vw.GoBack()
173+ case 9: // Forward button on many mice
174+ _ = vw.GoForward()
175+ }
176+}
A pkg/webkit/loop.go +14 -0
@@ -0,0 +1,14 @@
1+//go:build !webkit_cgo
2+
3+package webkit
4+
5+import "log"
6+
7+// RunMainLoop is a no-op in non-CGO builds.
8+func RunMainLoop() {
9+ log.Printf("[webkit] RunMainLoop (non-CGO): no GUI loop available")
10+}
11+
12+// QuitMainLoop is a no-op in non-CGO builds.
13+func QuitMainLoop() {}
14+
A pkg/webkit/loop_cgo.go +20 -0
@@ -0,0 +1,20 @@
1+//go:build webkit_cgo
2+
3+package webkit
4+
5+/*
6+#cgo pkg-config: gtk+-3.0
7+#include <gtk/gtk.h>
8+*/
9+import "C"
10+
11+// RunMainLoop enters the GTK main loop and blocks until quit.
12+func RunMainLoop() {
13+ C.gtk_main()
14+}
15+
16+// QuitMainLoop requests the GTK main loop to exit.
17+func QuitMainLoop() {
18+ C.gtk_main_quit()
19+}
20+
A pkg/webkit/scheme_cgo.go +50 -0
@@ -0,0 +1,50 @@
1+//go:build webkit_cgo
2+
3+package webkit
4+
5+/*
6+#include <stdlib.h>
7+*/
8+import "C"
9+
10+import (
11+ "unsafe"
12+)
13+
14+// Resolver provides bytes and mime for a given custom URI.
15+type SchemeResolver func(uri string) (mime string, data []byte, ok bool)
16+
17+var globalResolver SchemeResolver
18+
19+// SetURISchemeResolver sets the resolver used by the custom URI scheme handler.
20+func SetURISchemeResolver(r SchemeResolver) { globalResolver = r }
21+
22+//export goResolveURIScheme
23+func goResolveURIScheme(curi *C.char, outLen *C.size_t, outMime **C.char) unsafe.Pointer {
24+ if curi == nil || outLen == nil || outMime == nil { return nil }
25+ uri := C.GoString(curi)
26+ if globalResolver == nil {
27+ *outLen = 0
28+ *outMime = nil
29+ return nil
30+ }
31+ mime, data, ok := globalResolver(uri)
32+ if !ok || len(data) == 0 {
33+ *outLen = 0
34+ *outMime = nil
35+ return nil
36+ }
37+ buf := C.malloc(C.size_t(len(data)))
38+ if buf == nil {
39+ *outLen = 0
40+ *outMime = nil
41+ return nil
42+ }
43+ // Copy data into C buffer
44+ dst := (*[1 << 30]byte)(buf)[:len(data):len(data)]
45+ copy(dst, data)
46+ *outLen = C.size_t(len(data))
47+ *outMime = C.CString(mime)
48+ return buf
49+}
50+
A pkg/webkit/script.go +13 -0
@@ -0,0 +1,13 @@
1+//go:build !webkit_cgo
2+
3+package webkit
4+
5+// InjectScript evaluates JavaScript in the active WebView context.
6+func (w *WebView) InjectScript(js string) error {
7+ if w == nil || w.destroyed {
8+ return ErrNotImplemented
9+ }
10+ // Store last script for potential debugging/testing.
11+ _ = js
12+ return nil
13+}
A pkg/webkit/script_cgo.go +33 -0
@@ -0,0 +1,33 @@
1+//go:build webkit_cgo
2+
3+package webkit
4+
5+/*
6+#cgo pkg-config: webkit2gtk-4.0 gtk+-3.0
7+#include <stdlib.h>
8+#include <webkit2/webkit2.h>
9+*/
10+import "C"
11+
12+import "unsafe"
13+
14+func (w *WebView) InjectScript(js string) error {
15+ if w == nil || w.destroyed || w.native == nil || w.native.wv == nil {
16+ return ErrNotImplemented
17+ }
18+ cjs := C.CString(js)
19+ defer C.free(unsafe.Pointer(cjs))
20+ // Fire-and-forget evaluation using the modern API
21+ // length: -1 (NUL-terminated), world_name/source_uri: NULL, starting_line_number: 0
22+ C.webkit_web_view_evaluate_javascript(
23+ w.native.wv,
24+ (*C.gchar)(cjs),
25+ C.gssize(-1),
26+ nil, // world_name
27+ nil, // source_uri
28+ nil, // cancellable
29+ nil, // callback
30+ nil, // user_data
31+ )
32+ return nil
33+}
A pkg/webkit/settings.go +28 -0
@@ -0,0 +1,28 @@
1+package webkit
2+
3+// Config holds initialization settings for the WebKit-based browser components.
4+// This will map to WebKit2GTK and GTK settings in the real implementation.
5+type Config struct {
6+ // InitialURL is the first URL to load when creating a WebView.
7+ InitialURL string
8+
9+ // UserAgent allows overriding the default user agent string.
10+ UserAgent string
11+
12+ // EnableDeveloperExtras enables devtools/inspector.
13+ EnableDeveloperExtras bool
14+
15+ // ZoomDefault sets an initial zoom factor (1.0 = 100%).
16+ ZoomDefault float64
17+
18+ // DataDir is the base directory for persistent website data (cookies, localStorage, etc.).
19+ DataDir string
20+ // CacheDir is the base directory for cache data.
21+ CacheDir string
22+
23+ // Fonts apply to pages that don't specify fonts (browser defaults).
24+ DefaultSansFont string
25+ DefaultSerifFont string
26+ DefaultMonospaceFont string
27+ DefaultFontSize int // CSS px (~points)
28+}
A pkg/webkit/ucm_js.go +86 -0
@@ -0,0 +1,86 @@
1+//go:build webkit_cgo
2+
3+package webkit
4+
5+// ucmOmniboxScript is injected at document-start via WebKit UserContentManager.
6+// It renders a minimal overlay with an input and a suggestions list, handles
7+// Ctrl+L to toggle, Enter to navigate, and posts search queries to native.
8+const ucmOmniboxScript = `(() => {
9+ try {
10+ if (window.__dumber_omnibox_loaded) return; // idempotent
11+ window.__dumber_omnibox_loaded = true;
12+
13+ const H = {
14+ el: null,
15+ input: null,
16+ list: null,
17+ visible: false,
18+ suggestions: [],
19+ debounceTimer: 0,
20+ post(msg){ try { window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.dumber && window.webkit.messageHandlers.dumber.postMessage(JSON.stringify(msg)); } catch(_){} },
21+ render(){
22+ if (!H.el) H.mount();
23+ H.el.style.display = H.visible ? 'block' : 'none';
24+ if (H.visible) H.input.focus();
25+ },
26+ mount(){
27+ const root = document.createElement('div');
28+ root.id = 'dumber-omnibox-root';
29+ root.style.cssText = 'position:fixed;inset:0;z-index:2147483647;display:none;';
30+ const box = document.createElement('div');
31+ box.style.cssText = 'max-width:720px;margin:8vh auto;padding:8px 10px;background:#1b1b1b;color:#eee;border:1px solid #444;border-radius:8px;box-shadow:0 10px 30px rgba(0,0,0,.6);font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,\"Helvetica Neue\",Arial,sans-serif;';
32+ const input = document.createElement('input');
33+ input.type = 'text';
34+ input.placeholder = 'Type URL or search…';
35+ input.style.cssText = 'width:100%;padding:10px 12px;border-radius:6px;border:1px solid #555;background:#121212;color:#eee;font-size:16px;outline:none;';
36+ const list = document.createElement('div');
37+ list.style.cssText = 'margin-top:8px;max-height:50vh;overflow:auto;border-top:1px solid #333;';
38+ box.appendChild(input); box.appendChild(list); root.appendChild(box); document.documentElement.appendChild(root);
39+ input.addEventListener('keydown', (e)=>{
40+ if (e.key === 'Escape'){ H.toggle(false); }
41+ else if (e.key === 'Enter'){
42+ const pick = H.suggestions && H.suggestions[H.selectedIndex|0];
43+ const v = (pick && pick.url) || input.value || '';
44+ if (v) H.post({type:'navigate', url:v});
45+ H.toggle(false);
46+ } else if (e.key === 'ArrowDown' || e.key === 'ArrowUp'){
47+ e.preventDefault();
48+ const n = H.suggestions.length;
49+ if (n){
50+ H.selectedIndex = (H.selectedIndex||0) + (e.key==='ArrowDown'?1:-1);
51+ if (H.selectedIndex<0) H.selectedIndex = n-1; if (H.selectedIndex>=n) H.selectedIndex = 0;
52+ H.paintList();
53+ }
54+ }
55+ });
56+ input.addEventListener('input', ()=>{
57+ clearTimeout(H.debounceTimer);
58+ const q = input.value;
59+ H.debounceTimer = setTimeout(()=> H.post({type:'query', q, limit:10}), 120);
60+ });
61+ H.el = root; H.input = input; H.list = list; H.selectedIndex = -1; H.paintList();
62+ },
63+ paintList(){
64+ const list = H.list; if (!list) return;
65+ list.textContent = '';
66+ H.suggestions.forEach((s, i)=>{
67+ const item = document.createElement('div');
68+ item.style.cssText = 'padding:8px 10px;display:flex;gap:10px;align-items:center;cursor:pointer;border-bottom:1px solid #2a2a2a;'+(i===H.selectedIndex?'background:#0a0a0a;':'');
69+ const url = document.createElement('div'); url.textContent = s.url || ''; url.style.cssText = 'flex:1;color:#9ad;word-break:break-all;';
70+ const title = document.createElement('div'); title.textContent = s.title || ''; title.style.cssText = 'flex:1;color:#ccc;opacity:.9;';
71+ item.appendChild(title); item.appendChild(url);
72+ item.addEventListener('click',()=>{ H.post({type:'navigate', url:s.url}); H.toggle(false); });
73+ list.appendChild(item);
74+ });
75+ },
76+ toggle(v){ H.visible = (typeof v==='boolean')? v : !H.visible; H.render(); },
77+ setSuggestions(arr){ H.suggestions = Array.isArray(arr)? arr: []; H.selectedIndex = -1; H.paintList(); }
78+ };
79+ window.addEventListener('keydown', (e)=>{
80+ if ((e.ctrlKey||e.metaKey) && (e.key==='l' || e.key==='L')) { e.preventDefault(); H.toggle(true); }
81+ }, true);
82+ // Allow native side to update suggestions or toggle
83+ window.__dumber_setSuggestions = (arr)=> H.setSuggestions(arr);
84+ window.__dumber_toggle = ()=> H.toggle();
85+ } catch (e) { /* no-op */ }
86+})();`
A pkg/webkit/webview.go +118 -0
@@ -0,0 +1,118 @@
1+//go:build !webkit_cgo
2+
3+package webkit
4+
5+import (
6+ "fmt"
7+ "log"
8+)
9+
10+// WebView represents a browser view powered by WebKit2GTK.
11+// Methods are currently stubs returning ErrNotImplemented to satisfy TDD ordering.
12+type WebView struct {
13+ config *Config
14+ visible bool
15+ zoom float64
16+ url string
17+ destroyed bool
18+ window *Window
19+ msgHandler func(payload string)
20+ titleHandler func(title string)
21+ uriHandler func(uri string)
22+}
23+
24+// NewWebView constructs a new WebView instance.
25+func NewWebView(cfg *Config) (*WebView, error) {
26+ if cfg == nil {
27+ cfg = &Config{}
28+ }
29+ log.Printf("[webkit] NewWebView (non-CGO stub) — UI will not be displayed. Build with -tags=webkit_cgo for native window.")
30+ wv := &WebView{config: cfg}
31+ if cfg.ZoomDefault <= 0 {
32+ wv.zoom = 1.0
33+ } else {
34+ wv.zoom = cfg.ZoomDefault
35+ }
36+ // Construction succeeds; create a logical window placeholder.
37+ wv.window = &Window{Title: "Dumber Browser"}
38+ return wv, nil
39+}
40+
41+// LoadURL navigates the webview to the specified URL.
42+func (w *WebView) LoadURL(rawURL string) error {
43+ if w == nil || w.destroyed {
44+ return ErrNotImplemented
45+ }
46+ if rawURL == "" {
47+ return fmt.Errorf("url cannot be empty")
48+ }
49+ // Minimal validation; actual navigation handled by CGO bridge later.
50+ w.url = rawURL
51+ log.Printf("[webkit] LoadURL (non-CGO stub): %s", rawURL)
52+ return nil
53+}
54+
55+// Show makes the WebView visible.
56+func (w *WebView) Show() error {
57+ if w == nil || w.destroyed {
58+ return ErrNotImplemented
59+ }
60+ w.visible = true
61+ log.Printf("[webkit] Show (non-CGO stub): no native window will appear")
62+ return nil
63+}
64+
65+// Hide hides the WebView window.
66+func (w *WebView) Hide() error {
67+ if w == nil || w.destroyed {
68+ return ErrNotImplemented
69+ }
70+ w.visible = false
71+ return nil
72+}
73+
74+// Destroy releases native resources.
75+func (w *WebView) Destroy() error {
76+ if w == nil {
77+ return ErrNotImplemented
78+ }
79+ w.destroyed = true
80+ return nil
81+}
82+
83+// Window returns the associated native window wrapper (non-nil).
84+func (w *WebView) Window() *Window { return w.window }
85+
86+// GetCurrentURL returns the last requested URL (non-CGO build approximation).
87+func (w *WebView) GetCurrentURL() string { return w.url }
88+
89+// GoBack is not supported in the non-CGO stub.
90+func (w *WebView) GoBack() error { return ErrNotImplemented }
91+// GoForward is not supported in the non-CGO stub.
92+func (w *WebView) GoForward() error { return ErrNotImplemented }
93+
94+// ShowDevTools is a no-op in the non-CGO build.
95+func (w *WebView) ShowDevTools() error { return nil }
96+// CloseDevTools is a no-op in the non-CGO build.
97+func (w *WebView) CloseDevTools() error { return nil }
98+
99+// RegisterScriptMessageHandler registers a callback invoked when the content script posts a message.
100+func (w *WebView) RegisterScriptMessageHandler(cb func(payload string)) { w.msgHandler = cb }
101+
102+func (w *WebView) dispatchScriptMessage(payload string) {
103+ if w != nil && w.msgHandler != nil { w.msgHandler(payload) }
104+}
105+
106+// RegisterTitleChangedHandler registers a callback invoked when the page title changes.
107+func (w *WebView) RegisterTitleChangedHandler(cb func(title string)) { w.titleHandler = cb }
108+
109+func (w *WebView) dispatchTitleChanged(title string) {
110+ if w != nil && w.titleHandler != nil { w.titleHandler(title) }
111+}
112+
113+// RegisterURIChangedHandler registers a callback invoked when the current page URI changes.
114+func (w *WebView) RegisterURIChangedHandler(cb func(uri string)) { w.uriHandler = cb }
115+
116+func (w *WebView) dispatchURIChanged(uri string) {
117+ if w != nil && w.uriHandler != nil { w.uriHandler(uri) }
118+}
A pkg/webkit/webview_cgo.go +443 -0
@@ -0,0 +1,443 @@
1+//go:build webkit_cgo
2+
3+package webkit
4+
5+/*
6+#cgo pkg-config: webkit2gtk-4.0 gtk+-3.0 javascriptcoregtk-4.0
7+#include <stdlib.h>
8+#include <gtk/gtk.h>
9+#include <webkit2/webkit2.h>
10+#include <glib-object.h>
11+#include <gdk/gdk.h>
12+#include <jsc/jsc.h>
13+#include <glib.h>
14+#include <gio/gio.h>
15+
16+static GtkWidget* new_window() { return gtk_window_new(GTK_WINDOW_TOPLEVEL); }
17+static void connect_destroy_quit(GtkWidget* w) { g_signal_connect(w, "destroy", G_CALLBACK(gtk_main_quit), NULL); }
18+static WebKitWebView* as_webview(GtkWidget* w) { return WEBKIT_WEB_VIEW(w); }
19+static WebKitWebsiteDataManager* make_wdm(const gchar* data, const gchar* cache){
20+ return webkit_website_data_manager_new("base-data-directory", data, "base-cache-directory", cache, NULL);
21+}
22+
23+static gboolean gtk_prefers_dark() {
24+ GtkSettings* settings = gtk_settings_get_default();
25+ if (!settings) return FALSE;
26+ gboolean prefer = FALSE;
27+ g_object_get(settings, "gtk-application-prefer-dark-theme", &prefer, NULL);
28+ return prefer;
29+}
30+
31+// Note: preferred color scheme is handled via a user script injection to support
32+// older WebKitGTK versions without the color-scheme API. See enableUserContentManager.
33+
34+// Forward declaration of Go callback
35+extern void goOnKeyPress(unsigned long id, guint keyval, GdkModifierType state);
36+extern void goOnButtonPress(unsigned long id, guint button, GdkModifierType state);
37+extern void goOnUcmMessage(unsigned long id, const char* json);
38+extern void goOnTitleChanged(unsigned long id, const char* title);
39+extern void goOnURIChanged(unsigned long id, const char* uri);
40+extern void* goResolveURIScheme(char* uri, size_t* out_len, char** out_mime);
41+
42+gboolean on_key_press(GtkWidget* widget, GdkEventKey* event, gpointer user_data) {
43+ (void)widget;
44+ unsigned long id = (unsigned long)user_data;
45+ goOnKeyPress(id, event->keyval, event->state);
46+ return FALSE; // Propagate
47+}
48+
49+gboolean on_button_press(GtkWidget* widget, GdkEventButton* event, gpointer user_data) {
50+ (void)widget;
51+ unsigned long id = (unsigned long)user_data;
52+ goOnButtonPress(id, event->button, event->state);
53+ return FALSE;
54+}
55+
56+void on_title_notify(GObject* obj, GParamSpec* pspec, gpointer user_data) {
57+ (void)pspec;
58+ WebKitWebView* view = WEBKIT_WEB_VIEW(obj);
59+ const gchar* title = webkit_web_view_get_title(view);
60+ GtkWindow* win = GTK_WINDOW(user_data);
61+ if (title && win) {
62+ gtk_window_set_title(win, title);
63+ }
64+}
65+
66+void on_title_notify_id(GObject* obj, GParamSpec* pspec, gpointer user_data) {
67+ (void)pspec; (void)obj;
68+ const gchar* title = webkit_web_view_get_title(WEBKIT_WEB_VIEW(obj));
69+ if (title) { goOnTitleChanged((unsigned long)user_data, title); }
70+}
71+
72+void on_uri_notify(GObject* obj, GParamSpec* pspec, gpointer user_data) {
73+ (void)pspec; (void)obj;
74+ const gchar* uri = webkit_web_view_get_uri(WEBKIT_WEB_VIEW(obj));
75+ if (uri) { goOnURIChanged((unsigned long)user_data, uri); }
76+}
77+
78+static gchar* js_result_to_utf8(WebKitJavascriptResult* r) {
79+ JSCValue* v = webkit_javascript_result_get_js_value(r);
80+ // Returns newly-allocated string; free with g_free()
81+ return jsc_value_to_string(v);
82+}
83+
84+void on_ucm_message(WebKitUserContentManager* m, WebKitJavascriptResult* r, gpointer user_data) {
85+ (void)m;
86+ unsigned long id = (unsigned long)user_data;
87+ gchar* s = js_result_to_utf8(r);
88+ goOnUcmMessage(id, s);
89+ g_free(s);
90+}
91+
92+void on_uri_scheme(WebKitURISchemeRequest* request, gpointer user_data) {
93+ (void)user_data;
94+ const gchar* uri = webkit_uri_scheme_request_get_uri(request);
95+ size_t n = 0;
96+ char* mime = NULL;
97+ void* buf = goResolveURIScheme((char*)uri, &n, &mime);
98+ if (buf && n > 0 && mime) {
99+ GInputStream* stream = g_memory_input_stream_new_from_data(buf, (gssize)n, g_free);
100+ webkit_uri_scheme_request_finish(request, stream, (gint64)n, mime);
101+ g_object_unref(stream);
102+ g_free(mime);
103+ } else {
104+ GError* err = g_error_new_literal(g_quark_from_string("dumber"), 404, "Not found");
105+ webkit_uri_scheme_request_finish_error(request, err);
106+ g_error_free(err);
107+ }
108+}
109+*/
110+import "C"
111+
112+import (
113+ "errors"
114+ "log"
115+ "os"
116+ "path/filepath"
117+ "runtime"
118+ "unsafe"
119+)
120+
121+const omniboxDefaultScript = `(() => {
122+ try {
123+ if (window.__dumber_omnibox_loaded) return; window.__dumber_omnibox_loaded = true;
124+ const H = { el:null,input:null,list:null,visible:false,suggestions:[],debounceTimer:0,
125+ post(m){ try{ window.webkit?.messageHandlers?.dumber?.postMessage(JSON.stringify(m)); }catch(_){} },
126+ render(){ if(!this.el) this.mount(); this.el.style.display=this.visible?'block':'none'; if(this.visible) this.input.focus(); },
127+ mount(){ const r=document.createElement('div'); r.style.cssText='position:fixed;inset:0;z-index:2147483647;display:none;';
128+ const b=document.createElement('div'); b.style.cssText='max-width:720px;margin:8vh auto;padding:10px 12px;background:#16181a;color:#e6e6e6;border:1px solid #2a2e33;border-radius:10px;box-shadow:0 12px 36px rgba(0,0,0,.55);backdrop-filter:saturate(120%) blur(2px);font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,\"Helvetica Neue\",Arial,sans-serif;';
129+ const i=document.createElement('input'); i.type='text'; i.placeholder='Type URL or search…'; i.style.cssText='display:block;width:100%;box-sizing:border-box;padding:12px 14px;border-radius:8px;border:1px solid #3a3f45;background:#0f1113;color:#e6e6e6;font-size:15px;outline:none;';
130+ const l=document.createElement('div'); l.style.cssText='margin-top:10px;max-height:52vh;overflow:auto;border-top:1px solid #2a2e33;';
131+ b.appendChild(i); b.appendChild(l); r.appendChild(b); document.documentElement.appendChild(r);
132+ i.addEventListener('keydown', (e)=>{ if(e.key==='Escape'){H.toggle(false);} else if(e.key==='Enter'){ const pick=H.suggestions[H.selectedIndex|0]; const v=(pick&&pick.url)||i.value||''; if(v) H.post({type:'navigate', url:v}); H.toggle(false);} else if(e.key==='ArrowDown' || e.key==='ArrowUp'){ e.preventDefault(); const n=H.suggestions.length; if(n){ H.selectedIndex=(H.selectedIndex||0)+(e.key==='ArrowDown'?1:-1); if(H.selectedIndex<0)H.selectedIndex=n-1; if(H.selectedIndex>=n)H.selectedIndex=0; H.paintList(); } } });
133+ i.addEventListener('input', ()=>{ clearTimeout(H.debounceTimer); const q=i.value; H.debounceTimer=setTimeout(()=>H.post({type:'query', q, limit:10}), 120); });
134+ this.el=r; this.input=i; this.list=l; this.selectedIndex=-1; this.paintList(); },
135+ paintList(){ const l=this.list; if(!l) return; l.textContent=''; this.suggestions.forEach((s,i)=>{ const it=document.createElement('div'); it.style.cssText='padding:10px 12px;display:flex;gap:10px;align-items:center;cursor:pointer;border-bottom:1px solid #252a2f;'+(i===this.selectedIndex?'background:#0c0f12;':'');
136+ const icon=document.createElement('img'); icon.src=s.favicon||''; icon.width=18; icon.height=18; icon.loading='lazy'; icon.style.cssText='flex:0 0 18px;width:18px;height:18px;border-radius:4px;opacity:.95;'; icon.onerror=()=>{ icon.style.display='none'; };
137+ const url=document.createElement('div'); url.textContent=s.url||''; url.style.cssText='flex:1;min-width:0;color:#cfe6ff;font-size:13.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;';
138+ it.appendChild(icon); it.appendChild(url);
139+ it.addEventListener('mouseenter',()=>{ H.selectedIndex=i; H.paintList(); });
140+ it.addEventListener('click',()=>{ H.post({type:'navigate', url:s.url}); H.toggle(false); });
141+ l.appendChild(it);
142+ }); },
143+ toggle(v){ this.visible=(typeof v==='boolean')?v:!this.visible; this.render(); }, setSuggestions(a){ this.suggestions=Array.isArray(a)?a:[]; this.selectedIndex=-1; this.paintList(); }
144+ };
145+ window.addEventListener('keydown', (e)=>{ if((e.ctrlKey||e.metaKey) && (e.key==='l'||e.key==='L')){ e.preventDefault(); H.toggle(true); } }, true);
146+ window.__dumber_setSuggestions = (a)=> H.setSuggestions(a);
147+ window.__dumber_toggle = ()=> H.toggle();
148+ } catch(_){}
149+})();`
150+
151+type nativeView struct {
152+ win *C.GtkWidget
153+ view *C.GtkWidget
154+ wv *C.WebKitWebView
155+ ucm *C.WebKitUserContentManager
156+}
157+
158+// WebView represents a browser view powered by WebKit2GTK.
159+type WebView struct {
160+ config *Config
161+ zoom float64
162+ url string
163+ destroyed bool
164+ native *nativeView
165+ window *Window
166+ id uintptr
167+ msgHandler func(payload string)
168+ titleHandler func(title string)
169+ uriHandler func(uri string)
170+}
171+
172+// NewWebView constructs a new WebView instance with native WebKit2GTK widgets.
173+func NewWebView(cfg *Config) (*WebView, error) {
174+ log.Printf("[webkit] Initializing GTK and creating WebView (CGO)")
175+ if C.gtk_init_check(nil, nil) == 0 {
176+ return nil, errors.New("failed to initialize GTK")
177+ }
178+
179+ if cfg == nil {
180+ cfg = &Config{}
181+ }
182+
183+ // Prepare persistent website data/caches
184+ dataDir := cfg.DataDir
185+ cacheDir := cfg.CacheDir
186+ if dataDir == "" { dataDir = filepath.Join(os.TempDir(), "dumber-webkit-data") }
187+ if cacheDir == "" { cacheDir = filepath.Join(os.TempDir(), "dumber-webkit-cache") }
188+ _ = os.MkdirAll(dataDir, 0o755)
189+ _ = os.MkdirAll(cacheDir, 0o755)
190+
191+ cData := C.CString(dataDir)
192+ cCache := C.CString(cacheDir)
193+ defer C.free(unsafe.Pointer(cData))
194+ defer C.free(unsafe.Pointer(cCache))
195+ wdm := C.make_wdm((*C.gchar)(cData), (*C.gchar)(cCache))
196+ if wdm == nil { return nil, errors.New("failed to create WebsiteDataManager") }
197+ ctx := C.webkit_web_context_new_with_website_data_manager(wdm)
198+ if ctx == nil { return nil, errors.New("failed to create WebContext") }
199+
200+ // Register custom URI scheme handler for "dumb://"
201+ {
202+ sch := C.CString("dumb")
203+ C.webkit_web_context_register_uri_scheme(ctx, sch, (C.WebKitURISchemeRequestCallback)(C.on_uri_scheme), nil, nil)
204+ C.free(unsafe.Pointer(sch))
205+ }
206+
207+ // Cookie manager persistent storage
208+ cm := C.webkit_web_context_get_cookie_manager(ctx)
209+ if cm != nil {
210+ cookiePath := filepath.Join(dataDir, "cookies.sqlite")
211+ cCookie := C.CString(cookiePath)
212+ defer C.free(unsafe.Pointer(cCookie))
213+ C.webkit_cookie_manager_set_persistent_storage(cm, (*C.gchar)(cCookie), C.WEBKIT_COOKIE_PERSISTENT_STORAGE_SQLITE)
214+ C.webkit_cookie_manager_set_accept_policy(cm, C.WEBKIT_COOKIE_POLICY_ACCEPT_ALWAYS)
215+ }
216+
217+ // Create WebView with context
218+ viewWidget := C.webkit_web_view_new_with_context(ctx) // GtkWidget*
219+ if viewWidget == nil {
220+ return nil, errors.New("failed to create WebKitWebView")
221+ }
222+
223+ // Create a top-level window to host the view
224+ win := C.new_window()
225+ if win == nil {
226+ return nil, errors.New("failed to create GtkWindow")
227+ }
228+
229+ // Pack view widget into the window
230+ C.gtk_container_add((*C.GtkContainer)(unsafe.Pointer(win)), viewWidget)
231+ C.gtk_window_set_default_size((*C.GtkWindow)(unsafe.Pointer(win)), 1024, 768)
232+ C.connect_destroy_quit(win)
233+
234+ v := &WebView{
235+ config: cfg,
236+ zoom: 1.0,
237+ native: &nativeView{win: win, view: viewWidget, wv: C.as_webview(viewWidget), ucm: nil},
238+ window: &Window{win: win},
239+ }
240+ // Allow receiving button press events
241+ C.gtk_widget_add_events(win, C.GDK_BUTTON_PRESS_MASK|C.GDK_BUTTON_RELEASE_MASK)
242+ C.gtk_widget_add_events(viewWidget, C.GDK_BUTTON_PRESS_MASK|C.GDK_BUTTON_RELEASE_MASK)
243+
244+ // Assign an ID for accelerator dispatch and connect input events
245+ v.id = nextViewID()
246+ registerView(v.id, v)
247+ C.g_signal_connect_data(C.gpointer(unsafe.Pointer(win)), C.CString("key-press-event"), C.GCallback(C.on_key_press), C.gpointer(v.id), nil, 0)
248+ C.g_signal_connect_data(C.gpointer(unsafe.Pointer(viewWidget)), C.CString("button-press-event"), C.GCallback(C.on_button_press), C.gpointer(v.id), nil, 0)
249+ C.g_signal_connect_data(C.gpointer(unsafe.Pointer(win)), C.CString("button-press-event"), C.GCallback(C.on_button_press), C.gpointer(v.id), nil, 0)
250+ // Update window title when page title changes
251+ C.g_signal_connect_data(C.gpointer(unsafe.Pointer(viewWidget)), C.CString("notify::title"), C.GCallback(C.on_title_notify), C.gpointer(unsafe.Pointer(win)), nil, 0)
252+ // Also dispatch title change to Go with view id
253+ C.g_signal_connect_data(C.gpointer(unsafe.Pointer(viewWidget)), C.CString("notify::title"), C.GCallback(C.on_title_notify_id), C.gpointer(v.id), nil, 0)
254+ // Notify URI changes to Go to keep current URL in sync
255+ C.g_signal_connect_data(C.gpointer(unsafe.Pointer(viewWidget)), C.CString("notify::uri"), C.GCallback(C.on_uri_notify), C.gpointer(v.id), nil, 0)
256+ if cfg.ZoomDefault > 0 {
257+ v.zoom = cfg.ZoomDefault
258+ C.webkit_web_view_set_zoom_level(v.native.wv, C.gdouble(v.zoom))
259+ }
260+ if cfg.EnableDeveloperExtras {
261+ settings := C.webkit_web_view_get_settings(v.native.wv)
262+ if settings != nil {
263+ C.webkit_settings_set_enable_developer_extras(settings, C.gboolean(1))
264+ }
265+ }
266+ // Apply default fonts if provided in cfg
267+ if settings := C.webkit_web_view_get_settings(v.native.wv); settings != nil {
268+ if cfg.DefaultSansFont != "" {
269+ csans := C.CString(cfg.DefaultSansFont)
270+ C.webkit_settings_set_sans_serif_font_family(settings, (*C.gchar)(csans))
271+ C.free(unsafe.Pointer(csans))
272+ // Also set default font family to sans
273+ csans2 := C.CString(cfg.DefaultSansFont)
274+ C.webkit_settings_set_default_font_family(settings, (*C.gchar)(csans2))
275+ C.free(unsafe.Pointer(csans2))
276+ }
277+ if cfg.DefaultSerifFont != "" {
278+ cserif := C.CString(cfg.DefaultSerifFont)
279+ C.webkit_settings_set_serif_font_family(settings, (*C.gchar)(cserif))
280+ C.free(unsafe.Pointer(cserif))
281+ }
282+ if cfg.DefaultMonospaceFont != "" {
283+ cmono := C.CString(cfg.DefaultMonospaceFont)
284+ C.webkit_settings_set_monospace_font_family(settings, (*C.gchar)(cmono))
285+ C.free(unsafe.Pointer(cmono))
286+ }
287+ if cfg.DefaultFontSize > 0 {
288+ C.webkit_settings_set_default_font_size(settings, C.guint(cfg.DefaultFontSize))
289+ }
290+ }
291+ if cfg.InitialURL != "" {
292+ _ = v.LoadURL(cfg.InitialURL)
293+ }
294+ // Ensure native resources are released if user forgets to call Destroy
295+ runtime.SetFinalizer(v, func(v *WebView) { _ = v.Destroy() })
296+ log.Printf("[webkit] WebView created (CGO)")
297+
298+ // Obtain the view's UserContentManager and inject script
299+ if v.native != nil && v.native.wv != nil {
300+ u := C.webkit_web_view_get_user_content_manager(v.native.wv)
301+ if u != nil {
302+ v.native.ucm = u
303+ v.enableUserContentManager()
304+ }
305+ }
306+ return v, nil
307+}
308+
309+func (w *WebView) LoadURL(rawURL string) error {
310+ if w == nil || w.destroyed || w.native == nil || w.native.wv == nil {
311+ return ErrNotImplemented
312+ }
313+ w.url = rawURL
314+ curl := C.CString(rawURL)
315+ defer C.free(unsafe.Pointer(curl))
316+ C.webkit_web_view_load_uri(w.native.wv, (*C.gchar)(curl))
317+ log.Printf("[webkit] LoadURL: %s", rawURL)
318+ return nil
319+}
320+
321+func (w *WebView) Show() error {
322+ if w == nil || w.destroyed || w.native == nil || w.native.win == nil {
323+ return ErrNotImplemented
324+ }
325+ C.gtk_widget_show(w.native.win)
326+ C.gtk_widget_show(w.native.view)
327+ log.Printf("[webkit] Show window")
328+ return nil
329+}
330+
331+func (w *WebView) Hide() error {
332+ if w == nil || w.destroyed || w.native == nil || w.native.win == nil {
333+ return ErrNotImplemented
334+ }
335+ C.gtk_widget_hide(w.native.win)
336+ return nil
337+}
338+
339+func (w *WebView) Destroy() error {
340+ if w == nil || w.native == nil || w.native.win == nil {
341+ return ErrNotImplemented
342+ }
343+ C.gtk_widget_destroy(w.native.win)
344+ w.destroyed = true
345+ unregisterView(w.id)
346+ log.Printf("[webkit] Destroy window")
347+ return nil
348+}
349+
350+// Window returns the associated native window.
351+func (w *WebView) Window() *Window { return w.window }
352+
353+// GetCurrentURL returns the current URI from WebKit.
354+func (w *WebView) GetCurrentURL() string {
355+ if w == nil || w.native == nil || w.native.wv == nil { return "" }
356+ uri := C.webkit_web_view_get_uri(w.native.wv)
357+ if uri == nil { return "" }
358+ return C.GoString((*C.char)(unsafe.Pointer(uri)))
359+}
360+
361+func (w *WebView) GoBack() error {
362+ if w == nil || w.native == nil || w.native.wv == nil { return ErrNotImplemented }
363+ C.webkit_web_view_go_back(w.native.wv)
364+ return nil
365+}
366+
367+func (w *WebView) GoForward() error {
368+ if w == nil || w.native == nil || w.native.wv == nil { return ErrNotImplemented }
369+ C.webkit_web_view_go_forward(w.native.wv)
370+ return nil
371+}
372+
373+// RegisterScriptMessageHandler registers a callback invoked when the content script posts a message.
374+func (w *WebView) RegisterScriptMessageHandler(cb func(payload string)) { w.msgHandler = cb }
375+
376+func (w *WebView) dispatchScriptMessage(payload string) {
377+ if w != nil && w.msgHandler != nil { w.msgHandler(payload) }
378+}
379+
380+// RegisterTitleChangedHandler registers a callback invoked when the page title changes.
381+func (w *WebView) RegisterTitleChangedHandler(cb func(title string)) { w.titleHandler = cb }
382+
383+func (w *WebView) dispatchTitleChanged(title string) {
384+ if w != nil && w.titleHandler != nil { w.titleHandler(title) }
385+}
386+
387+// RegisterURIChangedHandler registers a callback invoked when the current page URI changes.
388+func (w *WebView) RegisterURIChangedHandler(cb func(uri string)) { w.uriHandler = cb }
389+
390+func (w *WebView) dispatchURIChanged(uri string) {
391+ if w != nil && w.uriHandler != nil { w.uriHandler(uri) }
392+}
393+
394+// enableUserContentManager registers the 'dumber' message handler and injects the omnibox script.
395+func (w *WebView) enableUserContentManager() {
396+ if w == nil || w.native == nil || w.native.ucm == nil { return }
397+ // Register handler "dumber"
398+ cname := C.CString("dumber")
399+ defer C.free(unsafe.Pointer(cname))
400+ C.webkit_user_content_manager_register_script_message_handler(w.native.ucm, (*C.gchar)(cname))
401+ C.g_signal_connect_data(C.gpointer(unsafe.Pointer(w.native.ucm)), C.CString("script-message-received::dumber"), C.GCallback(C.on_ucm_message), C.gpointer(w.id), nil, 0)
402+
403+ // Inject color-scheme preference script at document-start to inform sites of system theme
404+ preferDark := C.gtk_prefers_dark() != 0
405+ if preferDark {
406+ log.Printf("[theme] GTK prefers: dark")
407+ } else {
408+ log.Printf("[theme] GTK prefers: light")
409+ }
410+ var schemeJS string
411+ if preferDark {
412+ schemeJS = "(() => { try { const d=true; const cs=d?'dark':'light'; console.log('[dumber] color-scheme set:', cs); try{ window.webkit?.messageHandlers?.dumber?.postMessage(JSON.stringify({type:'theme', value: cs})) }catch(_){} const meta=document.createElement('meta'); meta.name='color-scheme'; meta.content='dark light'; document.documentElement.appendChild(meta); const s=document.createElement('style'); s.textContent=':root{color-scheme:dark;}'; document.documentElement.appendChild(s); const qD='(prefers-color-scheme: dark)'; const qL='(prefers-color-scheme: light)'; const orig=window.matchMedia; window.matchMedia=function(q){ if(typeof q==='string'&&(q.includes(qD)||q.includes(qL))){ const m={matches:q.includes('dark')?d:!d,media:q,onchange:null,addListener(){},removeListener(){},addEventListener(){},removeEventListener(){},dispatchEvent(){return false;}}; return m;} return orig.call(window,q); }; } catch(e){ console.warn('[dumber] color-scheme injection failed', e) } })();"
413+ } else {
414+ schemeJS = "(() => { try { const d=false; const cs=d?'dark':'light'; console.log('[dumber] color-scheme set:', cs); try{ window.webkit?.messageHandlers?.dumber?.postMessage(JSON.stringify({type:'theme', value: cs})) }catch(_){} const meta=document.createElement('meta'); meta.name='color-scheme'; meta.content='light dark'; document.documentElement.appendChild(meta); const s=document.createElement('style'); s.textContent=':root{color-scheme:light;}'; document.documentElement.appendChild(s); const qD='(prefers-color-scheme: dark)'; const qL='(prefers-color-scheme: light)'; const orig=window.matchMedia; window.matchMedia=function(q){ if(typeof q==='string'&&(q.includes(qD)||q.includes(qL))){ const m={matches:q.includes('dark')?d:!d,media:q,onchange:null,addListener(){},removeListener(){},addEventListener(){},removeEventListener(){},dispatchEvent(){return false;}}; return m;} return orig.call(window,q); }; } catch(e){ console.warn('[dumber] color-scheme injection failed', e) } })();"
415+ }
416+ cScheme := C.CString(schemeJS)
417+ defer C.free(unsafe.Pointer(cScheme))
418+ schemeScript := C.webkit_user_script_new((*C.gchar)(cScheme), C.WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES, C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, nil, nil)
419+ if schemeScript != nil {
420+ C.webkit_user_content_manager_add_script(w.native.ucm, schemeScript)
421+ C.webkit_user_script_unref(schemeScript)
422+ }
423+
424+ // Add user script at document-start
425+ src := C.CString(omniboxDefaultScript)
426+ defer C.free(unsafe.Pointer(src))
427+ script := C.webkit_user_script_new((*C.gchar)(src), C.WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES, C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, nil, nil)
428+ if script != nil {
429+ C.webkit_user_content_manager_add_script(w.native.ucm, script)
430+ C.webkit_user_script_unref(script)
431+ log.Printf("[webkit] UCM omnibox script injected at document-start")
432+ }
433+
434+ // Inject Wails runtime fetch interceptor for homepage bridging
435+ wailsBridge := `(() => { try { const origFetch = window.fetch.bind(window); const waiters = Object.create(null); window.__dumber_wails_resolve = (id, json) => { const w = waiters[id]; if(!w) return; delete waiters[id]; try { const headers = new Headers({"Content-Type":"application/json"}); w.resolve(new Response(json, { headers })); } catch(e){ w.reject(e); } }; window.fetch = (input, init) => { try { const url = new URL(input instanceof Request ? input.url : input, window.location.origin); if (url.pathname === '/wails/runtime') { const args = url.searchParams.get('args'); let payload = {}; try { payload = args ? JSON.parse(args) : {}; } catch(_){} const id = String(Date.now()) + '-' + Math.random().toString(36).slice(2); return new Promise((resolve, reject) => { waiters[id] = { resolve, reject }; try { window.webkit?.messageHandlers?.dumber?.postMessage(JSON.stringify({ type: 'wails', id, payload })); } catch (e) { reject(e); } }); } } catch(_){} return origFetch(input, init); }; } catch(_){} })();`
436+ cWails := C.CString(wailsBridge)
437+ defer C.free(unsafe.Pointer(cWails))
438+ wailsScript := C.webkit_user_script_new((*C.gchar)(cWails), C.WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES, C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, nil, nil)
439+ if wailsScript != nil {
440+ C.webkit_user_content_manager_add_script(w.native.ucm, wailsScript)
441+ C.webkit_user_script_unref(wailsScript)
442+ }
443+}
A pkg/webkit/window.go +20 -0
@@ -0,0 +1,20 @@
1+//go:build !webkit_cgo
2+
3+package webkit
4+
5+// Window represents a top-level application window hosting a WebView.
6+// Stub for future GTK application window management.
7+type Window struct {
8+ Title string
9+}
10+
11+// NewWindow constructs a Window. In real implementation, this would create a GTK window.
12+func NewWindow(title string) (*Window, error) {
13+ _ = title
14+ return &Window{Title: title}, ErrNotImplemented
15+}
16+
17+// SetTitle updates the window title (no-op in non-CGO build).
18+func (w *Window) SetTitle(title string) {
19+ w.Title = title
20+}
A pkg/webkit/window_cgo.go +37 -0
@@ -0,0 +1,37 @@
1+//go:build webkit_cgo
2+
3+package webkit
4+
5+/*
6+#cgo pkg-config: webkit2gtk-4.0 gtk+-3.0
7+#include <stdlib.h>
8+#include <gtk/gtk.h>
9+*/
10+import "C"
11+
12+import "unsafe"
13+
14+type Window struct {
15+ win *C.GtkWidget
16+}
17+
18+func NewWindow(title string) (*Window, error) {
19+ if C.gtk_init_check(nil, nil) == 0 {
20+ return nil, ErrNotImplemented
21+ }
22+ w := C.gtk_window_new(C.GTK_WINDOW_TOPLEVEL)
23+ if w == nil {
24+ return nil, ErrNotImplemented
25+ }
26+ ctitle := C.CString(title)
27+ defer C.free(unsafe.Pointer(ctitle))
28+ C.gtk_window_set_title((*C.GtkWindow)(unsafe.Pointer(w)), (*C.gchar)(ctitle))
29+ return &Window{win: w}, nil
30+}
31+
32+func (w *Window) SetTitle(title string) {
33+ if w == nil || w.win == nil { return }
34+ ctitle := C.CString(title)
35+ defer C.free(unsafe.Pointer(ctitle))
36+ C.gtk_window_set_title((*C.GtkWindow)(unsafe.Pointer(w.win)), (*C.gchar)(ctitle))
37+}
A pkg/webkit/zoom.go +31 -0
@@ -0,0 +1,31 @@
1+//go:build !webkit_cgo
2+
3+package webkit
4+
5+const (
6+ zoomMin = 0.25
7+ zoomMax = 5.0
8+)
9+
10+// SetZoom sets the current zoom factor for the WebView.
11+func (w *WebView) SetZoom(level float64) error {
12+ if w == nil || w.destroyed {
13+ return ErrNotImplemented
14+ }
15+ if level < zoomMin {
16+ level = zoomMin
17+ }
18+ if level > zoomMax {
19+ level = zoomMax
20+ }
21+ w.zoom = level
22+ return nil
23+}
24+
25+// GetZoom returns the current zoom factor.
26+func (w *WebView) GetZoom() (float64, error) {
27+ if w == nil || w.destroyed {
28+ return 1.0, ErrNotImplemented
29+ }
30+ return w.zoom, nil
31+}
A pkg/webkit/zoom_cgo.go +34 -0
@@ -0,0 +1,34 @@
1+//go:build webkit_cgo
2+
3+package webkit
4+
5+/*
6+#cgo pkg-config: webkit2gtk-4.0 gtk+-3.0
7+#include <webkit2/webkit2.h>
8+*/
9+import "C"
10+
11+func (w *WebView) SetZoom(level float64) error {
12+ if w == nil || w.destroyed || w.native == nil || w.native.wv == nil {
13+ return ErrNotImplemented
14+ }
15+ if level < 0.25 {
16+ level = 0.25
17+ }
18+ if level > 5.0 {
19+ level = 5.0
20+ }
21+ w.zoom = level
22+ C.webkit_web_view_set_zoom_level(w.native.wv, C.gdouble(level))
23+ return nil
24+}
25+
26+func (w *WebView) GetZoom() (float64, error) {
27+ if w == nil || w.destroyed || w.native == nil || w.native.wv == nil {
28+ return 1.0, ErrNotImplemented
29+ }
30+ z := float64(C.webkit_web_view_get_zoom_level(w.native.wv))
31+ w.zoom = z
32+ return z, nil
33+}
34+
M services/browser_service.go +173 -177
@@ -1,40 +1,48 @@
1+// Package services contains application services that orchestrate business logic.
12 package services
23
34 import (
4- "context"
5- "database/sql"
6- "embed"
7- "fmt"
8- "net/url"
9- "time"
10-
11- "github.com/wailsapp/wails/v3/pkg/application"
12- "github.com/bnema/dumber/internal/config"
13- "github.com/bnema/dumber/internal/db"
5+ "context"
6+ "database/sql"
7+ "embed"
8+ "fmt"
9+ "errors"
10+ "math"
11+ "time"
12+ neturl "net/url"
13+
14+ "github.com/bnema/dumber/internal/config"
15+ "github.com/bnema/dumber/internal/db"
16+ "github.com/bnema/dumber/pkg/webkit"
1417 )
1518
16-// WindowTitleUpdater interface allows the service to update the window title
17-// This matches the Wails v3 WebviewWindow.SetTitle method signature that returns application.Window
18-type WindowTitleUpdater interface {
19- SetTitle(title string) application.Window
19+func clampToInt32(i int64) int32 {
20+ if i > math.MaxInt32 {
21+ i = math.MaxInt32
22+ }
23+ if i < math.MinInt32 {
24+ i = math.MinInt32
25+ }
26+ return int32(i) //nolint:gosec // value is clamped to int32 bounds
2027 }
2128
22-// ScriptInjector interface allows the service to inject JavaScript into the webview
23-type ScriptInjector interface {
24- ExecJS(js string)
29+// WindowTitleUpdater interface allows the service to update the window title
30+// Decoupled from any specific GUI framework.
31+type WindowTitleUpdater interface {
32+ SetTitle(title string)
2533 }
2634
27-// BrowserService handles browser-related operations for Wails integration.
35+// BrowserService handles browser-related operations for the built-in browser.
2836 type BrowserService struct {
29- config *config.Config
30- dbQueries db.DatabaseQuerier
31- windowTitleUpdater WindowTitleUpdater
32- scriptInjector ScriptInjector
33- initialURL string
34- injectableScript string
37+ config *config.Config
38+ dbQueries db.DatabaseQuerier
39+ windowTitleUpdater WindowTitleUpdater
40+ webView *webkit.WebView
41+ initialURL string
42+ injectableScript string
3543 }
3644
37-// ServiceName returns the service name for Wails frontend binding
45+// ServiceName returns the service name for frontend binding
3846 func (s *BrowserService) ServiceName() string {
3947 return "BrowserService"
4048 }
@@ -61,14 +69,14 @@ type HistoryEntry struct {
6169
6270 // NewBrowserService creates a new BrowserService instance.
6371 func NewBrowserService(cfg *config.Config, queries db.DatabaseQuerier) *BrowserService {
64- return &BrowserService{
65- config: cfg,
66- dbQueries: queries,
67- windowTitleUpdater: nil,
68- scriptInjector: nil,
69- initialURL: "",
70- injectableScript: "",
71- }
72+ return &BrowserService{
73+ config: cfg,
74+ dbQueries: queries,
75+ windowTitleUpdater: nil,
76+ webView: nil,
77+ initialURL: "",
78+ injectableScript: "",
79+ }
7280 }
7381
7482 // SetWindowTitleUpdater sets the window title updater interface
@@ -76,9 +84,9 @@ func (s *BrowserService) SetWindowTitleUpdater(updater WindowTitleUpdater) {
7684 s.windowTitleUpdater = updater
7785 }
7886
79-// SetScriptInjector sets the script injector interface
80-func (s *BrowserService) SetScriptInjector(injector ScriptInjector) {
81- s.scriptInjector = injector
87+// AttachWebView connects a native WebKit WebView to this service for integration.
88+func (s *BrowserService) AttachWebView(view *webkit.WebView) {
89+ s.webView = view
8290 }
8391
8492 // LoadInjectableScript loads the minified injectable controls script from assets
@@ -93,40 +101,20 @@ func (s *BrowserService) LoadInjectableScript(assets embed.FS) error {
93101
94102 // InjectControlScript injects the global controls script into the current page
95103 func (s *BrowserService) InjectControlScript(ctx context.Context) error {
96- if s.scriptInjector == nil {
97- return fmt.Errorf("script injector not set")
98- }
99-
100- if s.injectableScript == "" {
101- return fmt.Errorf("injectable script not loaded")
102- }
103-
104- // Wrap the script in a try-catch and DOM ready check for better compatibility
105- wrappedScript := fmt.Sprintf(`
106- (function() {
107- try {
108- // Check if DOM is ready
109- if (document.readyState === 'loading') {
110- document.addEventListener('DOMContentLoaded', function() {
111- try {
112- %s
113- } catch (e) {
114- console.warn('Dumber Browser controls failed to load on DOMContentLoaded:', e);
115- }
116- });
117- } else {
118- // DOM is already ready
119- %s
120- }
121- } catch (e) {
122- console.warn('Dumber Browser controls injection failed:', e);
123- }
124- })();
125- `, s.injectableScript, s.injectableScript)
126-
127- // Inject the wrapped script
128- s.scriptInjector.ExecJS(wrappedScript)
129- return nil
104+ _ = ctx
105+ if s.webView == nil {
106+ return fmt.Errorf("webview not attached")
107+ }
108+ if s.injectableScript == "" {
109+ return fmt.Errorf("injectable script not loaded")
110+ }
111+ wrappedScript := "(function(){try{" +
112+ "if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',function(){try{" +
113+ s.injectableScript +
114+ "}catch(e){console.warn('Dumber Browser controls failed to load on DOMContentLoaded:', e);}});}else{" +
115+ s.injectableScript +
116+ "}}catch(e){console.warn('Dumber Browser controls injection failed:', e);}})();"
117+ return s.webView.InjectScript(wrappedScript)
130118 }
131119
132120 // Navigate handles navigation to a URL and records it in history.
@@ -194,14 +182,16 @@ func (s *BrowserService) GetRecentHistory(ctx context.Context, limit int) ([]His
194182
195183 result := make([]HistoryEntry, len(entries))
196184 for i, entry := range entries {
197- result[i] = HistoryEntry{
198- ID: entry.ID,
199- URL: entry.Url,
200- Title: entry.Title.String,
201- VisitCount: int32(entry.VisitCount.Int64),
202- LastVisited: entry.LastVisited.Time,
203- CreatedAt: entry.CreatedAt.Time,
204- }
185+ // Defensive cast to int32 to prevent overflow
186+ vc := clampToInt32(entry.VisitCount.Int64)
187+ result[i] = HistoryEntry{
188+ ID: entry.ID,
189+ URL: entry.Url,
190+ Title: entry.Title.String,
191+ VisitCount: vc,
192+ LastVisited: entry.LastVisited.Time,
193+ CreatedAt: entry.CreatedAt.Time,
194+ }
205195 }
206196
207197 return result, nil
@@ -227,14 +217,15 @@ func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit
227217
228218 result := make([]HistoryEntry, len(entries))
229219 for i, entry := range entries {
230- result[i] = HistoryEntry{
231- ID: entry.ID,
232- URL: entry.Url,
233- Title: entry.Title.String,
234- VisitCount: int32(entry.VisitCount.Int64),
235- LastVisited: entry.LastVisited.Time,
236- CreatedAt: entry.CreatedAt.Time,
237- }
220+ vc := clampToInt32(entry.VisitCount.Int64)
221+ result[i] = HistoryEntry{
222+ ID: entry.ID,
223+ URL: entry.Url,
224+ Title: entry.Title.String,
225+ VisitCount: vc,
226+ LastVisited: entry.LastVisited.Time,
227+ CreatedAt: entry.CreatedAt.Time,
228+ }
238229 }
239230
240231 return result, nil
@@ -242,22 +233,25 @@ func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit
242233
243234 // DeleteHistoryEntry removes a specific history entry.
244235 func (s *BrowserService) DeleteHistoryEntry(ctx context.Context, id int64) error {
245- // Note: DeleteHistory method doesn't exist in current schema
246- // This would need to be implemented in the database layer
247- return fmt.Errorf("delete history not implemented yet")
236+ _ = ctx
237+ _ = id
238+ // Note: DeleteHistory method doesn't exist in current schema
239+ // This would need to be implemented in the database layer
240+ return fmt.Errorf("delete history not implemented yet")
248241 }
249242
250243 // ClearHistory removes all history entries.
251244 func (s *BrowserService) ClearHistory(ctx context.Context) error {
252- // Note: ClearAllHistory method doesn't exist in current schema
253- // This would need to be implemented in the database layer
254- return fmt.Errorf("clear all history not implemented yet")
245+ _ = ctx
246+ // Note: ClearAllHistory method doesn't exist in current schema
247+ // This would need to be implemented in the database layer
248+ return fmt.Errorf("clear all history not implemented yet")
255249 }
256250
257251 // GetHistoryStats returns statistics about browser history.
258252 func (s *BrowserService) GetHistoryStats(ctx context.Context) (map[string]interface{}, error) {
259- // Get recent entries for basic stats
260- recentEntries, err := s.dbQueries.GetHistory(ctx, 1000)
253+ // Get recent entries for basic stats
254+ recentEntries, err := s.dbQueries.GetHistory(ctx, recentHistoryLimit)
261255 if err != nil {
262256 return nil, err
263257 }
@@ -279,14 +273,16 @@ func (s *BrowserService) GetHistoryStats(ctx context.Context) (map[string]interf
279273
280274 // GetConfig returns the current browser configuration.
281275 func (s *BrowserService) GetConfig(ctx context.Context) (*config.Config, error) {
282- return s.config, nil
276+ _ = ctx
277+ return s.config, nil
283278 }
284279
285280 // UpdateConfig updates the browser configuration.
286281 func (s *BrowserService) UpdateConfig(ctx context.Context, newConfig *config.Config) error {
287- // In a real implementation, you'd want to validate and persist the config
288- s.config = newConfig
289- return nil
282+ _ = ctx
283+ // In a real implementation, you'd want to validate and persist the config
284+ s.config = newConfig
285+ return nil
290286 }
291287
292288 // GetSearchShortcuts returns available search shortcuts.
@@ -347,47 +343,51 @@ func (s *BrowserService) ResetZoom(ctx context.Context, url string) (float64, er
347343
348344 // GetZoomLevel retrieves the saved zoom level for a URL.
349345 func (s *BrowserService) GetZoomLevel(ctx context.Context, url string) (float64, error) {
350- if url == "" {
351- return 1.0, nil
352- }
353-
354- // Extract domain for consistent storage across pages of the same site
355- domain := s.extractDomain(url)
356-
357- zoomLevel, err := s.dbQueries.GetZoomLevel(ctx, domain)
358- if err != nil {
359- if err == sql.ErrNoRows {
360- // No zoom setting found, return default
361- return 1.0, nil
362- }
363- return 1.0, err
364- }
365-
366- return zoomLevel, nil
346+ if url == "" {
347+ return 1.0, nil
348+ }
349+
350+ key := zoomKeyFromURL(url)
351+ zoomLevel, err := s.dbQueries.GetZoomLevel(ctx, key)
352+ if err != nil {
353+ if errors.Is(err, sql.ErrNoRows) {
354+ // Fallback to exact URL record if present (backward compatibility)
355+ zl, err2 := s.dbQueries.GetZoomLevel(ctx, url)
356+ if err2 == nil {
357+ return zl, nil
358+ }
359+ // No zoom setting found, return default
360+ return 1.0, nil
361+ }
362+ return 1.0, err
363+ }
364+
365+ return zoomLevel, nil
367366 }
368367
369368 // SetZoomLevel sets the zoom level for a URL.
370369 func (s *BrowserService) SetZoomLevel(ctx context.Context, url string, zoomLevel float64) error {
371- if url == "" {
372- return fmt.Errorf("URL cannot be empty")
373- }
370+ if url == "" {
371+ return fmt.Errorf("URL cannot be empty")
372+ }
374373
375- // Clamp zoom level to reasonable bounds
376- if zoomLevel < 0.25 {
377- zoomLevel = 0.25
378- } else if zoomLevel > 5.0 {
379- zoomLevel = 5.0
380- }
374+ // Clamp zoom level to reasonable bounds
375+ if zoomLevel < zoomMin {
376+ zoomLevel = zoomMin
377+ } else if zoomLevel > zoomMax {
378+ zoomLevel = zoomMax
379+ }
381380
382- domain := s.extractDomain(url)
383- return s.dbQueries.SetZoomLevel(ctx, domain, zoomLevel)
381+ key := zoomKeyFromURL(url)
382+ return s.dbQueries.SetZoomLevel(ctx, key, zoomLevel)
384383 }
385384
386385 // GetCurrentURL returns the current URL (this would be implemented by the frontend)
387386 func (s *BrowserService) GetCurrentURL(ctx context.Context) (string, error) {
388- // This would be implemented by getting the current URL from the webview
389- // For now, return empty as this will be called by the frontend
390- return "", nil
387+ if s.webView == nil {
388+ return "", nil
389+ }
390+ return s.webView.GetCurrentURL(), nil
391391 }
392392
393393 // GetInitialURL returns the initially navigated URL for frontend synchronization
@@ -408,21 +408,29 @@ func (s *BrowserService) CopyCurrentURL(ctx context.Context, url string) error {
408408
409409 // GoBack provides navigation back functionality
410410 func (s *BrowserService) GoBack(ctx context.Context) error {
411- // This will be handled by the frontend's webview
412- // Backend just logs the action
413- fmt.Println("Navigation: Go back")
414- return nil
411+ if s.webView != nil {
412+ return s.webView.GoBack()
413+ }
414+ fmt.Println("Navigation: Go back")
415+ return nil
415416 }
416417
417418 // GoForward provides navigation forward functionality
418419 func (s *BrowserService) GoForward(ctx context.Context) error {
419- // This will be handled by the frontend's webview
420- // Backend just logs the action
421- fmt.Println("Navigation: Go forward")
422- return nil
420+ if s.webView != nil {
421+ return s.webView.GoForward()
422+ }
423+ fmt.Println("Navigation: Go forward")
424+ return nil
423425 }
424426
425427 // getNextZoomLevel returns the next Firefox zoom level in the specified direction
428+const (
429+ zoomMin = 0.25
430+ zoomMax = 5.0
431+ recentHistoryLimit = 1000
432+)
433+
426434 func (s *BrowserService) getNextZoomLevel(currentZoom float64, zoomIn bool) float64 {
427435 // Find the closest current zoom level
428436 closestIndex := 0
@@ -439,51 +447,39 @@ func (s *BrowserService) getNextZoomLevel(currentZoom float64, zoomIn bool) floa
439447 }
440448 }
441449
442- if zoomIn {
443- // Move to next higher zoom level
444- if closestIndex < len(firefoxZoomLevels)-1 {
445- return firefoxZoomLevels[closestIndex+1]
446- }
447- return firefoxZoomLevels[closestIndex] // Already at max
448- } else {
449- // Move to next lower zoom level
450- if closestIndex > 0 {
451- return firefoxZoomLevels[closestIndex-1]
452- }
453- return firefoxZoomLevels[closestIndex] // Already at min
454- }
450+ if zoomIn {
451+ // Move to next higher zoom level
452+ if closestIndex < len(firefoxZoomLevels)-1 {
453+ return firefoxZoomLevels[closestIndex+1]
454+ }
455+ return firefoxZoomLevels[closestIndex] // Already at max
456+ }
457+ // Move to next lower zoom level
458+ if closestIndex > 0 {
459+ return firefoxZoomLevels[closestIndex-1]
460+ }
461+ return firefoxZoomLevels[closestIndex] // Already at min
455462 }
456463
457464 // setZoom sets the zoom level to a specific value
458465 func (s *BrowserService) setZoom(ctx context.Context, url string, zoomLevel float64) (float64, error) {
459- err := s.SetZoomLevel(ctx, url, zoomLevel)
460- if err != nil {
461- return 1.0, err
462- }
463- return zoomLevel, nil
466+ err := s.SetZoomLevel(ctx, url, zoomLevel)
467+ if err != nil {
468+ return 1.0, err
469+ }
470+ return zoomLevel, nil
471+}
472+
473+// zoomKeyFromURL extracts a stable per-domain key for zoom persistence.
474+// Uses hostname if present; falls back to the full input URL string.
475+func zoomKeyFromURL(raw string) string {
476+ u, err := neturl.Parse(raw)
477+ if err != nil || u.Host == "" {
478+ return raw
479+ }
480+ return u.Host
464481 }
465482
466483 // extractDomain extracts the domain from a URL for zoom level storage
467484 // Domain-based zoom allows settings to persist across different pages of the same site
468-func (s *BrowserService) extractDomain(rawURL string) string {
469- if rawURL == "" {
470- return ""
471- }
472-
473- // Parse the URL to extract the domain
474- parsedURL, err := url.Parse(rawURL)
475- if err != nil {
476- // If URL parsing fails, use the raw URL as fallback
477- // This handles edge cases like local files or malformed URLs
478- return rawURL
479- }
480-
481- // Return the hostname (domain) for consistent storage
482- domain := parsedURL.Hostname()
483- if domain == "" {
484- // Fallback to the original URL if no hostname can be extracted
485- return rawURL
486- }
487-
488- return domain
489-}⏎̸
485+// extractDomain is no longer used; zoom is stored per URL for contract parity.
M services/config_service.go +67 -49
@@ -1,20 +1,22 @@
11 package services
22
33 import (
4- "context"
5- "fmt"
6- "os"
7- "path/filepath"
4+ "context"
5+ "fmt"
6+ "os"
7+ "path/filepath"
88
99 "github.com/bnema/dumber/internal/config"
1010 )
1111
12-// ConfigService handles configuration operations for Wails integration.
12+// ConfigService handles configuration operations for the application.
1313 type ConfigService struct {
14- config *config.Config
15- configPath string
14+ config *config.Config
15+ configPath string
1616 }
1717
18+const defaultDirPerm = 0o750
19+
1820 // ConfigInfo represents configuration information for the frontend.
1921 type ConfigInfo struct {
2022 ConfigPath string `json:"config_path"`
@@ -33,24 +35,27 @@ func NewConfigService(cfg *config.Config, configPath string) *ConfigService {
3335
3436 // GetConfigInfo returns comprehensive configuration information.
3537 func (s *ConfigService) GetConfigInfo(ctx context.Context) (*ConfigInfo, error) {
36- return &ConfigInfo{
37- ConfigPath: s.configPath,
38- DatabasePath: s.config.Database.Path,
39- SearchShortcuts: s.config.SearchShortcuts,
40- DmenuSettings: &s.config.Dmenu,
41- }, nil
38+ _ = ctx
39+ return &ConfigInfo{
40+ ConfigPath: s.configPath,
41+ DatabasePath: s.config.Database.Path,
42+ SearchShortcuts: s.config.SearchShortcuts,
43+ DmenuSettings: &s.config.Dmenu,
44+ }, nil
4245 }
4346
4447 // GetSearchShortcuts returns all configured search shortcuts.
4548 func (s *ConfigService) GetSearchShortcuts(ctx context.Context) (map[string]config.SearchShortcut, error) {
46- return s.config.SearchShortcuts, nil
49+ _ = ctx
50+ return s.config.SearchShortcuts, nil
4751 }
4852
4953 // AddSearchShortcut adds a new search shortcut.
5054 func (s *ConfigService) AddSearchShortcut(ctx context.Context, key string, shortcut config.SearchShortcut) error {
51- if key == "" {
52- return fmt.Errorf("shortcut key cannot be empty")
53- }
55+ _ = ctx
56+ if key == "" {
57+ return fmt.Errorf("shortcut key cannot be empty")
58+ }
5459
5560 if shortcut.URL == "" {
5661 return fmt.Errorf("shortcut URL cannot be empty")
@@ -66,9 +71,10 @@ func (s *ConfigService) AddSearchShortcut(ctx context.Context, key string, short
6671
6772 // UpdateSearchShortcut updates an existing search shortcut.
6873 func (s *ConfigService) UpdateSearchShortcut(ctx context.Context, key string, shortcut config.SearchShortcut) error {
69- if key == "" {
70- return fmt.Errorf("shortcut key cannot be empty")
71- }
74+ _ = ctx
75+ if key == "" {
76+ return fmt.Errorf("shortcut key cannot be empty")
77+ }
7278
7379 if _, exists := s.config.SearchShortcuts[key]; !exists {
7480 return fmt.Errorf("shortcut '%s' does not exist", key)
@@ -84,9 +90,10 @@ func (s *ConfigService) UpdateSearchShortcut(ctx context.Context, key string, sh
8490
8591 // DeleteSearchShortcut removes a search shortcut.
8692 func (s *ConfigService) DeleteSearchShortcut(ctx context.Context, key string) error {
87- if key == "" {
88- return fmt.Errorf("shortcut key cannot be empty")
89- }
93+ _ = ctx
94+ if key == "" {
95+ return fmt.Errorf("shortcut key cannot be empty")
96+ }
9097
9198 if _, exists := s.config.SearchShortcuts[key]; !exists {
9299 return fmt.Errorf("shortcut '%s' does not exist", key)
@@ -98,14 +105,16 @@ func (s *ConfigService) DeleteSearchShortcut(ctx context.Context, key string) er
98105
99106 // GetDmenuSettings returns current dmenu settings.
100107 func (s *ConfigService) GetDmenuSettings(ctx context.Context) (*config.DmenuConfig, error) {
101- return &s.config.Dmenu, nil
108+ _ = ctx
109+ return &s.config.Dmenu, nil
102110 }
103111
104112 // UpdateDmenuSettings updates dmenu configuration.
105113 func (s *ConfigService) UpdateDmenuSettings(ctx context.Context, settings *config.DmenuConfig) error {
106- if settings == nil {
107- return fmt.Errorf("dmenu settings cannot be nil")
108- }
114+ _ = ctx
115+ if settings == nil {
116+ return fmt.Errorf("dmenu settings cannot be nil")
117+ }
109118
110119 s.config.Dmenu = *settings
111120 return s.saveConfig()
@@ -113,20 +122,22 @@ func (s *ConfigService) UpdateDmenuSettings(ctx context.Context, settings *confi
113122
114123 // GetDatabasePath returns the current database path.
115124 func (s *ConfigService) GetDatabasePath(ctx context.Context) (string, error) {
116- return s.config.Database.Path, nil
125+ _ = ctx
126+ return s.config.Database.Path, nil
117127 }
118128
119129 // SetDatabasePath updates the database path.
120130 func (s *ConfigService) SetDatabasePath(ctx context.Context, path string) error {
121- if path == "" {
122- return fmt.Errorf("database path cannot be empty")
123- }
131+ _ = ctx
132+ if path == "" {
133+ return fmt.Errorf("database path cannot be empty")
134+ }
124135
125136 // Validate that the directory exists or can be created
126137 dir := filepath.Dir(path)
127- if err := os.MkdirAll(dir, 0755); err != nil {
128- return fmt.Errorf("failed to create database directory: %w", err)
129- }
138+ if err := os.MkdirAll(dir, defaultDirPerm); err != nil {
139+ return fmt.Errorf("failed to create database directory: %w", err)
140+ }
130141
131142 s.config.Database.Path = path
132143 return s.saveConfig()
@@ -134,23 +145,27 @@ func (s *ConfigService) SetDatabasePath(ctx context.Context, path string) error
134145
135146 // GetDefaultBrowser returns the default browser setting.
136147 func (s *ConfigService) GetDefaultBrowser(ctx context.Context) (string, error) {
137- // Default browser is now built into the app
138- return "dumber (built-in)", nil
148+ _ = ctx
149+ // Default browser is now built into the app
150+ return "dumber (built-in)", nil
139151 }
140152
141153 // SetDefaultBrowser updates the default browser setting.
142154 func (s *ConfigService) SetDefaultBrowser(ctx context.Context, browser string) error {
143- // Default browser setting is not needed for built-in browser
144- return fmt.Errorf("default browser is built-in and cannot be changed")
155+ _ = ctx
156+ _ = browser
157+ // Default browser setting is not needed for built-in browser
158+ return fmt.Errorf("default browser is built-in and cannot be changed")
145159 }
146160
147161 // ReloadConfig reloads configuration from file.
148162 func (s *ConfigService) ReloadConfig(ctx context.Context) error {
149- // Create new manager and load config
150- manager, err := config.NewManager()
151- if err != nil {
152- return fmt.Errorf("failed to create config manager: %w", err)
153- }
163+ _ = ctx
164+ // Create new manager and load config
165+ manager, err := config.NewManager()
166+ if err != nil {
167+ return fmt.Errorf("failed to create config manager: %w", err)
168+ }
154169
155170 if err := manager.Load(); err != nil {
156171 return fmt.Errorf("failed to load config: %w", err)
@@ -162,7 +177,8 @@ func (s *ConfigService) ReloadConfig(ctx context.Context) error {
162177
163178 // ValidateConfig validates the current configuration.
164179 func (s *ConfigService) ValidateConfig(ctx context.Context) ([]string, error) {
165- var errors []string
180+ _ = ctx
181+ var errors []string
166182
167183 // Validate database path
168184 if s.config.Database.Path == "" {
@@ -200,14 +216,16 @@ func (s *ConfigService) ValidateConfig(ctx context.Context) ([]string, error) {
200216
201217 // GetConfigPath returns the path to the configuration file.
202218 func (s *ConfigService) GetConfigPath(ctx context.Context) (string, error) {
203- return s.configPath, nil
219+ _ = ctx
220+ return s.configPath, nil
204221 }
205222
206223 // ExportConfig returns the current configuration as JSON.
207224 func (s *ConfigService) ExportConfig(ctx context.Context) (*config.Config, error) {
208- // Return a copy of the config to prevent modifications
209- configCopy := *s.config
210- return &configCopy, nil
225+ _ = ctx
226+ // Return a copy of the config to prevent modifications
227+ configCopy := *s.config
228+ return &configCopy, nil
211229 }
212230
213231 // ImportConfig imports configuration from a Config object.
@@ -237,4 +255,4 @@ func (s *ConfigService) saveConfig() error {
237255 // For now, return success - implementing config saving would require
238256 // extending the config package with save functionality
239257 return nil
240-}⏎̸
258+}
M services/parser_service.go +41 -28
@@ -10,7 +10,7 @@ import (
1010 "github.com/bnema/dumber/internal/parser"
1111 )
1212
13-// ParserService wraps the parser functionality for Wails integration.
13+// ParserService wraps the parser functionality for the application.
1414 type ParserService struct {
1515 parser *parser.Parser
1616 dbQueries *db.Queries
@@ -32,69 +32,81 @@ func NewParserService(cfg *config.Config, queries *db.Queries) *ParserService {
3232
3333 // ParseInput parses user input and returns navigation result.
3434 func (s *ParserService) ParseInput(ctx context.Context, input string) (*parser.ParseResult, error) {
35- if input == "" {
36- return nil, fmt.Errorf("input cannot be empty")
37- }
35+ _ = ctx
36+ if input == "" {
37+ return nil, fmt.Errorf("input cannot be empty")
38+ }
3839
3940 return s.parser.ParseInput(input)
4041 }
4142
4243 // GetCompletions provides URL/search completions based on input.
4344 func (s *ParserService) GetCompletions(ctx context.Context, input string, limit int) ([]string, error) {
44- if limit <= 0 {
45- limit = 10 // Default limit
46- }
45+ _ = ctx
46+ if limit <= 0 {
47+ limit = 10 // Default limit
48+ }
4749
4850 return s.parser.SuggestCompletions(input, limit)
4951 }
5052
5153 // FuzzySearchHistory performs fuzzy search on history entries.
52-func (s *ParserService) FuzzySearchHistory(ctx context.Context, query string, threshold float64) ([]parser.FuzzyMatch, error) {
53- if threshold <= 0 {
54- threshold = 0.3 // Default threshold
55- }
54+func (s *ParserService) FuzzySearchHistory(
55+ ctx context.Context, query string, threshold float64,
56+) ([]parser.FuzzyMatch, error) {
57+ _ = ctx
58+ if threshold <= 0 {
59+ threshold = 0.3 // Default threshold
60+ }
5661
5762 return s.parser.FuzzySearchHistory(query, threshold)
5863 }
5964
6065 // GetSupportedShortcuts returns configured search shortcuts.
6166 func (s *ParserService) GetSupportedShortcuts(ctx context.Context) (map[string]config.SearchShortcut, error) {
62- return s.parser.GetSupportedShortcuts(), nil
67+ _ = ctx
68+ return s.parser.GetSupportedShortcuts(), nil
6369 }
6470
6571 // ProcessShortcut processes a shortcut and returns the resulting URL.
6672 func (s *ParserService) ProcessShortcut(ctx context.Context, shortcut, query string) (string, error) {
67- shortcuts := s.parser.GetSupportedShortcuts()
68- return s.parser.ProcessShortcut(shortcut, query, shortcuts)
73+ _ = ctx
74+ shortcuts := s.parser.GetSupportedShortcuts()
75+ return s.parser.ProcessShortcut(shortcut, query, shortcuts)
6976 }
7077
7178 // CalculateSimilarity calculates similarity between two strings.
7279 func (s *ParserService) CalculateSimilarity(ctx context.Context, s1, s2 string) (float64, error) {
73- return s.parser.CalculateSimilarity(s1, s2), nil
80+ _ = ctx
81+ return s.parser.CalculateSimilarity(s1, s2), nil
7482 }
7583
7684 // ValidateURL checks if the input represents a valid URL.
7785 func (s *ParserService) ValidateURL(ctx context.Context, input string) (bool, error) {
78- return s.parser.IsValidURL(input), nil
86+ _ = ctx
87+ return s.parser.IsValidURL(input), nil
7988 }
8089
8190 // PreviewParse provides a preview of what ParseInput would return.
8291 func (s *ParserService) PreviewParse(ctx context.Context, input string) (*parser.ParseResult, error) {
83- if input == "" {
84- return nil, fmt.Errorf("input cannot be empty")
85- }
86-
87- return s.parser.PreviewParse(input)
92+ _ = ctx
93+ if input == "" {
94+ return nil, fmt.Errorf("input cannot be empty")
95+ }
96+
97+ return s.parser.PreviewParse(input)
8898 }
8999
90100 // GetFuzzyConfig returns current fuzzy matching configuration.
91101 func (s *ParserService) GetFuzzyConfig(ctx context.Context) (*parser.FuzzyConfig, error) {
92- return s.parser.GetFuzzyConfig(), nil
102+ _ = ctx
103+ return s.parser.GetFuzzyConfig(), nil
93104 }
94105
95106 // UpdateFuzzyConfig updates the fuzzy matching configuration.
96107 func (s *ParserService) UpdateFuzzyConfig(ctx context.Context, config *parser.FuzzyConfig) error {
97- return s.parser.UpdateFuzzyConfig(config)
108+ _ = ctx
109+ return s.parser.UpdateFuzzyConfig(config)
98110 }
99111
100112 // DatabaseHistoryProvider implements HistoryProvider using the database.
@@ -123,10 +135,11 @@ func (h *DatabaseHistoryProvider) GetRecentHistory(limit int) ([]*db.History, er
123135
124136 // GetAllHistory returns all history entries for fuzzy search.
125137 func (h *DatabaseHistoryProvider) GetAllHistory() ([]*db.History, error) {
126- ctx := context.Background()
127-
128- // Use a large limit to get all history
129- entries, err := h.queries.GetHistory(ctx, 10000)
138+ ctx := context.Background()
139+
140+ // Use a large limit to get all history
141+ const allHistoryLimit = 10000
142+ entries, err := h.queries.GetHistory(ctx, allHistoryLimit)
130143 if err != nil {
131144 return nil, err
132145 }
@@ -182,4 +195,4 @@ func (h *DatabaseHistoryProvider) GetHistoryByURL(url string) (*db.History, erro
182195 }
183196
184197 return &entries[0], nil
185-}⏎̸
198+}
A specs/003-webkit2gtk-migration/contracts/webkit-bindings-api.yaml +279 -0
@@ -0,0 +1,279 @@
1+openapi: 3.0.3
2+info:
3+ title: WebKit2GTK Go Bindings API
4+ description: Contract for pkg/webkit Go bindings to WebKit2GTK native browser engine
5+ version: 1.0.0
6+ contact:
7+ name: Dumber Browser WebKit Integration
8+
9+paths:
10+ # Note: This is a native library API, not REST endpoints
11+ # These represent Go function signatures for contract testing
12+
13+components:
14+ schemas:
15+ WebViewConfig:
16+ type: object
17+ properties:
18+ width:
19+ type: integer
20+ minimum: 800
21+ description: Initial window width
22+ height:
23+ type: integer
24+ minimum: 600
25+ description: Initial window height
26+ userAgent:
27+ type: string
28+ description: Browser user agent string
29+ enableDevTools:
30+ type: boolean
31+ default: true
32+ description: Enable WebKit developer tools
33+ enableJavaScript:
34+ type: boolean
35+ default: true
36+ description: Enable JavaScript execution
37+ required:
38+ - width
39+ - height
40+
41+ NavigationRequest:
42+ type: object
43+ properties:
44+ url:
45+ type: string
46+ format: uri
47+ description: Target URL for navigation
48+ timeout:
49+ type: integer
50+ default: 30000
51+ description: Navigation timeout in milliseconds
52+ required:
53+ - url
54+
55+ NavigationResult:
56+ type: object
57+ properties:
58+ success:
59+ type: boolean
60+ description: Navigation success status
61+ finalURL:
62+ type: string
63+ format: uri
64+ description: Final URL after redirects
65+ loadTime:
66+ type: integer
67+ description: Page load time in milliseconds
68+ error:
69+ type: string
70+ nullable: true
71+ description: Error message if navigation failed
72+
73+ KeyboardShortcut:
74+ type: object
75+ properties:
76+ modifiers:
77+ type: array
78+ items:
79+ type: string
80+ enum: [ctrl, alt, shift, cmd]
81+ description: Modifier keys required
82+ key:
83+ type: string
84+ description: Primary key (e.g., 'c', 'Left', 'Right')
85+ action:
86+ type: string
87+ enum: [navigate_back, navigate_forward, copy_url, zoom_in, zoom_out, zoom_reset]
88+ description: Action to perform
89+ required:
90+ - key
91+ - action
92+
93+ ScriptInjectionRequest:
94+ type: object
95+ properties:
96+ script:
97+ type: string
98+ description: JavaScript code to inject
99+ runAtEnd:
100+ type: boolean
101+ default: false
102+ description: Run script after DOM content loaded
103+ worldName:
104+ type: string
105+ default: "dumber-browser"
106+ description: WebKit user script world name
107+ required:
108+ - script
109+
110+ ScriptInjectionResult:
111+ type: object
112+ properties:
113+ success:
114+ type: boolean
115+ description: Script injection success status
116+ error:
117+ type: string
118+ nullable: true
119+ description: Error message if injection failed
120+
121+ ZoomRequest:
122+ type: object
123+ properties:
124+ level:
125+ type: number
126+ minimum: 0.25
127+ maximum: 5.0
128+ description: Zoom level (1.0 = 100%)
129+ required:
130+ - level
131+
132+ ZoomResult:
133+ type: object
134+ properties:
135+ success:
136+ type: boolean
137+ description: Zoom operation success status
138+ actualLevel:
139+ type: number
140+ description: Actual zoom level set by WebKit
141+ error:
142+ type: string
143+ nullable: true
144+ description: Error message if zoom failed
145+
146+ BrowserError:
147+ type: object
148+ properties:
149+ code:
150+ type: string
151+ enum: [
152+ WEBKIT_INIT_FAILED,
153+ GTK_INIT_FAILED,
154+ NAVIGATION_FAILED,
155+ SCRIPT_INJECTION_FAILED,
156+ KEYBOARD_BINDING_FAILED,
157+ MEMORY_LIMIT_EXCEEDED
158+ ]
159+ message:
160+ type: string
161+ description: Human-readable error message
162+ details:
163+ type: string
164+ nullable: true
165+ description: Technical error details
166+ required:
167+ - code
168+ - message
169+
170+# Contract Test Functions (Go function signatures)
171+# These will be implemented as integration tests
172+
173+x-go-functions:
174+
175+ # WebView Management
176+ NewWebView:
177+ description: Create new WebKit browser instance
178+ parameters:
179+ - name: config
180+ schema: WebViewConfig
181+ returns:
182+ - name: webview
183+ type: "*WebView"
184+ - name: error
185+ type: error
186+
187+ LoadURL:
188+ description: Navigate to specified URL
189+ parameters:
190+ - name: webview
191+ type: "*WebView"
192+ - name: request
193+ schema: NavigationRequest
194+ returns:
195+ - name: result
196+ schema: NavigationResult
197+ - name: error
198+ type: error
199+
200+ # Keyboard Shortcuts
201+ RegisterKeyboardShortcut:
202+ description: Register keyboard shortcut handler
203+ parameters:
204+ - name: webview
205+ type: "*WebView"
206+ - name: shortcut
207+ schema: KeyboardShortcut
208+ - name: callback
209+ type: "func()"
210+ returns:
211+ - name: error
212+ type: error
213+
214+ # Script Injection
215+ InjectScript:
216+ description: Inject JavaScript into current page
217+ parameters:
218+ - name: webview
219+ type: "*WebView"
220+ - name: request
221+ schema: ScriptInjectionRequest
222+ returns:
223+ - name: result
224+ schema: ScriptInjectionResult
225+ - name: error
226+ type: error
227+
228+ # Zoom Control
229+ SetZoom:
230+ description: Set browser zoom level
231+ parameters:
232+ - name: webview
233+ type: "*WebView"
234+ - name: request
235+ schema: ZoomRequest
236+ returns:
237+ - name: result
238+ schema: ZoomResult
239+ - name: error
240+ type: error
241+
242+ GetZoom:
243+ description: Get current browser zoom level
244+ parameters:
245+ - name: webview
246+ type: "*WebView"
247+ returns:
248+ - name: level
249+ type: float64
250+ - name: error
251+ type: error
252+
253+ # Lifecycle Management
254+ Show:
255+ description: Display browser window
256+ parameters:
257+ - name: webview
258+ type: "*WebView"
259+ returns:
260+ - name: error
261+ type: error
262+
263+ Hide:
264+ description: Hide browser window
265+ parameters:
266+ - name: webview
267+ type: "*WebView"
268+ returns:
269+ - name: error
270+ type: error
271+
272+ Destroy:
273+ description: Clean up browser resources
274+ parameters:
275+ - name: webview
276+ type: "*WebView"
277+ returns:
278+ - name: error
279+ type: error⏎̸
A specs/003-webkit2gtk-migration/data-model.md +155 -0
@@ -0,0 +1,155 @@
1+# Data Model: WebKit2GTK Native Browser Backend
2+
3+**Feature**: Native WebKit Browser Backend
4+**Date**: 2025-01-21
5+**Status**: Complete
6+
7+## Core Entities
8+
9+### WebKit Browser Instance
10+**Purpose**: Main browser engine wrapper managing WebKitWebView and GTK window
11+**Key Attributes**:
12+- WebView: WebKitWebView instance for rendering
13+- Window: GTK ApplicationWindow container
14+- Settings: WebKitSettings for browser configuration
15+- UserContentManager: WebKitUserContentManager for JavaScript injection
16+- KeybindingController: GTK event handler for keyboard shortcuts
17+
18+**Relationships**:
19+- Contains one WebKitWebView instance
20+- Manages one GTK ApplicationWindow
21+- References shared HistoryManager and ZoomManager
22+- Interfaces with existing SQLite database through unchanged schema
23+
24+### WebKit Bindings Interface
25+**Purpose**: CGO wrapper providing Go interface to WebKit2GTK C APIs
26+**Key Attributes**:
27+- WebViewHandle: C pointer to WebKitWebView
28+- WindowHandle: C pointer to GtkApplicationWindow
29+- SettingsHandle: C pointer to WebKitSettings
30+- CallbackRegistry: Go callback functions for WebKit signals
31+- ErrorContext: Error propagation from C to Go
32+
33+**State Transitions**:
34+1. Uninitialized → C library loaded → GTK initialized → WebKit configured → Ready
35+2. Ready → Loading URL → Content loaded → Interactive
36+3. Interactive → Navigation event → Loading URL (cycle)
37+4. Any state → Error → Error handling → Recovery attempt
38+
39+### Migration State Manager
40+**Purpose**: Manages transition from Wails to WebKit implementation
41+**Key Attributes**:
42+- MigrationMode: Flag indicating Wails/WebKit/Dual mode
43+- DataPreservation: User data backup and restore mechanisms
44+- FeatureParity: Validation checklist for Wails feature equivalence
45+- PerformanceMetrics: Comparison data between implementations
46+
47+**Validation Rules**:
48+- All existing user data must be preserved during migration
49+- Browser functionality must maintain 100% feature parity
50+- Performance must meet or exceed Wails baseline metrics
51+- Keyboard shortcuts must function identically to current implementation
52+
53+## Data Flow Architecture
54+
55+### Browser Lifecycle
56+```
57+1. Application Start
58+ ├── Load pkg/webkit bindings
59+ ├── Initialize GTK application
60+ ├── Create WebKitWebView instance
61+ ├── Configure browser settings
62+ ├── Setup keyboard event handlers
63+ └── Load initial URL or homepage
64+
65+2. URL Navigation
66+ ├── Validate URL through existing parser service
67+ ├── Load URL in WebKitWebView
68+ ├── Update history database (unchanged schema)
69+ ├── Configure zoom level from database
70+ └── Inject global keyboard scripts via UserContentManager
71+
72+3. Keyboard Events
73+ ├── GTK captures key events
74+ ├── Route to appropriate handler (navigation/zoom/copy)
75+ ├── Execute WebKit operation or JavaScript injection
76+ └── Update database if needed (zoom persistence)
77+```
78+
79+### C/Go Interface Boundaries
80+```
81+Go Layer (pkg/webkit):
82+├── WebView struct with Go methods
83+├── Event handling with Go channels
84+├── Error handling with Go error types
85+└── Memory management with Go finalizers
86+
87+CGO Bridge:
88+├── C function wrappers for WebKit APIs
89+├── Callback function registration
90+├── Memory allocation/deallocation
91+└── Type conversion (C ↔ Go)
92+
93+C Layer (WebKit2GTK):
94+├── WebKitWebView widget management
95+├── GTK event loop integration
96+├── WebKit signals and callbacks
97+└── Native browser engine operations
98+```
99+
100+## Database Integration
101+
102+### Preserved Schema
103+**Status**: No changes to existing SQLite schema
104+- History table: Maintain current structure and data
105+- Zoom settings table: Continue using domain-based zoom storage
106+- All SQLC generated queries: Unchanged interface
107+- Database migrations: No new migrations required
108+
109+### New Database Interactions
110+**WebKit-specific data**: Store as configuration, not database entities
111+- WebKit settings: Managed through WebKitSettings API
112+- User content scripts: Stored as embedded resources, not database
113+- Browser state: Memory-only, restored through WebKit APIs
114+
115+## Error Handling Model
116+
117+### Error Propagation Chain
118+```
119+WebKit C Error → CGO Error Conversion → Go Error → User Notification
120+
121+Error Categories:
122+├── Initialization Errors: WebKit library loading, GTK setup
123+├── Navigation Errors: Invalid URLs, network failures, load timeouts
124+├── Script Injection Errors: UserContentManager failures, CSP violations
125+├── Keyboard Binding Errors: GTK event registration, handler failures
126+└── Performance Errors: Memory limits, CPU constraints, timeout violations
127+```
128+
129+### Recovery Strategies
130+- Initialization failures: Fallback error dialog, graceful exit
131+- Navigation errors: Error page display, retry mechanisms
132+- Script failures: Silent failure with logging, functionality degradation
133+- Memory errors: Garbage collection, resource cleanup, restart recommendation
134+
135+## Performance Data Model
136+
137+### Metrics Collection
138+**Startup Performance**:
139+- Library loading time: WebKit2GTK and GTK initialization
140+- WebView creation time: Widget setup and configuration
141+- First paint time: Initial browser window display
142+
143+**Runtime Performance**:
144+- Memory usage: WebKit process + GTK overhead + Go runtime
145+- Navigation speed: URL load time, DOM ready time
146+- Event handling latency: Keyboard shortcut response time
147+
148+**Comparison Baseline**:
149+- Current Wails metrics as performance targets
150+- Performance regression detection through automated testing
151+- Resource usage monitoring for constitutional compliance (<100MB baseline)
152+
153+---
154+
155+**Data Model Status**: ✅ Complete - All entities defined, ready for contract generation⏎̸
A specs/003-webkit2gtk-migration/plan.md +240 -0
@@ -0,0 +1,240 @@
1+# Implementation Plan: Native WebKit Browser Backend
2+
3+**Branch**: `003-webkit2gtk-migration` | **Date**: 2025-01-21 | **Spec**: [spec.md](spec.md)
4+**Input**: Feature specification from `/home/brice/dev/projects/dumb-browser/specs/003-webkit2gtk-migration/spec.md`
5+
6+## Execution Flow (/plan command scope)
7+```
8+1. Load feature spec from Input path
9+ → If not found: ERROR "No feature spec at {path}"
10+2. Fill Technical Context (scan for NEEDS CLARIFICATION)
11+ → Detect Project Type from context (web=frontend+backend, mobile=app+api)
12+ → Set Structure Decision based on project type
13+3. Evaluate Constitution Check section below
14+ → If violations exist: Document in Complexity Tracking
15+ → If no justification possible: ERROR "Simplify approach first"
16+ → Update Progress Tracking: Initial Constitution Check
17+4. Execute Phase 0 → research.md
18+ → If NEEDS CLARIFICATION remain: ERROR "Resolve unknowns"
19+5. Execute Phase 1 → contracts, data-model.md, quickstart.md, agent-specific template file (e.g., `CLAUDE.md` for Claude Code, `.github/copilot-instructions.md` for GitHub Copilot, or `GEMINI.md` for Gemini CLI).
20+6. Re-evaluate Constitution Check section
21+ → If new violations: Refactor design, return to Phase 1
22+ → Update Progress Tracking: Post-Design Constitution Check
23+7. Plan Phase 2 → Describe task generation approach (DO NOT create tasks.md)
24+8. STOP - Ready for /tasks command
25+```
26+
27+**IMPORTANT**: The /plan command STOPS at step 7. Phases 2-4 are executed by other commands:
28+- Phase 2: /tasks command creates tasks.md
29+- Phase 3-4: Implementation execution (manual or via tools)
30+
31+## Summary
32+Replace Wails framework with direct WebKit2GTK bindings to gain full control over browser engine capabilities, maintain all existing functionality while removing framework limitations, and create custom C Go bindings in a pkg/ package for potential future extraction.
33+
34+## Technical Context
35+**Language/Version**: Go 1.25.1 + C bindings for WebKit2GTK
36+**Primary Dependencies**: WebKit2GTK, GTK3, CGO for C bindings
37+**Storage**: SQLite (maintain existing database schema and data)
38+**Testing**: Go testing + WebKit integration tests
39+**Target Platform**: Linux (Wayland/X11) - focus on GTK-based systems
40+**Project Type**: single - native desktop application
41+**Performance Goals**: <500ms startup time, <100MB baseline memory, maintain existing performance
42+**Constraints**: Remove all Wails dependencies, preserve user data, maintain keyboard shortcuts and browser features
43+**Scale/Scope**: Single browser window, existing CLI functionality, WebKit2GTK reference clone for development
44+
45+## Constitution Check
46+*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
47+
48+**Simplicity**:
49+- Projects: 1 (single native browser application)
50+- Using framework directly? ✅ (Direct WebKit2GTK, removing Wails wrapper)
51+- Single data model? ✅ (Maintain existing SQLite schema)
52+- Avoiding patterns? ✅ (Direct C bindings, no unnecessary abstraction layers)
53+
54+**Architecture**:
55+- EVERY feature as library? ✅ (pkg/ package for WebKit bindings, reusable)
56+- Libraries listed: pkg/webkit (WebKit2GTK Go bindings), existing internal packages
57+- CLI per library: Maintain existing CLI, add webkit package testing commands
58+- Library docs: llms.txt format planned for pkg/webkit
59+
60+**Testing (NON-NEGOTIABLE)**:
61+- RED-GREEN-Refactor cycle enforced? ✅ (Contract tests for WebKit bindings first)
62+- Git commits show tests before implementation? ✅ (Will create failing WebKit tests)
63+- Order: Contract→Integration→E2E→Unit strictly followed? ✅
64+- Real dependencies used? ✅ (Real WebKit2GTK, actual browser engine)
65+- Integration tests for: WebKit bindings, browser functionality, keyboard shortcuts
66+- FORBIDDEN: Implementation before test, skipping RED phase
67+
68+**Observability**:
69+- Structured logging included? ✅ (Continue using slog)
70+- Frontend logs → backend? ✅ (WebKit console logs to Go backend)
71+- Error context sufficient? ✅ (WebKit error propagation to Go)
72+
73+**Versioning**:
74+- Version number assigned? Next version after current (maintain semver)
75+- BUILD increments on every change? ✅
76+- Breaking changes handled? ✅ (Migration from Wails to native, preserve user data)
77+
78+## Project Structure
79+
80+### Documentation (this feature)
81+```
82+specs/[###-feature]/
83+├── plan.md # This file (/plan command output)
84+├── research.md # Phase 0 output (/plan command)
85+├── data-model.md # Phase 1 output (/plan command)
86+├── quickstart.md # Phase 1 output (/plan command)
87+├── contracts/ # Phase 1 output (/plan command)
88+└── tasks.md # Phase 2 output (/tasks command - NOT created by /plan)
89+```
90+
91+### Source Code (repository root)
92+```
93+# Option 1: Single project (DEFAULT)
94+src/
95+├── models/
96+├── services/
97+├── cli/
98+└── lib/
99+
100+tests/
101+├── contract/
102+├── integration/
103+└── unit/
104+
105+# Option 2: Web application (when "frontend" + "backend" detected)
106+backend/
107+├── src/
108+│ ├── models/
109+│ ├── services/
110+│ └── api/
111+└── tests/
112+
113+frontend/
114+├── src/
115+│ ├── components/
116+│ ├── pages/
117+│ └── services/
118+└── tests/
119+
120+# Option 3: Mobile + API (when "iOS/Android" detected)
121+api/
122+└── [same as backend above]
123+
124+ios/ or android/
125+└── [platform-specific structure]
126+```
127+
128+**Structure Decision**: Option 1 (Single project) - Native desktop application with pkg/ for WebKit bindings
129+
130+## Phase 0: Outline & Research
131+1. **Extract unknowns from Technical Context** above:
132+ - For each NEEDS CLARIFICATION → research task
133+ - For each dependency → best practices task
134+ - For each integration → patterns task
135+
136+2. **Generate and dispatch research agents**:
137+ ```
138+ For each unknown in Technical Context:
139+ Task: "Research {unknown} for {feature context}"
140+ For each technology choice:
141+ Task: "Find best practices for {tech} in {domain}"
142+ ```
143+
144+3. **Consolidate findings** in `research.md` using format:
145+ - Decision: [what was chosen]
146+ - Rationale: [why chosen]
147+ - Alternatives considered: [what else evaluated]
148+
149+**Output**: research.md with all NEEDS CLARIFICATION resolved
150+
151+## Phase 1: Design & Contracts
152+*Prerequisites: research.md complete*
153+
154+1. **Extract entities from feature spec** → `data-model.md`:
155+ - Entity name, fields, relationships
156+ - Validation rules from requirements
157+ - State transitions if applicable
158+
159+2. **Generate API contracts** from functional requirements:
160+ - For each user action → endpoint
161+ - Use standard REST/GraphQL patterns
162+ - Output OpenAPI/GraphQL schema to `/contracts/`
163+
164+3. **Generate contract tests** from contracts:
165+ - One test file per endpoint
166+ - Assert request/response schemas
167+ - Tests must fail (no implementation yet)
168+
169+4. **Extract test scenarios** from user stories:
170+ - Each story → integration test scenario
171+ - Quickstart test = story validation steps
172+
173+5. **Update agent file incrementally** (O(1) operation):
174+ - Run `/scripts/update-agent-context.sh [claude|gemini|copilot]` for your AI assistant
175+ - If exists: Add only NEW tech from current plan
176+ - Preserve manual additions between markers
177+ - Update recent changes (keep last 3)
178+ - Keep under 150 lines for token efficiency
179+ - Output to repository root
180+
181+**Output**: data-model.md, /contracts/*, failing tests, quickstart.md, agent-specific file
182+
183+## Phase 2: Task Planning Approach
184+*This section describes what the /tasks command will do - DO NOT execute during /plan*
185+
186+**Task Generation Strategy**:
187+- Load `/templates/tasks-template.md` as base
188+- Generate tasks from WebKit bindings contract (webkit-bindings-api.yaml)
189+- Create pkg/webkit package structure and CGO bridge tasks [P]
190+- WebKit2GTK reference repository setup and analysis tasks
191+- Contract tests for each WebKit API function [P]
192+- Integration tests for browser features (navigation, zoom, shortcuts)
193+- Migration tasks to remove Wails dependencies sequentially
194+- Performance validation and data preservation verification tasks
195+
196+**Ordering Strategy**:
197+- TDD order: Contract tests before WebKit bindings implementation
198+- Dependency order: CGO bridge → WebKit API → Browser integration → Wails removal
199+- Parallel tasks: Independent WebKit functions, separate test files [P]
200+- Sequential migration: Preserve existing functionality throughout transition
201+
202+**Estimated Output**: 30-35 numbered, ordered tasks focusing on WebKit migration in tasks.md
203+
204+**IMPORTANT**: This phase is executed by the /tasks command, NOT by /plan
205+
206+## Phase 3+: Future Implementation
207+*These phases are beyond the scope of the /plan command*
208+
209+**Phase 3**: Task execution (/tasks command creates tasks.md)
210+**Phase 4**: Implementation (execute tasks.md following constitutional principles)
211+**Phase 5**: Validation (run tests, execute quickstart.md, performance validation)
212+
213+## Complexity Tracking
214+*Fill ONLY if Constitution Check has violations that must be justified*
215+
216+| Violation | Why Needed | Simpler Alternative Rejected Because |
217+|-----------|------------|-------------------------------------|
218+| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
219+| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
220+
221+
222+## Progress Tracking
223+*This checklist is updated during execution flow*
224+
225+**Phase Status**:
226+- [x] Phase 0: Research complete (/plan command)
227+- [x] Phase 1: Design complete (/plan command)
228+- [x] Phase 2: Task planning complete (/plan command - describe approach only)
229+- [ ] Phase 3: Tasks generated (/tasks command)
230+- [ ] Phase 4: Implementation complete
231+- [ ] Phase 5: Validation passed
232+
233+**Gate Status**:
234+- [x] Initial Constitution Check: PASS
235+- [x] Post-Design Constitution Check: PASS
236+- [x] All NEEDS CLARIFICATION resolved
237+- [x] Complexity deviations documented (none required)
238+
239+---
240+*Based on Constitution v2.1.1 - See `/memory/constitution.md`*⏎̸
A specs/003-webkit2gtk-migration/quickstart.md +199 -0
@@ -0,0 +1,199 @@
1+# Quickstart: WebKit2GTK Native Browser Backend
2+
3+**Feature**: Native WebKit Browser Backend
4+**Date**: 2025-01-21
5+**Status**: Ready for implementation
6+
7+## Prerequisites
8+
9+### System Dependencies
10+```bash
11+# Install WebKit2GTK development libraries
12+sudo pacman -S webkit2gtk-4.0 gtk3
13+
14+# Clone WebKit2GTK reference repository
15+mkdir -p /home/brice/dev/clone
16+cd /home/brice/dev/clone
17+git clone https://github.com/WebKit/webkit.git webkit2gtk-reference
18+
19+# Verify CGO is available
20+go env CGO_ENABLED # Should output: 1
21+```
22+
23+### Development Environment
24+```bash
25+# Ensure Go version compatibility
26+go version # Should be Go 1.21+
27+
28+# Install WebKit2GTK pkg-config
29+pkg-config --cflags webkit2gtk-4.0 # Should output compiler flags
30+pkg-config --libs webkit2gtk-4.0 # Should output linker flags
31+```
32+
33+## Quick Validation
34+
35+### Step 1: Verify Current State
36+```bash
37+# Check current browser works with Wails
38+./dumber-browser
39+# Expected: Browser opens with homepage, keyboard shortcuts work
40+
41+# Note current performance baseline
42+time ./dumber-browser &
43+# Expected: Browser starts within 500ms
44+```
45+
46+### Step 2: Basic WebKit Integration Test
47+```bash
48+# Create minimal WebKit test (after pkg/webkit implementation)
49+cd tests/integration
50+go test -v ./webkit_basic_test.go
51+# Expected: WebView creates, loads google.fr, closes cleanly
52+```
53+
54+### Step 3: Feature Parity Validation
55+```bash
56+# Test keyboard shortcuts
57+# Expected: Alt+Left/Right = navigation, Ctrl+Shift+C = copy URL
58+
59+# Test zoom functionality
60+# Expected: Ctrl++/Ctrl+-/Ctrl+0 zoom in/out/reset
61+
62+# Test script injection
63+# Expected: Global keyboard shortcuts work on external sites
64+```
65+
66+### Step 4: Performance Validation
67+```bash
68+# Startup time test
69+time ./dumber-browser &
70+# Target: <500ms (same as Wails baseline)
71+
72+# Memory usage test
73+ps aux | grep dumber-browser
74+# Target: <100MB baseline + WebKit overhead
75+
76+# Navigation performance
77+# Target: Page loads equivalent to current Wails performance
78+```
79+
80+### Step 5: Data Preservation Test
81+```bash
82+# Backup current database
83+cp ~/.config/dumber/browser.db ~/.config/dumber/browser.db.backup
84+
85+# Run WebKit version, browse some sites, adjust zoom
86+# Close browser, restart
87+
88+# Verify data preserved
89+sqlite3 ~/.config/dumber/browser.db "SELECT COUNT(*) FROM history;"
90+# Expected: Same or increased count, no data loss
91+
92+# Verify zoom settings preserved
93+# Expected: Previously set zoom levels maintained
94+```
95+
96+## Success Criteria Checklist
97+
98+### Basic Functionality ✓
99+- [ ] Browser window opens and displays correctly
100+- [ ] Can navigate to google.fr successfully
101+- [ ] Page renders with full HTML5/CSS3/JavaScript support
102+- [ ] Browser window closes cleanly without errors
103+
104+### Keyboard Shortcuts ✓
105+- [ ] Alt+Left arrow navigates back
106+- [ ] Alt+Right arrow navigates forward
107+- [ ] Ctrl+Shift+C copies current URL to clipboard
108+- [ ] Ctrl+Plus/Equals zooms in
109+- [ ] Ctrl+Minus zooms out
110+- [ ] Ctrl+0 resets zoom to 100%
111+
112+### Data Preservation ✓
113+- [ ] Existing browser history maintained
114+- [ ] Zoom settings per domain preserved
115+- [ ] No database schema changes required
116+- [ ] All SQLC queries work without modification
117+
118+### Performance Standards ✓
119+- [ ] Startup time <500ms (constitution requirement)
120+- [ ] Memory usage <100MB baseline (constitution requirement)
121+- [ ] Navigation speed equivalent to Wails baseline
122+- [ ] Keyboard shortcut response time <100ms
123+
124+### Advanced Features ✓
125+- [ ] JavaScript injection works on external sites
126+- [ ] Developer tools accessible (F12)
127+- [ ] Error handling graceful (no crashes)
128+- [ ] Multiple page navigation works correctly
129+
130+## Troubleshooting Common Issues
131+
132+### WebKit Initialization Fails
133+```bash
134+# Check WebKit2GTK installation
135+pkg-config --exists webkit2gtk-4.0 && echo "WebKit2GTK found" || echo "WebKit2GTK missing"
136+
137+# Check GTK installation
138+pkg-config --exists gtk+-3.0 && echo "GTK3 found" || echo "GTK3 missing"
139+```
140+
141+### CGO Compilation Errors
142+```bash
143+# Verify CGO configuration
144+go env CGO_ENABLED CGO_CFLAGS CGO_LDFLAGS
145+
146+# Test basic CGO compilation
147+echo 'package main; import "C"; func main() {}' | go run -x -
148+```
149+
150+### Performance Regression
151+```bash
152+# Compare memory usage
153+# Wails version: ps aux | grep dumber-browser (baseline)
154+# WebKit version: ps aux | grep dumber-browser (should be similar)
155+
156+# Compare startup time
157+time ./dumber-browser-wails &
158+time ./dumber-browser-webkit &
159+# WebKit should be equal or faster
160+```
161+
162+### Keyboard Shortcuts Not Working
163+```bash
164+# Test GTK event handling in isolation
165+# Check that GTK application receives key events
166+# Verify WebKit UserContentManager script injection
167+
168+# Test on different sites
169+# google.fr - should work
170+# Local file - should work
171+# HTTPS site with CSP - may need handling
172+```
173+
174+## Migration Strategy
175+
176+### Phase 1: Parallel Implementation
177+- Keep existing Wails code intact
178+- Develop pkg/webkit alongside current implementation
179+- Use feature flags or separate binary for testing
180+
181+### Phase 2: Feature Parity
182+- Achieve 100% functional equivalence with Wails version
183+- Pass all existing tests with WebKit implementation
184+- Validate performance meets constitutional requirements
185+
186+### Phase 3: Full Migration
187+- Switch default implementation to WebKit
188+- Remove Wails dependencies from go.mod
189+- Remove frontend TypeScript build system
190+- Update documentation to remove Wails references
191+
192+### Phase 4: Cleanup
193+- Remove deprecated Wails code
194+- Optimize pkg/webkit for production use
195+- Prepare pkg/webkit for potential repository extraction
196+
197+---
198+
199+**Quickstart Status**: ✅ Ready - All validation steps defined, implementation can begin⏎̸
A specs/003-webkit2gtk-migration/research.md +113 -0
@@ -0,0 +1,113 @@
1+# Research: WebKit2GTK Native Bindings Migration
2+
3+**Feature**: Native WebKit Browser Backend
4+**Date**: 2025-01-21
5+**Status**: Complete
6+
7+## WebKit2GTK Integration Patterns
8+
9+### Decision: CGO-based WebKit2GTK bindings
10+**Rationale**:
11+- Direct access to WebKit2GTK C APIs provides full control over browser engine
12+- CGO allows seamless integration of C libraries with Go code
13+- Performance critical for <500ms startup requirement
14+- Maintains compatibility with existing GTK-based Linux desktop environments
15+
16+**Alternatives considered**:
17+- Keeping Wails: Rejected due to framework limitations and lack of direct engine control
18+- gotk3/webkit2: Existing Go bindings but lack modern WebKit2GTK features needed
19+- Embedding Chromium: Too heavyweight, violates constitution's speed-first principle
20+
21+## C Go Bindings Architecture
22+
23+### Decision: pkg/webkit package structure
24+**Rationale**:
25+- Isolates WebKit-specific code for future extraction as separate repository
26+- Follows Go package conventions with clear API boundaries
27+- Enables independent testing and versioning of WebKit bindings
28+- Supports constitution requirement for library-based architecture
29+
30+**Alternatives considered**:
31+- Internal package only: Rejected, doesn't support future repo extraction goal
32+- Multiple WebKit packages: Over-engineering, violates simplicity principle
33+- Direct main package integration: Tight coupling, harder to test and maintain
34+
35+## WebKit2GTK Reference Implementation
36+
37+### Decision: Clone WebKit2GTK repository to /home/brice/dev/clone
38+**Rationale**:
39+- Provides official API documentation and examples for C bindings
40+- Reference implementations for browser features (navigation, zoom, JS execution)
41+- Understanding WebKit widget lifecycle and event handling patterns
42+- Source of truth for WebKit signal/callback patterns needed for keyboard shortcuts
43+
44+**Research findings**:
45+- WebKit2GTK provides WebKitWebView widget for rendering
46+- WebKitSettings for browser configuration and feature toggles
47+- WebKitUserContentManager for JavaScript injection (replaces current Wails script injection)
48+- GTK key binding system for keyboard shortcuts (Alt+arrows, Ctrl+shortcuts)
49+
50+## Migration Strategy
51+
52+### Decision: Gradual replacement maintaining data compatibility
53+**Rationale**:
54+- Constitution requires preserving user data (history, bookmarks, zoom settings)
55+- Existing SQLite schema and SQLC generated code can be maintained
56+- Allows A/B testing of WebKit vs Wails implementations
57+- Minimizes user disruption during migration
58+
59+**Migration approach**:
60+1. Create pkg/webkit with basic WebView rendering
61+2. Implement keyboard shortcuts through GTK event handling
62+3. Replace Wails window with GTK ApplicationWindow + WebKitWebView
63+4. Migrate script injection from Wails ExecJS to WebKit UserContentManager
64+5. Remove Wails dependencies and frontend build system
65+
66+## Performance Considerations
67+
68+### Decision: Direct WebKit widget integration
69+**Rationale**:
70+- WebKit2GTK designed for embedding in native applications
71+- Eliminates JavaScript bridge overhead present in Wails
72+- Direct GTK event loop integration for better responsiveness
73+- Memory management handled by GObject reference counting
74+
75+**Performance expectations**:
76+- Startup time improvement due to removing Wails initialization overhead
77+- Memory reduction by eliminating embedded web frontend
78+- Better keyboard shortcut responsiveness through direct GTK events
79+- Improved page loading performance with direct WebKit configuration
80+
81+## Documentation Sources
82+
83+### Decision: Multi-source documentation approach
84+**Rationale**:
85+- WebKit2GTK official docs: API reference and C examples
86+- GNOME Web (Epiphany) source: Real-world WebKit2GTK usage patterns
87+- CGO best practices: Memory management and C/Go interface design
88+- GTK documentation: Window management and keyboard event handling
89+
90+**Key resources identified**:
91+- WebKit2GTK API Reference: https://webkitgtk.org/reference/webkit2gtk/stable/
92+- Epiphany browser source: Example of WebKit2GTK browser implementation
93+- CGO documentation: Memory safety and performance best practices
94+- GTK4 migration guides: Future-proofing for eventual GTK4 support
95+
96+## Risk Mitigation
97+
98+### Decision: Incremental implementation with fallback plan
99+**Rationale**:
100+- Large architectural change requires careful validation
101+- User data preservation is non-negotiable requirement
102+- Performance regressions would violate constitution speed-first principle
103+
104+**Mitigation strategies**:
105+- Maintain parallel Wails implementation during development
106+- Comprehensive integration testing before removing Wails
107+- Database migration scripts with rollback capability
108+- Performance benchmarking against current Wails implementation
109+- Feature parity validation through existing test suite
110+
111+---
112+
113+**Research Status**: ✅ Complete - All technical unknowns resolved, ready for Phase 1 design⏎̸
A specs/003-webkit2gtk-migration/spec.md +121 -0
@@ -0,0 +1,121 @@
1+# Feature Specification: Native WebKit Browser Backend
2+
3+**Feature Branch**: `003-webkit2gtk-migration`
4+**Created**: 2025-01-21
5+**Status**: Draft
6+**Input**: User description: "I want to ditch Wails and use Webkit2Gtk directly and create my own C go bindings"
7+
8+## Execution Flow (main)
9+```
10+1. Parse user description from Input
11+ → If empty: ERROR "No feature description provided"
12+2. Extract key concepts from description
13+ → Identify: actors, actions, data, constraints
14+3. For each unclear aspect:
15+ → Mark with [NEEDS CLARIFICATION: specific question]
16+4. Fill User Scenarios & Testing section
17+ → If no clear user flow: ERROR "Cannot determine user scenarios"
18+5. Generate Functional Requirements
19+ → Each requirement must be testable
20+ → Mark ambiguous requirements
21+6. Identify Key Entities (if data involved)
22+7. Run Review Checklist
23+ → If any [NEEDS CLARIFICATION]: WARN "Spec has uncertainties"
24+ → If implementation details found: ERROR "Remove tech details"
25+8. Return: SUCCESS (spec ready for planning)
26+```
27+
28+---
29+
30+## ⚡ Quick Guidelines
31+- ✅ Focus on WHAT users need and WHY
32+- ❌ Avoid HOW to implement (no tech stack, APIs, code structure)
33+- 👥 Written for business stakeholders, not developers
34+
35+### Section Requirements
36+- **Mandatory sections**: Must be completed for every feature
37+- **Optional sections**: Include only when relevant to the feature
38+- When a section doesn't apply, remove it entirely (don't leave as "N/A")
39+
40+### For AI Generation
41+When creating this spec from a user prompt:
42+1. **Mark all ambiguities**: Use [NEEDS CLARIFICATION: specific question] for any assumption you'd need to make
43+2. **Don't guess**: If the prompt doesn't specify something (e.g., "login system" without auth method), mark it
44+3. **Think like a tester**: Every vague requirement should fail the "testable and unambiguous" checklist item
45+4. **Common underspecified areas**:
46+ - User types and permissions
47+ - Data retention/deletion policies
48+ - Performance targets and scale
49+ - Error handling behaviors
50+ - Integration requirements
51+ - Security/compliance needs
52+
53+---
54+
55+## User Scenarios & Testing *(mandatory)*
56+
57+### Primary User Story
58+As a developer/power user of the dumb browser application, I need the browser to have full control over its web rendering engine and browser capabilities without being limited by third-party frameworks, so that I can customize browser behavior, optimize performance, and maintain direct access to all native browser features.
59+
60+### Acceptance Scenarios
61+1. **Given** the current browser uses a third-party framework, **When** I replace it with a native solution, **Then** the browser maintains all existing functionality (navigation, zoom, keyboard controls, history)
62+2. **Given** the native browser backend is implemented, **When** I want to add custom browser features, **Then** I can directly access and modify browser engine capabilities without framework limitations
63+3. **Given** the browser is running with the native backend, **When** users interact with web pages, **Then** performance is equal to or better than the previous framework-based implementation
64+4. **Given** the native backend is active, **When** the browser encounters different web content types, **Then** all standard web technologies render correctly
65+
66+### Edge Cases
67+- What happens when the native bindings fail to load or initialize?
68+- How does the system handle web pages that stress-test browser engine capabilities?
69+- What occurs if the browser needs to interface with system-level features not exposed by the native bindings?
70+
71+## Requirements *(mandatory)*
72+
73+### Functional Requirements
74+- **FR-001**: System MUST render web pages with full HTML5, CSS3, and JavaScript support equivalent to modern browsers
75+- **FR-002**: System MUST maintain all existing browser controls (navigation, zoom, keyboard shortcuts) without regression
76+- **FR-003**: System MUST preserve existing user data (history, bookmarks, zoom settings) during migration
77+- **FR-004**: System MUST support the same web content types and media formats as the current implementation
78+- **FR-005**: System MUST provide identical or improved page loading performance compared to current framework
79+- **FR-006**: System MUST maintain cross-platform compatibility [NEEDS CLARIFICATION: which platforms need to be supported - Linux only, or multiple platforms?]
80+- **FR-007**: System MUST handle browser crashes gracefully without data loss
81+- **FR-008**: System MUST support developer tools and debugging capabilities for web content
82+- **FR-009**: System MUST maintain security features like sandboxing and content security policies
83+- **FR-010**: System MUST provide memory management equivalent to or better than current implementation
84+
85+### Key Entities
86+- **Browser Engine Interface**: The abstraction layer that handles web content rendering, JavaScript execution, and browser feature access
87+- **Native Bindings**: The interface components that connect high-level application code with low-level browser engine capabilities
88+- **Migration State**: Configuration and data preservation mechanisms that ensure seamless transition from old to new backend
89+
90+---
91+
92+## Review & Acceptance Checklist
93+*GATE: Automated checks run during main() execution*
94+
95+### Content Quality
96+- [ ] No implementation details (languages, frameworks, APIs)
97+- [ ] Focused on user value and business needs
98+- [ ] Written for non-technical stakeholders
99+- [ ] All mandatory sections completed
100+
101+### Requirement Completeness
102+- [ ] No [NEEDS CLARIFICATION] markers remain
103+- [ ] Requirements are testable and unambiguous
104+- [ ] Success criteria are measurable
105+- [ ] Scope is clearly bounded
106+- [ ] Dependencies and assumptions identified
107+
108+---
109+
110+## Execution Status
111+*Updated by main() during processing*
112+
113+- [x] User description parsed
114+- [x] Key concepts extracted
115+- [x] Ambiguities marked
116+- [x] User scenarios defined
117+- [x] Requirements generated
118+- [x] Entities identified
119+- [ ] Review checklist passed
120+
121+---⏎̸
A specs/003-webkit2gtk-migration/tasks.md +146 -0
@@ -0,0 +1,146 @@
1+# Tasks: WebKit2GTK Native Browser Migration
2+
3+**Input**: Design documents from `/specs/003-webkit2gtk-migration/`
4+**Prerequisites**: plan.md (✓), research.md (✓), data-model.md (✓), contracts/ (✓), quickstart.md (✓)
5+
6+## Phase 3.1: Setup
7+
8+- [ ] T001 Clone WebKit2GTK reference repository to /home/brice/dev/clone/webkit2gtk-reference
9+- [ ] T002 Install WebKit2GTK and GTK3 development dependencies on system
10+- [x] T003 [P] Create pkg/webkit package structure with basic Go files
11+- [x] T004 [P] Configure CGO build flags for WebKit2GTK and GTK3 in pkg/webkit
12+- [x] T005 [P] Setup linting and formatting tools for CGO code
13+
14+## Phase 3.2: Tests First (TDD) ⚠️ MUST COMPLETE BEFORE 3.3
15+
16+**CRITICAL: These tests MUST be written and MUST FAIL before ANY implementation**
17+
18+- [x] T006 [P] Contract test NewWebView function in tests/contract/test_webview_creation.go
19+- [x] T007 [P] Contract test LoadURL navigation in tests/contract/test_navigation.go
20+- [x] T008 [P] Contract test RegisterKeyboardShortcut in tests/contract/test_keyboard_shortcuts.go
21+- [x] T009 [P] Contract test InjectScript functionality in tests/contract/test_script_injection.go
22+- [x] T010 [P] Contract test SetZoom/GetZoom operations in tests/contract/test_zoom_control.go
23+- [x] T011 [P] Contract test Show/Hide/Destroy lifecycle in tests/contract/test_lifecycle.go
24+- [x] T012 [P] Integration test basic WebKit browser window in tests/integration/test_webkit_basic.go
25+- [x] T013 [P] Integration test google.fr navigation in tests/integration/test_google_navigation.go
26+- [x] T014 [P] Integration test keyboard shortcuts on external sites in tests/integration/test_external_shortcuts.go
27+- [x] T015 [P] Integration test zoom persistence with database in tests/integration/test_zoom_persistence.go
28+
29+## Phase 3.3: Core Implementation (ONLY after tests are failing)
30+
31+- [x] T016 [P] WebView struct and basic CGO wrapper in pkg/webkit/webview.go
32+- [x] T017 [P] GTK Application window management in pkg/webkit/window.go
33+- [x] T018 [P] WebKit settings configuration in pkg/webkit/settings.go
34+- [x] T019 [P] Keyboard event handling with GTK in pkg/webkit/keyboard.go
35+- [x] T020 [P] JavaScript injection via UserContentManager in pkg/webkit/script.go
36+- [x] T021 [P] Zoom control WebKit API wrapper in pkg/webkit/zoom.go
37+- [x] T022 NewWebView constructor function
38+- [x] T023 LoadURL navigation implementation
39+- [x] T024 RegisterKeyboardShortcut GTK event binding
40+- [x] T025 InjectScript WebKit UserContentManager integration
41+- [x] T026 SetZoom/GetZoom WebKit API calls
42+- [x] T027 Show/Hide/Destroy lifecycle management
43+- [x] T028 CGO memory management and finalizers
44+- [ ] T029 Error handling and C to Go error propagation
45+
46+## Phase 3.4: Integration
47+
48+- [x] T030 WebKit browser service integration in services/browser_service.go
49+- [x] T031 Replace Wails ExecJS with WebKit InjectScript in services/browser_service.go
50+- [x] T032 Integrate WebKit zoom with existing database storage in services/browser_service.go
51+- [x] T033 Update main.go to use WebKit browser instead of Wails
52+- [x] T034 Remove Wails periodic script injection, use WebKit UserContentManager
53+- [x] T035 Update CLI commands to work with WebKit backend
54+- [x] T036 Preserve existing keyboard shortcut behavior through GTK events
55+
56+## Phase 3.5: Migration & Polish
57+
58+- [ ] T037 [P] Performance tests: startup time <500ms in tests/unit/test_performance.go
59+- [ ] T038 [P] Performance tests: memory usage <100MB baseline in tests/unit/test_memory.go
60+- [ ] T039 [P] Unit tests for WebKit error handling in tests/unit/test_error_handling.go
61+- [ ] T040 [P] Unit tests for CGO memory management in tests/unit/test_memory_management.go
62+- [x] T041 Data preservation validation: existing history and zoom settings
63+- [x] T042 Remove Wails dependencies from go.mod and go.sum
64+- [ ] T043 Remove frontend TypeScript build system (frontend/ directory)
65+- [ ] T044 Remove Wails references from documentation and README
66+- [ ] T045 [P] Update pkg/webkit documentation in llms.txt format
67+- [ ] T046 Run quickstart.md validation scenarios
68+- [ ] T047 Performance comparison: WebKit vs Wails baseline metrics
69+
70+## Dependencies
71+
72+- Setup (T001-T005) before tests (T006-T015)
73+- Tests (T006-T015) before implementation (T016-T029)
74+- T016 blocks T022, T030, T033
75+- T017 blocks T027, T033
76+- T018 blocks T022, T026
77+- T019 blocks T024, T036
78+- T020 blocks T025, T031
79+- T021 blocks T026, T032
80+- Core implementation (T016-T029) before integration (T030-T036)
81+- Integration (T030-T036) before migration (T042-T044)
82+- Implementation before polish (T037-T047)
83+
84+## Parallel Example
85+
86+```
87+# Launch T006-T011 together (contract tests):
88+Task: "Contract test NewWebView function in tests/contract/test_webview_creation.go"
89+Task: "Contract test LoadURL navigation in tests/contract/test_navigation.go"
90+Task: "Contract test RegisterKeyboardShortcut in tests/contract/test_keyboard_shortcuts.go"
91+Task: "Contract test InjectScript functionality in tests/contract/test_script_injection.go"
92+Task: "Contract test SetZoom/GetZoom operations in tests/contract/test_zoom_control.go"
93+Task: "Contract test Show/Hide/Destroy lifecycle in tests/contract/test_lifecycle.go"
94+
95+# Launch T016-T021 together (core CGO wrappers):
96+Task: "WebView struct and basic CGO wrapper in pkg/webkit/webview.go"
97+Task: "GTK Application window management in pkg/webkit/window.go"
98+Task: "WebKit settings configuration in pkg/webkit/settings.go"
99+Task: "Keyboard event handling with GTK in pkg/webkit/keyboard.go"
100+Task: "JavaScript injection via UserContentManager in pkg/webkit/script.go"
101+Task: "Zoom control WebKit API wrapper in pkg/webkit/zoom.go"
102+```
103+
104+## Notes
105+
106+- [P] tasks = different files, no dependencies
107+- Verify all contract tests fail before implementing
108+- Commit after each task completion
109+- WebKit2GTK reference repository provides C API examples
110+- CGO memory management critical for stability
111+- Preserve all existing user data during migration
112+
113+## Task Generation Rules
114+
115+*Applied during main() execution*
116+
117+1. **From Contracts**:
118+ - Each Go function in webkit-bindings-api.yaml → contract test task [P]
119+ - Each WebKit API operation → implementation task
120+
121+2. **From Data Model**:
122+ - WebKit Browser Instance → WebView/Window/Settings tasks [P]
123+ - WebKit Bindings Interface → CGO wrapper tasks [P]
124+ - Migration State Manager → data preservation tasks
125+
126+3. **From User Stories**:
127+ - google.fr navigation → integration test [P]
128+ - Keyboard shortcuts → integration test [P]
129+ - Zoom persistence → integration test [P]
130+
131+4. **Ordering**:
132+ - Setup → Contract Tests → CGO Wrappers → API Functions → Integration → Migration → Polish
133+ - Dependencies block parallel execution
134+
135+## Validation Checklist
136+
137+*GATE: Checked by main() before returning*
138+
139+- [x] All WebKit API contracts have corresponding tests
140+- [x] All data model entities have implementation tasks
141+- [x] All tests come before implementation (TDD enforced)
142+- [x] Parallel tasks truly independent ([P] = different files)
143+- [x] Each task specifies exact file path
144+- [x] No task modifies same file as another [P] task
145+- [x] Migration preserves user data (history, zoom settings)
146+- [x] Performance requirements addressed (<500ms, <100MB)
M tests/contract/browser_controls_test.go +2 -4
@@ -10,7 +10,6 @@ import (
1010 "github.com/bnema/dumber/services"
1111 "github.com/bnema/dumber/internal/config"
1212 mock_db "github.com/bnema/dumber/internal/db/mocks"
13- "github.com/wailsapp/wails/v3/pkg/application"
1413 "github.com/golang/mock/gomock"
1514 )
1615
@@ -42,10 +41,9 @@ type MockWindowTitleUpdater struct {
4241 callCount int
4342 }
4443
45-func (m *MockWindowTitleUpdater) SetTitle(title string) application.Window {
44+func (m *MockWindowTitleUpdater) SetTitle(title string) {
4645 m.lastTitle = title
4746 m.callCount++
48- return nil
4947 }
5048
5149 // Contract Test: BrowserService Zoom Controls
@@ -304,4 +302,4 @@ func TestBrowserService_ErrorHandling_Contract(t *testing.T) {
304302 t.Logf("GoBack returned error with nil config (may be expected): %v", err)
305303 }
306304 })
307-}⏎̸
305+}
A tests/contract/test_keyboard_shortcuts.go +14 -0
@@ -0,0 +1,14 @@
1+package contract
2+
3+import (
4+ "testing"
5+ "github.com/bnema/dumber/pkg/webkit"
6+)
7+
8+// Contract: RegisterKeyboardShortcut binds accelerator without error and triggers callback on press.
9+func Test_RegisterKeyboardShortcut_Contract(t *testing.T) {
10+ view, err := webkit.NewWebView(&webkit.Config{})
11+ if err != nil { t.Fatalf("NewWebView failed: %v", err) }
12+ err = view.RegisterKeyboardShortcut("Ctrl+L", func() {})
13+ if err != nil { t.Fatalf("RegisterKeyboardShortcut should succeed, got: %v", err) }
14+}
A tests/contract/test_lifecycle.go +15 -0
@@ -0,0 +1,15 @@
1+package contract
2+
3+import (
4+ "testing"
5+ "github.com/bnema/dumber/pkg/webkit"
6+)
7+
8+// Contract: Show/Hide/Destroy lifecycle operations succeed without error.
9+func Test_Lifecycle_Contract(t *testing.T) {
10+ view, err := webkit.NewWebView(&webkit.Config{})
11+ if err != nil { t.Fatalf("NewWebView failed: %v", err) }
12+ if err := view.Show(); err != nil { t.Fatalf("Show should succeed: %v", err) }
13+ if err := view.Hide(); err != nil { t.Fatalf("Hide should succeed: %v", err) }
14+ if err := view.Destroy(); err != nil { t.Fatalf("Destroy should succeed: %v", err) }
15+}
A tests/contract/test_navigation.go +17 -0
@@ -0,0 +1,17 @@
1+package contract
2+
3+import (
4+ "testing"
5+ "github.com/bnema/dumber/pkg/webkit"
6+)
7+
8+// Contract: LoadURL navigates to a valid URL without error.
9+func Test_LoadURL_Navigation_Contract(t *testing.T) {
10+ view, err := webkit.NewWebView(&webkit.Config{})
11+ if err != nil {
12+ t.Fatalf("NewWebView failed: %v", err)
13+ }
14+ if err := view.LoadURL("https://example.com"); err != nil {
15+ t.Fatalf("LoadURL should succeed, got: %v", err)
16+ }
17+}
A tests/contract/test_script_injection.go +15 -0
@@ -0,0 +1,15 @@
1+package contract
2+
3+import (
4+ "testing"
5+ "github.com/bnema/dumber/pkg/webkit"
6+)
7+
8+// Contract: InjectScript executes JS without error once implemented.
9+func Test_InjectScript_Contract(t *testing.T) {
10+ view, err := webkit.NewWebView(&webkit.Config{})
11+ if err != nil { t.Fatalf("NewWebView failed: %v", err) }
12+ if err := view.InjectScript("console.log('hello')"); err != nil {
13+ t.Fatalf("InjectScript should succeed, got: %v", err)
14+ }
15+}
A tests/contract/test_webview_creation.go +19 -0
@@ -0,0 +1,19 @@
1+package contract
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/pkg/webkit"
7+)
8+
9+// Contract: NewWebView creates a WebView without error and allows initial configuration.
10+func Test_NewWebView_Contract(t *testing.T) {
11+ cfg := &webkit.Config{InitialURL: "https://example.com", ZoomDefault: 1.0}
12+ view, err := webkit.NewWebView(cfg)
13+ if err != nil {
14+ t.Fatalf("expected NewWebView to succeed, got error: %v", err)
15+ }
16+ if view == nil {
17+ t.Fatalf("expected non-nil WebView")
18+ }
19+}
A tests/contract/test_zoom_control.go +18 -0
@@ -0,0 +1,18 @@
1+package contract
2+
3+import (
4+ "testing"
5+ "github.com/bnema/dumber/pkg/webkit"
6+)
7+
8+// Contract: SetZoom/GetZoom operate without error and persist value.
9+func Test_ZoomControl_Contract(t *testing.T) {
10+ view, err := webkit.NewWebView(&webkit.Config{ZoomDefault: 1.0})
11+ if err != nil { t.Fatalf("NewWebView failed: %v", err) }
12+ if err := view.SetZoom(1.25); err != nil {
13+ t.Fatalf("SetZoom should succeed, got: %v", err)
14+ }
15+ z, err := view.GetZoom()
16+ if err != nil { t.Fatalf("GetZoom should succeed, got: %v", err) }
17+ if z != 1.25 { t.Fatalf("expected zoom 1.25, got %v", z) }
18+}
M tests/integration/browser_controls_integration_test.go +26 -29
@@ -12,7 +12,6 @@ import (
1212 "github.com/bnema/dumber/internal/config"
1313 "github.com/bnema/dumber/internal/db"
1414 "github.com/bnema/dumber/services"
15- "github.com/wailsapp/wails/v3/pkg/application"
1615 _ "github.com/ncruces/go-sqlite3/driver" // SQLite driver
1716 _ "github.com/ncruces/go-sqlite3/embed" // Embed SQLite
1817 )
@@ -24,11 +23,10 @@ type MockWindowUpdater struct {
2423 }
2524
2625 // Implement the WindowTitleUpdater interface method
27-func (m *MockWindowUpdater) SetTitle(title string) application.Window {
26+func (m *MockWindowUpdater) SetTitle(title string) {
2827 m.mutex.Lock()
2928 defer m.mutex.Unlock()
3029 m.titles = append(m.titles, title)
31- return nil
3230 }
3331
3432 // setupTestDB creates a temporary SQLite database for integration testing
@@ -43,31 +41,30 @@ func setupTestDB(t *testing.T) (*sql.DB, *db.Queries, func()) {
4341 }
4442
4543 // Create tables using the migration schema
46- createTablesSQL := `
47- CREATE TABLE IF NOT EXISTS history (
48- id INTEGER PRIMARY KEY AUTOINCREMENT,
49- url TEXT NOT NULL,
50- title TEXT,
51- visit_count INTEGER DEFAULT 1,
52- last_visited DATETIME DEFAULT CURRENT_TIMESTAMP,
53- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
54- );
55-
56- CREATE TABLE IF NOT EXISTS shortcuts (
57- id INTEGER PRIMARY KEY AUTOINCREMENT,
58- shortcut TEXT NOT NULL UNIQUE,
59- url_template TEXT NOT NULL,
60- description TEXT,
61- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
62- );
63-
64- CREATE TABLE IF NOT EXISTS zoom_settings (
65- id INTEGER PRIMARY KEY AUTOINCREMENT,
66- url TEXT NOT NULL UNIQUE,
67- zoom_level REAL NOT NULL DEFAULT 1.0,
68- last_updated DATETIME DEFAULT CURRENT_TIMESTAMP
69- );
70- `
44+ createTablesSQL := `
45+ CREATE TABLE IF NOT EXISTS history (
46+ id INTEGER PRIMARY KEY AUTOINCREMENT,
47+ url TEXT NOT NULL,
48+ title TEXT,
49+ visit_count INTEGER DEFAULT 1,
50+ last_visited DATETIME DEFAULT CURRENT_TIMESTAMP,
51+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
52+ );
53+
54+ CREATE TABLE IF NOT EXISTS shortcuts (
55+ id INTEGER PRIMARY KEY AUTOINCREMENT,
56+ shortcut TEXT NOT NULL UNIQUE,
57+ url_template TEXT NOT NULL,
58+ description TEXT,
59+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
60+ );
61+
62+ CREATE TABLE IF NOT EXISTS zoom_levels (
63+ domain TEXT NOT NULL UNIQUE,
64+ zoom_factor REAL NOT NULL DEFAULT 1.0,
65+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
66+ );
67+ `
7168
7269 if _, err := database.Exec(createTablesSQL); err != nil {
7370 t.Fatalf("Failed to create test tables: %v", err)
@@ -370,4 +367,4 @@ func verifyZoomInDatabase(t *testing.T, queries *db.Queries, url string, expecte
370367 // if zoom != expectedZoom {
371368 // t.Errorf("Database zoom mismatch. Expected %f, got %f", expectedZoom, zoom)
372369 // }
373-}⏎̸
370+}
A tests/integration/test_external_shortcuts.go +15 -0
@@ -0,0 +1,15 @@
1+package integration
2+
3+import (
4+ "testing"
5+ "github.com/bnema/dumber/pkg/webkit"
6+)
7+
8+// Integration: keyboard shortcuts work on external sites once implemented.
9+func Test_WebKit_ExternalShortcuts_Integration(t *testing.T) {
10+ view, err := webkit.NewWebView(&webkit.Config{})
11+ if err != nil { t.Fatalf("NewWebView failed: %v", err) }
12+ if err := view.RegisterKeyboardShortcut("Ctrl+K", func(){}); err != nil {
13+ t.Fatalf("RegisterKeyboardShortcut should succeed, got: %v", err)
14+ }
15+}
A tests/integration/test_google_navigation.go +15 -0
@@ -0,0 +1,15 @@
1+package integration
2+
3+import (
4+ "testing"
5+ "github.com/bnema/dumber/pkg/webkit"
6+)
7+
8+// Integration: navigate to google.fr without error once implemented.
9+func Test_WebKit_GoogleNavigation_Integration(t *testing.T) {
10+ view, err := webkit.NewWebView(&webkit.Config{})
11+ if err != nil { t.Fatalf("NewWebView failed: %v", err) }
12+ if err := view.LoadURL("https://www.google.fr"); err != nil {
13+ t.Fatalf("LoadURL should succeed, got: %v", err)
14+ }
15+}
A tests/integration/test_webkit_basic.go +15 -0
@@ -0,0 +1,15 @@
1+package integration
2+
3+import (
4+ "testing"
5+ "github.com/bnema/dumber/pkg/webkit"
6+)
7+
8+// Integration: basic WebKit window and navigation works.
9+func Test_WebKit_BasicWindow_Integration(t *testing.T) {
10+ view, err := webkit.NewWebView(&webkit.Config{InitialURL: "about:blank"})
11+ if err != nil { t.Fatalf("NewWebView failed: %v", err) }
12+ if err := view.LoadURL("https://example.com"); err != nil {
13+ t.Fatalf("LoadURL should succeed, got: %v", err)
14+ }
15+}
A tests/integration/test_zoom_persistence.go +18 -0
@@ -0,0 +1,18 @@
1+package integration
2+
3+import (
4+ "testing"
5+ "github.com/bnema/dumber/pkg/webkit"
6+)
7+
8+// Integration: zoom persists across operations (with DB) once implemented.
9+func Test_WebKit_ZoomPersistence_Integration(t *testing.T) {
10+ view, err := webkit.NewWebView(&webkit.Config{ZoomDefault: 1.0})
11+ if err != nil { t.Fatalf("NewWebView failed: %v", err) }
12+ if err := view.SetZoom(1.1); err != nil {
13+ t.Fatalf("SetZoom should succeed, got: %v", err)
14+ }
15+ z, err := view.GetZoom()
16+ if err != nil { t.Fatalf("GetZoom should succeed, got: %v", err) }
17+ if z != 1.1 { t.Fatalf("expected zoom 1.1, got %v", z) }
18+}
A tests/unit/test_error_handling.go +19 -0
@@ -0,0 +1,19 @@
1+package unit
2+
3+import (
4+ "errors"
5+ "testing"
6+ "github.com/bnema/dumber/pkg/webkit"
7+)
8+
9+// Unit: WebKit error handling should propagate errors with context once implemented.
10+func Test_WebKit_ErrorHandling(t *testing.T) {
11+ _, err := webkit.NewWebView(nil)
12+ if err == nil {
13+ t.Fatalf("expected error prior to implementation (RED phase)")
14+ }
15+ if !errors.Is(err, webkit.ErrNotImplemented) {
16+ t.Fatalf("expected ErrNotImplemented, got %v", err)
17+ }
18+}
19+
A tests/unit/test_memory.go +14 -0
@@ -0,0 +1,14 @@
1+package unit
2+
3+import (
4+ "testing"
5+ "github.com/bnema/dumber/pkg/webkit"
6+)
7+
8+// Memory test placeholder: baseline <100MB once implemented.
9+func Test_WebKit_Memory_Baseline(t *testing.T) {
10+ if _, err := webkit.NewWebView(&webkit.Config{}); err == nil {
11+ t.Fatalf("expected construction to fail prior to implementation (RED phase)")
12+ }
13+}
14+
A tests/unit/test_memory_management.go +14 -0
@@ -0,0 +1,14 @@
1+package unit
2+
3+import (
4+ "testing"
5+ "github.com/bnema/dumber/pkg/webkit"
6+)
7+
8+// Unit: CGO memory management semantics (finalizers, ownership) to be verified post-implementation.
9+func Test_WebKit_CGOMemory_Management(t *testing.T) {
10+ if _, err := webkit.NewWebView(&webkit.Config{}); err == nil {
11+ t.Fatalf("expected construction to fail prior to implementation (RED phase)")
12+ }
13+}
14+
A tests/unit/test_performance.go +14 -0
@@ -0,0 +1,14 @@
1+package unit
2+
3+import (
4+ "testing"
5+ "github.com/bnema/dumber/pkg/webkit"
6+)
7+
8+// Performance test placeholder: startup time <500ms (to be measured once implemented).
9+func Test_WebKit_Performance_StartupTime(t *testing.T) {
10+ if _, err := webkit.NewWebView(&webkit.Config{}); err == nil {
11+ t.Fatalf("expected construction to fail prior to implementation (RED phase)")
12+ }
13+}
14+
D wails.json +0 -24
@@ -1,24 +0,0 @@
1-{
2- "name": "dumber",
3- "displayname": "Dumber Browser",
4- "description": "A smart URL launcher and browser with learning behavior",
5- "version": "0.1.0",
6- "author": "dumber",
7- "license": "MIT",
8- "build": {
9- "dir": ".",
10- "main": "main.go",
11- "binary": "dumber",
12- "output": "dist"
13- },
14- "frontend": {
15- "dir": "frontend",
16- "dist": "dist"
17- },
18- "flags": {
19- "linux": [
20- "-s",
21- "-w"
22- ]
23- }
24-}⏎̸

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close