feat(webkit): migrate to native WebKit2GTK backend, add dumb:// scheme + /api, UCM omnibox, persistent storage, theme + fonts; drop Wails runtime; debounce accelerators; homepage uses first‑party API; persist titles; per-domain zoom\n\n- Native WebKit2GTK CGO bindings (webview/window/zoom/script/keyboard)\n- Persistent WebsiteDataManager + cookies.sqlite; custom dumb:// scheme\n- dumb://api endpoints: /config, /history/recent, /history/search, /history/stats\n- Inject omnibox + theme at document-start via UserContentManager\n- Keyboard/mouse controls: zoom in/out/reset, F12, mouse 8/9 back/forward\n- Debounce accelerators to avoid duplicate triggers\n- Homepage switched to /api fetches; removed @wailsio/runtime + bindings\n- Configurable default fonts (Fira Sans/Code) + default size\n- Persist page titles; homepage shows title or host fallback\n- Per-domain zoom, reapplied on navigation; URI tracking\n- Added detailed scheme/API logs; fixed asset resolution for dumb://homepage\n- Updated specs to mark T020, T025, T041 complete
- commit
- f6244ebf165c3c0be713cdac898633632fb57994
- author
- bamen · · 2025-09-09 20:06 UTC
- parent
- 5f1b0a5
- signature
- unsigned
68 files · +3515 -1240
M .gitignore +2 -0
| @@ -57,6 +57,8 @@ config.local.* | |||
| 57 | 57 | # Build artifacts and caches | |
| 58 | 58 | .cache/ | |
| 59 | 59 | *.prof | |
| 60 | + | tmp/go-cache/ | |
| 61 | + | tmp/go-mod/ | |
| 60 | 62 | ||
| 61 | 63 | # Node/TypeScript related | |
| 62 | 64 | node_modules/ | |
M CLAUDE.md +23 -14
| @@ -1,25 +1,26 @@ | |||
| 1 | 1 | # dumb-browser Development Guidelines | |
| 2 | 2 | ||
| 3 | - | Auto-generated from all feature plans. Last updated: 2025-09-09 | |
| 3 | + | Auto-generated from all feature plans. Last updated: 2025-01-21 | |
| 4 | 4 | ||
| 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 | |
| 7 | 8 | ||
| 8 | 9 | ## Project Structure | |
| 9 | 10 | ``` | |
| 11 | + | pkg/ | |
| 12 | + | └── webkit/ # WebKit2GTK Go bindings (extractable) | |
| 13 | + | ||
| 10 | 14 | 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 | |
| 15 | 19 | ||
| 16 | 20 | 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 | |
| 23 | 24 | ``` | |
| 24 | 25 | ||
| 25 | 26 | ## Commands | |
| @@ -45,11 +46,19 @@ sqlite3 ~/.config/dumber/browser.db | |||
| 45 | 46 | Go 1.25.1: Follow standard Go conventions with gofmt | |
| 46 | 47 | - Use structured logging (slog) | |
| 47 | 48 | - 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 | |
| 49 | 51 | - Constitutional simplicity principles | |
| 50 | 52 | ||
| 51 | 53 | ## Recent Changes | |
| 54 | + | - 003-webkit2gtk-migration: Migrating from Wails to native WebKit2GTK with custom CGO bindings | |
| 52 | 55 | - 002-browser-controls-ui: Added keyboard/mouse controls, zoom persistence, clipboard integration | |
| 53 | 56 | ||
| 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 | + | ||
| 54 | 63 | <!-- MANUAL ADDITIONS START --> | |
| 55 | 64 | <!-- MANUAL ADDITIONS END -->⏎̸ | |
M Makefile +16 -8
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | # Makefile for dumber | |
| 2 | 2 | ||
| 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 | |
| 4 | 4 | ||
| 5 | 5 | # Variables | |
| 6 | 6 | BINARY_NAME=dumber | |
| @@ -21,14 +21,14 @@ help: ## Show this help message | |||
| 21 | 21 | @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) | |
| 22 | 22 | ||
| 23 | 23 | # 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)..." | |
| 26 | 26 | @mkdir -p $(DIST_DIR) | |
| 27 | - | CGO_ENABLED=1 wails3 build | |
| 27 | + | CGO_ENABLED=1 go build $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH) | |
| 28 | 28 | ||
| 29 | 29 | build-frontend: ## Build TypeScript frontend | |
| 30 | 30 | @echo "Building TypeScript frontend..." | |
| 31 | - | @cd frontend && npm ci --silent && npm run build | |
| 31 | + | @cd frontend && npm install --silent && npm run build | |
| 32 | 32 | @echo "Frontend build complete" | |
| 33 | 33 | ||
| 34 | 34 | build-static: ## Build static binary (CGO disabled, CLI-only functionality) | |
| @@ -36,6 +36,17 @@ build-static: ## Build static binary (CGO disabled, CLI-only functionality) | |||
| 36 | 36 | @mkdir -p $(DIST_DIR) | |
| 37 | 37 | CGO_ENABLED=0 go build $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME)-static $(MAIN_PATH) | |
| 38 | 38 | ||
| 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 | + | ||
| 39 | 50 | # Development targets | |
| 40 | 51 | dev: ## Run in development mode | |
| 41 | 52 | @echo "Running in development mode..." | |
| @@ -68,7 +79,6 @@ lint-fix: ## Run golangci-lint with --fix | |||
| 68 | 79 | install-tools: ## Install development tools | |
| 69 | 80 | @echo "Installing development tools..." | |
| 70 | 81 | go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest | |
| 71 | - | go install github.com/wailsapp/wails/v3/cmd/wails3@latest | |
| 72 | 82 | go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest | |
| 73 | 83 | @echo "Tools installed successfully" | |
| 74 | 84 | ||
| @@ -93,8 +103,6 @@ check: ## Check that all tools and dependencies are working | |||
| 93 | 103 | @go version | |
| 94 | 104 | @echo "\nSQLC version:" | |
| 95 | 105 | @sqlc version | |
| 96 | - | @echo "\nWails version:" | |
| 97 | - | @wails3 version | |
| 98 | 106 | @echo "\nGolangci-lint version:" | |
| 99 | 107 | @golangci-lint version | |
| 100 | 108 | @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 @@ | |||
| 19 | 19 | "typescript": "^5.3.0", | |
| 20 | 20 | "typescript-eslint": "^8.43.0" | |
| 21 | 21 | }, | |
| 22 | - | "dependencies": { | |
| 23 | - | "@wailsio/runtime": "^3.0.0-alpha.66" | |
| 24 | - | } | |
| 22 | + | "dependencies": {} | |
| 25 | 23 | } | |
M frontend/src/injected-controls.ts +2 -2
| @@ -9,7 +9,7 @@ | |||
| 9 | 9 | * - URL copying | |
| 10 | 10 | * - Mouse navigation buttons | |
| 11 | 11 | * | |
| 12 | - | * Zoom controls are handled natively by Wails in main.go | |
| 12 | + | * Zoom controls are handled natively by the GTK backend | |
| 13 | 13 | */ | |
| 14 | 14 | ||
| 15 | 15 | (function() { | |
| @@ -121,4 +121,4 @@ | |||
| 121 | 121 | } catch (error) { | |
| 122 | 122 | console.error('❌ Failed to initialize Dumber Browser controls:', error); | |
| 123 | 123 | } | |
| 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 | + | } | |
| 2 | 9 | ||
| 3 | 10 | export class AppService { | |
| 4 | 11 | private history: HistoryEntry[] = []; | |
| 5 | 12 | private shortcuts: Record<string, any> = {}; | |
| 6 | 13 | ||
| 7 | - | constructor() { | |
| 8 | - | // Using Wails v3 service bindings directly | |
| 9 | - | } | |
| 14 | + | constructor() {} | |
| 10 | 15 | ||
| 11 | 16 | async initialize(): Promise<void> { | |
| 12 | 17 | try { | |
| @@ -21,7 +26,7 @@ export class AppService { | |||
| 21 | 26 | ||
| 22 | 27 | async loadHistory(): Promise<void> { | |
| 23 | 28 | try { | |
| 24 | - | const recentHistory = await BrowserService.GetRecentHistory(50); | |
| 29 | + | const recentHistory: HistoryEntry[] = await fetch('/api/history/recent?limit=50').then(r => r.json()); | |
| 25 | 30 | this.history = recentHistory; | |
| 26 | 31 | } catch (error) { | |
| 27 | 32 | console.error('Failed to load history:', error); | |
| @@ -57,8 +62,8 @@ export class AppService { | |||
| 57 | 62 | ||
| 58 | 63 | async loadShortcuts(): Promise<void> { | |
| 59 | 64 | 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 || {}; | |
| 62 | 67 | } catch (error) { | |
| 63 | 68 | console.error('Failed to load shortcuts:', error); | |
| 64 | 69 | // Mock shortcuts for development/fallback | |
| @@ -87,4 +92,4 @@ export class AppService { | |||
| 87 | 92 | console.error('Failed to copy:', err); | |
| 88 | 93 | } | |
| 89 | 94 | } | |
| 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'; | |
| 3 | 1 | import { NotificationService } from './notifications.js'; | |
| 4 | 2 | import { DebugConsoleService } from './debug-console.js'; | |
| 5 | 3 | ||
| 6 | 4 | export class BrowserControlsService { | |
| 7 | 5 | private currentURL = ''; | |
| 8 | - | private currentZoom = 1.0; | |
| 9 | 6 | private notifications = new NotificationService(); | |
| 10 | 7 | private debugConsole = new DebugConsoleService(); | |
| 11 | 8 | ||
| 12 | 9 | constructor() { | |
| 13 | 10 | // Debug logging with enhanced details | |
| 14 | 11 | 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'); | |
| 16 | 13 | ||
| 17 | 14 | this.initializeEventListeners(); | |
| 18 | 15 | this.debugConsole.addLog('info', 'frontend', 'init', 'BrowserControlsService initialized ✓'); | |
| @@ -42,26 +39,9 @@ export class BrowserControlsService { | |||
| 42 | 39 | const { ctrlKey, metaKey, shiftKey, altKey, key } = event; | |
| 43 | 40 | const isCmd = ctrlKey || metaKey; | |
| 44 | 41 | ||
| 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 | |
| 65 | 45 | } | |
| 66 | 46 | ||
| 67 | 47 | // Copy URL | |
| @@ -76,14 +56,14 @@ export class BrowserControlsService { | |||
| 76 | 56 | if (altKey && key === 'ArrowLeft') { | |
| 77 | 57 | this.debugConsole.addLog('info', 'frontend', 'action', 'Go Back triggered'); | |
| 78 | 58 | event.preventDefault(); | |
| 79 | - | await this.goBack(); | |
| 59 | + | history.back(); | |
| 80 | 60 | return; | |
| 81 | 61 | } | |
| 82 | 62 | ||
| 83 | 63 | if (altKey && key === 'ArrowRight') { | |
| 84 | 64 | this.debugConsole.addLog('info', 'frontend', 'action', 'Go Forward triggered'); | |
| 85 | 65 | event.preventDefault(); | |
| 86 | - | await this.goForward(); | |
| 66 | + | history.forward(); | |
| 87 | 67 | return; | |
| 88 | 68 | } | |
| 89 | 69 | } | |
| @@ -114,82 +94,7 @@ export class BrowserControlsService { | |||
| 114 | 94 | this.loadSavedZoom(); | |
| 115 | 95 | } | |
| 116 | 96 | ||
| 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. | |
| 193 | 98 | ||
| 194 | 99 | async copyCurrentURL(): Promise<void> { | |
| 195 | 100 | try { | |
| @@ -253,60 +158,22 @@ export class BrowserControlsService { | |||
| 253 | 158 | ||
| 254 | 159 | document.title = title; | |
| 255 | 160 | ||
| 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 | |
| 262 | 162 | } catch (error) { | |
| 263 | 163 | console.error('Failed to update title:', error); | |
| 264 | 164 | } | |
| 265 | 165 | } | |
| 266 | 166 | ||
| 267 | 167 | 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 | |
| 276 | 169 | } | |
| 277 | 170 | ||
| 278 | 171 | async initialize(): Promise<void> { | |
| 279 | 172 | // 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); | |
| 308 | 175 | ||
| 309 | 176 | // Load saved zoom level (only if we're staying on homepage) | |
| 310 | - | await this.loadSavedZoom(); | |
| 177 | + | // Zoom handled natively | |
| 311 | 178 | } | |
| 312 | 179 | } | |
M frontend/src/services/debug-console.ts +1 -43
| @@ -83,51 +83,9 @@ export class DebugConsoleService { | |||
| 83 | 83 | event.preventDefault(); | |
| 84 | 84 | this.toggle(); | |
| 85 | 85 | } | |
| 86 | - | ||
| 87 | - | // F11 to inspect window.go in detail | |
| 88 | - | if (event.key === 'F11') { | |
| 89 | - | event.preventDefault(); | |
| 90 | - | this.inspectWailsServices(); | |
| 91 | - | } | |
| 92 | 86 | }); | |
| 93 | 87 | } | |
| 94 | 88 | ||
| 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 | - | ||
| 131 | 89 | private interceptConsole(): void { | |
| 132 | 90 | const originalConsole = { | |
| 133 | 91 | log: console.log, | |
| @@ -297,4 +255,4 @@ export class DebugConsoleService { | |||
| 297 | 255 | public isDebugVisible(): boolean { | |
| 298 | 256 | return this.isVisible; | |
| 299 | 257 | } | |
| 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 }; | |
| 4 | 3 | ||
| 5 | 4 | export class DOMRenderer { | |
| 6 | 5 | private historyListElement: HTMLElement | null = null; | |
| @@ -73,9 +72,11 @@ export class DOMRenderer { | |||
| 73 | 72 | private createHistoryItem(item: HistoryEntry): HTMLElement { | |
| 74 | 73 | const historyItem = document.createElement('div'); | |
| 75 | 74 | historyItem.className = 'history-item'; | |
| 75 | + | const parsed = this.safeParseURL(item.url); | |
| 76 | + | const title = item.title && item.title.trim() !== '' ? item.title : (parsed.host || 'Untitled'); | |
| 76 | 77 | historyItem.innerHTML = ` | |
| 77 | 78 | <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> | |
| 79 | 80 | `; | |
| 80 | 81 | ||
| 81 | 82 | historyItem.addEventListener('click', () => { | |
| @@ -104,14 +105,6 @@ export class DOMRenderer { | |||
| 104 | 105 | ||
| 105 | 106 | private async navigateToUrl(url: string): Promise<void> { | |
| 106 | 107 | 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 | - | } | |
| 115 | 108 | window.location.href = url; | |
| 116 | 109 | } | |
| 117 | 110 | ||
| @@ -148,4 +141,13 @@ export class DOMRenderer { | |||
| 148 | 141 | div.textContent = text; | |
| 149 | 142 | return div.innerHTML; | |
| 150 | 143 | } | |
| 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 | + | } | |
| 151 | 153 | } | |
M frontend/tsup.config.ts +2 -2
| @@ -15,7 +15,7 @@ export default defineConfig([ | |||
| 15 | 15 | bundle: true, | |
| 16 | 16 | splitting: false, | |
| 17 | 17 | external: [], | |
| 18 | - | noExternal: ['@wailsio/runtime'], | |
| 18 | + | // noExternal: [], | |
| 19 | 19 | }, | |
| 20 | 20 | // Injectable controls script (minified for injection) | |
| 21 | 21 | { | |
| @@ -51,4 +51,4 @@ export default defineConfig([ | |||
| 51 | 51 | console.log('✓ Frontend TypeScript compiled successfully') | |
| 52 | 52 | } | |
| 53 | 53 | } | |
| 54 | - | ])⏎̸ | |
| 54 | + | ]) | |
M go.mod +3 -39
| @@ -8,60 +8,24 @@ require ( | |||
| 8 | 8 | github.com/ncruces/go-sqlite3 v0.29.0 | |
| 9 | 9 | github.com/spf13/cobra v1.10.1 | |
| 10 | 10 | github.com/spf13/viper v1.21.0 | |
| 11 | - | github.com/wailsapp/wails/v3 v3.0.0-alpha.28 | |
| 12 | 11 | ) | |
| 13 | 12 | ||
| 14 | 13 | 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 | |
| 28 | 14 | 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 | |
| 32 | 16 | 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 | |
| 41 | 17 | github.com/ncruces/julianday v1.0.0 // indirect | |
| 42 | 18 | 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 | |
| 47 | 20 | 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 | |
| 51 | 21 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect | |
| 52 | 22 | github.com/spf13/afero v1.15.0 // indirect | |
| 53 | 23 | github.com/spf13/cast v1.10.0 // indirect | |
| 54 | 24 | github.com/spf13/pflag v1.0.10 // indirect | |
| 55 | 25 | github.com/subosito/gotenv v1.6.0 // indirect | |
| 56 | 26 | 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 | |
| 60 | 27 | 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 | |
| 63 | 28 | golang.org/x/sys v0.36.0 // indirect | |
| 64 | 29 | 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 | |
| 67 | 31 | ) | |
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= | |
| 18 | 1 | 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= | |
| 22 | 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= | |
| 23 | 3 | 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= | |
| 30 | 4 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= | |
| 31 | 5 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= | |
| 32 | 6 | github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= | |
| 33 | 7 | 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= | |
| 46 | 8 | github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= | |
| 47 | 9 | 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= | |
| 52 | 10 | github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= | |
| 53 | 11 | github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= | |
| 54 | 12 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= | |
| 55 | 13 | 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= | |
| 58 | 14 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= | |
| 59 | 15 | 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= | |
| 67 | 17 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= | |
| 68 | 18 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= | |
| 69 | 19 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= | |
| 70 | 20 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= | |
| 71 | 21 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= | |
| 72 | 22 | 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= | |
| 86 | 23 | github.com/ncruces/go-sqlite3 v0.29.0 h1:1tsLiagCoqZEfcHDeKsNSv5jvrY/Iu393pAnw2wLNJU= | |
| 87 | 24 | github.com/ncruces/go-sqlite3 v0.29.0/go.mod h1:r1hSvYKPNJ+OlUA1O3r8o9LAawzPAlqeZiIdxTBBBJ0= | |
| 88 | 25 | github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M= | |
| 89 | 26 | 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= | |
| 92 | 27 | github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= | |
| 93 | 28 | 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= | |
| 100 | 29 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | |
| 101 | 30 | 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= | |
| 105 | 31 | github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= | |
| 106 | 32 | github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= | |
| 107 | 33 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= | |
| 108 | 34 | github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= | |
| 109 | 35 | 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= | |
| 117 | 36 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= | |
| 118 | 37 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= | |
| 119 | 38 | github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= | |
| @@ -127,64 +46,33 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= | |||
| 127 | 46 | github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= | |
| 128 | 47 | github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= | |
| 129 | 48 | 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= | |
| 133 | 49 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= | |
| 134 | 50 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= | |
| 135 | 51 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= | |
| 136 | 52 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= | |
| 137 | 53 | github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= | |
| 138 | 54 | 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= | |
| 147 | 55 | github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= | |
| 148 | 56 | go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= | |
| 149 | 57 | go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= | |
| 150 | 58 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= | |
| 151 | 59 | 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= | |
| 157 | 60 | golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= | |
| 158 | 61 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= | |
| 159 | 62 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= | |
| 160 | 63 | 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= | |
| 165 | 64 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | |
| 166 | 65 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | |
| 167 | 66 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= | |
| 168 | 67 | 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= | |
| 171 | 68 | 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= | |
| 173 | 69 | 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= | |
| 175 | 70 | 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= | |
| 180 | 71 | golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= | |
| 181 | 72 | golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= | |
| 182 | 73 | 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= | |
| 185 | 74 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= | |
| 186 | 75 | 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= | |
| 188 | 76 | golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= | |
| 189 | 77 | golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= | |
| 190 | 78 | 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 | |||
| 194 | 82 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | |
| 195 | 83 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | |
| 196 | 84 | 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= | |
| 198 | 85 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= | |
| 199 | 86 | 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= | |
| 206 | 87 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | |
| 207 | 88 | 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 { | |||
| 157 | 157 | return openURLWithConfig(url, nil) | |
| 158 | 158 | } | |
| 159 | 159 | ||
| 160 | - | // openURLWithConfig opens a URL using our built-in Wails browser | |
| 160 | + | // openURLWithConfig opens a URL using our built-in browser | |
| 161 | 161 | func openURLWithConfig(url string, cfg *config.Config) error { | |
| 162 | 162 | // Get the path to our own executable | |
| 163 | 163 | executable, err := os.Executable() | |
M internal/config/config.go +32 -15
| @@ -16,11 +16,12 @@ import ( | |||
| 16 | 16 | ||
| 17 | 17 | // Config represents the complete configuration for dumber. | |
| 18 | 18 | 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"` | |
| 24 | 25 | } | |
| 25 | 26 | ||
| 26 | 27 | // DatabaseConfig holds database-related configuration. | |
| @@ -67,6 +68,16 @@ type LoggingConfig struct { | |||
| 67 | 68 | Compress bool `mapstructure:"compress" yaml:"compress"` | |
| 68 | 69 | } | |
| 69 | 70 | ||
| 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 | + | ||
| 70 | 81 | // Manager handles configuration loading, watching, and reloading. | |
| 71 | 82 | type Manager struct { | |
| 72 | 83 | config *Config | |
| @@ -258,14 +269,14 @@ func (m *Manager) reload() error { | |||
| 258 | 269 | ||
| 259 | 270 | // setDefaults sets default configuration values in Viper. | |
| 260 | 271 | func (m *Manager) setDefaults() { | |
| 261 | - | defaults := DefaultConfig() | |
| 272 | + | defaults := DefaultConfig() | |
| 262 | 273 | ||
| 263 | 274 | // Database defaults | |
| 264 | 275 | m.viper.SetDefault("database.max_connections", defaults.Database.MaxConnections) | |
| 265 | 276 | m.viper.SetDefault("database.max_idle_time", defaults.Database.MaxIdleTime) | |
| 266 | 277 | m.viper.SetDefault("database.query_timeout", defaults.Database.QueryTimeout) | |
| 267 | 278 | ||
| 268 | - | // Note: Browser config removed - we build our own browser with Wails | |
| 279 | + | // Note: Browser config removed - we build our own browser | |
| 269 | 280 | ||
| 270 | 281 | // History defaults | |
| 271 | 282 | m.viper.SetDefault("history.max_entries", defaults.History.MaxEntries) | |
| @@ -285,14 +296,20 @@ func (m *Manager) setDefaults() { | |||
| 285 | 296 | m.viper.SetDefault("dmenu.date_format", defaults.Dmenu.DateFormat) | |
| 286 | 297 | m.viper.SetDefault("dmenu.sort_by_visit_count", defaults.Dmenu.SortByVisitCount) | |
| 287 | 298 | ||
| 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) | |
| 296 | 313 | } | |
| 297 | 314 | ||
| 298 | 315 | // createDefaultConfig creates a default configuration file. | |
M internal/config/defaults.go +18 -12
| @@ -7,7 +7,7 @@ import ( | |||
| 7 | 7 | ||
| 8 | 8 | // DefaultConfig returns the default configuration values for dumber. | |
| 9 | 9 | func DefaultConfig() *Config { | |
| 10 | - | return &Config{ | |
| 10 | + | return &Config{ | |
| 11 | 11 | Database: DatabaseConfig{ | |
| 12 | 12 | MaxConnections: 1, | |
| 13 | 13 | MaxIdleTime: time.Minute * 5, | |
| @@ -46,16 +46,22 @@ func DefaultConfig() *Config { | |||
| 46 | 46 | DateFormat: "2006-01-02 15:04", | |
| 47 | 47 | SortByVisitCount: true, | |
| 48 | 48 | }, | |
| 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 | + | } | |
| 59 | 65 | } | |
| 60 | 66 | ||
| 61 | 67 | // GetDefaultSearchShortcuts returns the default search shortcuts. | |
| @@ -104,4 +110,4 @@ func GetDefaultSearchShortcuts() map[string]SearchShortcut { | |||
| 104 | 110 | } | |
| 105 | 111 | } | |
| 106 | 112 | ||
| 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 @@ | |||
| 1 | 1 | package main | |
| 2 | 2 | ||
| 3 | 3 | 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" | |
| 18 | 23 | ) | |
| 19 | 24 | ||
| 20 | - | // Embed the frontend assets | |
| 21 | - | // | |
| 22 | - | //go:embed frontend/dist | |
| 23 | - | var assets embed.FS | |
| 24 | - | ||
| 25 | 25 | // Build information set via ldflags | |
| 26 | 26 | var ( | |
| 27 | 27 | version = "dev" | |
| @@ -36,8 +36,8 @@ func main() { | |||
| 36 | 36 | return | |
| 37 | 37 | } | |
| 38 | 38 | ||
| 39 | - | // Otherwise run the GUI browser | |
| 40 | - | runBrowser() | |
| 39 | + | // Otherwise run the GUI browser | |
| 40 | + | runBrowser() | |
| 41 | 41 | } | |
| 42 | 42 | ||
| 43 | 43 | // shouldRunCLI determines if we should run in CLI mode based on arguments | |
| @@ -106,148 +106,376 @@ func runCLI() { | |||
| 106 | 106 | if err := rootCmd.Execute(); err != nil { | |
| 107 | 107 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) | |
| 108 | 108 | os.Exit(1) | |
| 109 | - | } | |
| 109 | + | } | |
| 110 | 110 | } | |
| 111 | 111 | ||
| 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 | |
| 247 | 114 | ||
| 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 | + | } | |
| 253 | 481 | } | |
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. | |
| 1 | 2 | package services | |
| 2 | 3 | ||
| 3 | 4 | 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" | |
| 14 | 17 | ) | |
| 15 | 18 | ||
| 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 | |
| 20 | 27 | } | |
| 21 | 28 | ||
| 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) | |
| 25 | 33 | } | |
| 26 | 34 | ||
| 27 | - | // BrowserService handles browser-related operations for Wails integration. | |
| 35 | + | // BrowserService handles browser-related operations for the built-in browser. | |
| 28 | 36 | 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 | |
| 35 | 43 | } | |
| 36 | 44 | ||
| 37 | - | // ServiceName returns the service name for Wails frontend binding | |
| 45 | + | // ServiceName returns the service name for frontend binding | |
| 38 | 46 | func (s *BrowserService) ServiceName() string { | |
| 39 | 47 | return "BrowserService" | |
| 40 | 48 | } | |
| @@ -61,14 +69,14 @@ type HistoryEntry struct { | |||
| 61 | 69 | ||
| 62 | 70 | // NewBrowserService creates a new BrowserService instance. | |
| 63 | 71 | 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 | + | } | |
| 72 | 80 | } | |
| 73 | 81 | ||
| 74 | 82 | // SetWindowTitleUpdater sets the window title updater interface | |
| @@ -76,9 +84,9 @@ func (s *BrowserService) SetWindowTitleUpdater(updater WindowTitleUpdater) { | |||
| 76 | 84 | s.windowTitleUpdater = updater | |
| 77 | 85 | } | |
| 78 | 86 | ||
| 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 | |
| 82 | 90 | } | |
| 83 | 91 | ||
| 84 | 92 | // LoadInjectableScript loads the minified injectable controls script from assets | |
| @@ -93,40 +101,20 @@ func (s *BrowserService) LoadInjectableScript(assets embed.FS) error { | |||
| 93 | 101 | ||
| 94 | 102 | // InjectControlScript injects the global controls script into the current page | |
| 95 | 103 | 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) | |
| 130 | 118 | } | |
| 131 | 119 | ||
| 132 | 120 | // 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 | |||
| 194 | 182 | ||
| 195 | 183 | result := make([]HistoryEntry, len(entries)) | |
| 196 | 184 | 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 | + | } | |
| 205 | 195 | } | |
| 206 | 196 | ||
| 207 | 197 | return result, nil | |
| @@ -227,14 +217,15 @@ func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit | |||
| 227 | 217 | ||
| 228 | 218 | result := make([]HistoryEntry, len(entries)) | |
| 229 | 219 | 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 | + | } | |
| 238 | 229 | } | |
| 239 | 230 | ||
| 240 | 231 | return result, nil | |
| @@ -242,22 +233,25 @@ func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit | |||
| 242 | 233 | ||
| 243 | 234 | // DeleteHistoryEntry removes a specific history entry. | |
| 244 | 235 | 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") | |
| 248 | 241 | } | |
| 249 | 242 | ||
| 250 | 243 | // ClearHistory removes all history entries. | |
| 251 | 244 | 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") | |
| 255 | 249 | } | |
| 256 | 250 | ||
| 257 | 251 | // GetHistoryStats returns statistics about browser history. | |
| 258 | 252 | 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) | |
| 261 | 255 | if err != nil { | |
| 262 | 256 | return nil, err | |
| 263 | 257 | } | |
| @@ -279,14 +273,16 @@ func (s *BrowserService) GetHistoryStats(ctx context.Context) (map[string]interf | |||
| 279 | 273 | ||
| 280 | 274 | // GetConfig returns the current browser configuration. | |
| 281 | 275 | func (s *BrowserService) GetConfig(ctx context.Context) (*config.Config, error) { | |
| 282 | - | return s.config, nil | |
| 276 | + | _ = ctx | |
| 277 | + | return s.config, nil | |
| 283 | 278 | } | |
| 284 | 279 | ||
| 285 | 280 | // UpdateConfig updates the browser configuration. | |
| 286 | 281 | 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 | |
| 290 | 286 | } | |
| 291 | 287 | ||
| 292 | 288 | // GetSearchShortcuts returns available search shortcuts. | |
| @@ -347,47 +343,51 @@ func (s *BrowserService) ResetZoom(ctx context.Context, url string) (float64, er | |||
| 347 | 343 | ||
| 348 | 344 | // GetZoomLevel retrieves the saved zoom level for a URL. | |
| 349 | 345 | 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 | |
| 367 | 366 | } | |
| 368 | 367 | ||
| 369 | 368 | // SetZoomLevel sets the zoom level for a URL. | |
| 370 | 369 | 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 | + | } | |
| 374 | 373 | ||
| 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 | + | } | |
| 381 | 380 | ||
| 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) | |
| 384 | 383 | } | |
| 385 | 384 | ||
| 386 | 385 | // GetCurrentURL returns the current URL (this would be implemented by the frontend) | |
| 387 | 386 | 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 | |
| 391 | 391 | } | |
| 392 | 392 | ||
| 393 | 393 | // GetInitialURL returns the initially navigated URL for frontend synchronization | |
| @@ -408,21 +408,29 @@ func (s *BrowserService) CopyCurrentURL(ctx context.Context, url string) error { | |||
| 408 | 408 | ||
| 409 | 409 | // GoBack provides navigation back functionality | |
| 410 | 410 | 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 | |
| 415 | 416 | } | |
| 416 | 417 | ||
| 417 | 418 | // GoForward provides navigation forward functionality | |
| 418 | 419 | 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 | |
| 423 | 425 | } | |
| 424 | 426 | ||
| 425 | 427 | // 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 | + | ||
| 426 | 434 | func (s *BrowserService) getNextZoomLevel(currentZoom float64, zoomIn bool) float64 { | |
| 427 | 435 | // Find the closest current zoom level | |
| 428 | 436 | closestIndex := 0 | |
| @@ -439,51 +447,39 @@ func (s *BrowserService) getNextZoomLevel(currentZoom float64, zoomIn bool) floa | |||
| 439 | 447 | } | |
| 440 | 448 | } | |
| 441 | 449 | ||
| 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 | |
| 455 | 462 | } | |
| 456 | 463 | ||
| 457 | 464 | // setZoom sets the zoom level to a specific value | |
| 458 | 465 | 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 | |
| 464 | 481 | } | |
| 465 | 482 | ||
| 466 | 483 | // extractDomain extracts the domain from a URL for zoom level storage | |
| 467 | 484 | // 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 @@ | |||
| 1 | 1 | package services | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "context" | |
| 5 | - | "fmt" | |
| 6 | - | "os" | |
| 7 | - | "path/filepath" | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/dumber/internal/config" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | - | // ConfigService handles configuration operations for Wails integration. | |
| 12 | + | // ConfigService handles configuration operations for the application. | |
| 13 | 13 | type ConfigService struct { | |
| 14 | - | config *config.Config | |
| 15 | - | configPath string | |
| 14 | + | config *config.Config | |
| 15 | + | configPath string | |
| 16 | 16 | } | |
| 17 | 17 | ||
| 18 | + | const defaultDirPerm = 0o750 | |
| 19 | + | ||
| 18 | 20 | // ConfigInfo represents configuration information for the frontend. | |
| 19 | 21 | type ConfigInfo struct { | |
| 20 | 22 | ConfigPath string `json:"config_path"` | |
| @@ -33,24 +35,27 @@ func NewConfigService(cfg *config.Config, configPath string) *ConfigService { | |||
| 33 | 35 | ||
| 34 | 36 | // GetConfigInfo returns comprehensive configuration information. | |
| 35 | 37 | 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 | |
| 42 | 45 | } | |
| 43 | 46 | ||
| 44 | 47 | // GetSearchShortcuts returns all configured search shortcuts. | |
| 45 | 48 | 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 | |
| 47 | 51 | } | |
| 48 | 52 | ||
| 49 | 53 | // AddSearchShortcut adds a new search shortcut. | |
| 50 | 54 | 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 | + | } | |
| 54 | 59 | ||
| 55 | 60 | if shortcut.URL == "" { | |
| 56 | 61 | return fmt.Errorf("shortcut URL cannot be empty") | |
| @@ -66,9 +71,10 @@ func (s *ConfigService) AddSearchShortcut(ctx context.Context, key string, short | |||
| 66 | 71 | ||
| 67 | 72 | // UpdateSearchShortcut updates an existing search shortcut. | |
| 68 | 73 | 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 | + | } | |
| 72 | 78 | ||
| 73 | 79 | if _, exists := s.config.SearchShortcuts[key]; !exists { | |
| 74 | 80 | return fmt.Errorf("shortcut '%s' does not exist", key) | |
| @@ -84,9 +90,10 @@ func (s *ConfigService) UpdateSearchShortcut(ctx context.Context, key string, sh | |||
| 84 | 90 | ||
| 85 | 91 | // DeleteSearchShortcut removes a search shortcut. | |
| 86 | 92 | 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 | + | } | |
| 90 | 97 | ||
| 91 | 98 | if _, exists := s.config.SearchShortcuts[key]; !exists { | |
| 92 | 99 | return fmt.Errorf("shortcut '%s' does not exist", key) | |
| @@ -98,14 +105,16 @@ func (s *ConfigService) DeleteSearchShortcut(ctx context.Context, key string) er | |||
| 98 | 105 | ||
| 99 | 106 | // GetDmenuSettings returns current dmenu settings. | |
| 100 | 107 | func (s *ConfigService) GetDmenuSettings(ctx context.Context) (*config.DmenuConfig, error) { | |
| 101 | - | return &s.config.Dmenu, nil | |
| 108 | + | _ = ctx | |
| 109 | + | return &s.config.Dmenu, nil | |
| 102 | 110 | } | |
| 103 | 111 | ||
| 104 | 112 | // UpdateDmenuSettings updates dmenu configuration. | |
| 105 | 113 | 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 | + | } | |
| 109 | 118 | ||
| 110 | 119 | s.config.Dmenu = *settings | |
| 111 | 120 | return s.saveConfig() | |
| @@ -113,20 +122,22 @@ func (s *ConfigService) UpdateDmenuSettings(ctx context.Context, settings *confi | |||
| 113 | 122 | ||
| 114 | 123 | // GetDatabasePath returns the current database path. | |
| 115 | 124 | 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 | |
| 117 | 127 | } | |
| 118 | 128 | ||
| 119 | 129 | // SetDatabasePath updates the database path. | |
| 120 | 130 | 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 | + | } | |
| 124 | 135 | ||
| 125 | 136 | // Validate that the directory exists or can be created | |
| 126 | 137 | 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 | + | } | |
| 130 | 141 | ||
| 131 | 142 | s.config.Database.Path = path | |
| 132 | 143 | return s.saveConfig() | |
| @@ -134,23 +145,27 @@ func (s *ConfigService) SetDatabasePath(ctx context.Context, path string) error | |||
| 134 | 145 | ||
| 135 | 146 | // GetDefaultBrowser returns the default browser setting. | |
| 136 | 147 | 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 | |
| 139 | 151 | } | |
| 140 | 152 | ||
| 141 | 153 | // SetDefaultBrowser updates the default browser setting. | |
| 142 | 154 | 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") | |
| 145 | 159 | } | |
| 146 | 160 | ||
| 147 | 161 | // ReloadConfig reloads configuration from file. | |
| 148 | 162 | 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 | + | } | |
| 154 | 169 | ||
| 155 | 170 | if err := manager.Load(); err != nil { | |
| 156 | 171 | return fmt.Errorf("failed to load config: %w", err) | |
| @@ -162,7 +177,8 @@ func (s *ConfigService) ReloadConfig(ctx context.Context) error { | |||
| 162 | 177 | ||
| 163 | 178 | // ValidateConfig validates the current configuration. | |
| 164 | 179 | func (s *ConfigService) ValidateConfig(ctx context.Context) ([]string, error) { | |
| 165 | - | var errors []string | |
| 180 | + | _ = ctx | |
| 181 | + | var errors []string | |
| 166 | 182 | ||
| 167 | 183 | // Validate database path | |
| 168 | 184 | if s.config.Database.Path == "" { | |
| @@ -200,14 +216,16 @@ func (s *ConfigService) ValidateConfig(ctx context.Context) ([]string, error) { | |||
| 200 | 216 | ||
| 201 | 217 | // GetConfigPath returns the path to the configuration file. | |
| 202 | 218 | func (s *ConfigService) GetConfigPath(ctx context.Context) (string, error) { | |
| 203 | - | return s.configPath, nil | |
| 219 | + | _ = ctx | |
| 220 | + | return s.configPath, nil | |
| 204 | 221 | } | |
| 205 | 222 | ||
| 206 | 223 | // ExportConfig returns the current configuration as JSON. | |
| 207 | 224 | 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 | |
| 211 | 229 | } | |
| 212 | 230 | ||
| 213 | 231 | // ImportConfig imports configuration from a Config object. | |
| @@ -237,4 +255,4 @@ func (s *ConfigService) saveConfig() error { | |||
| 237 | 255 | // For now, return success - implementing config saving would require | |
| 238 | 256 | // extending the config package with save functionality | |
| 239 | 257 | return nil | |
| 240 | - | }⏎̸ | |
| 258 | + | } | |
M services/parser_service.go +41 -28
| @@ -10,7 +10,7 @@ import ( | |||
| 10 | 10 | "github.com/bnema/dumber/internal/parser" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | - | // ParserService wraps the parser functionality for Wails integration. | |
| 13 | + | // ParserService wraps the parser functionality for the application. | |
| 14 | 14 | type ParserService struct { | |
| 15 | 15 | parser *parser.Parser | |
| 16 | 16 | dbQueries *db.Queries | |
| @@ -32,69 +32,81 @@ func NewParserService(cfg *config.Config, queries *db.Queries) *ParserService { | |||
| 32 | 32 | ||
| 33 | 33 | // ParseInput parses user input and returns navigation result. | |
| 34 | 34 | 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 | + | } | |
| 38 | 39 | ||
| 39 | 40 | return s.parser.ParseInput(input) | |
| 40 | 41 | } | |
| 41 | 42 | ||
| 42 | 43 | // GetCompletions provides URL/search completions based on input. | |
| 43 | 44 | 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 | + | } | |
| 47 | 49 | ||
| 48 | 50 | return s.parser.SuggestCompletions(input, limit) | |
| 49 | 51 | } | |
| 50 | 52 | ||
| 51 | 53 | // 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 | + | } | |
| 56 | 61 | ||
| 57 | 62 | return s.parser.FuzzySearchHistory(query, threshold) | |
| 58 | 63 | } | |
| 59 | 64 | ||
| 60 | 65 | // GetSupportedShortcuts returns configured search shortcuts. | |
| 61 | 66 | 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 | |
| 63 | 69 | } | |
| 64 | 70 | ||
| 65 | 71 | // ProcessShortcut processes a shortcut and returns the resulting URL. | |
| 66 | 72 | 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) | |
| 69 | 76 | } | |
| 70 | 77 | ||
| 71 | 78 | // CalculateSimilarity calculates similarity between two strings. | |
| 72 | 79 | 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 | |
| 74 | 82 | } | |
| 75 | 83 | ||
| 76 | 84 | // ValidateURL checks if the input represents a valid URL. | |
| 77 | 85 | 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 | |
| 79 | 88 | } | |
| 80 | 89 | ||
| 81 | 90 | // PreviewParse provides a preview of what ParseInput would return. | |
| 82 | 91 | 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) | |
| 88 | 98 | } | |
| 89 | 99 | ||
| 90 | 100 | // GetFuzzyConfig returns current fuzzy matching configuration. | |
| 91 | 101 | func (s *ParserService) GetFuzzyConfig(ctx context.Context) (*parser.FuzzyConfig, error) { | |
| 92 | - | return s.parser.GetFuzzyConfig(), nil | |
| 102 | + | _ = ctx | |
| 103 | + | return s.parser.GetFuzzyConfig(), nil | |
| 93 | 104 | } | |
| 94 | 105 | ||
| 95 | 106 | // UpdateFuzzyConfig updates the fuzzy matching configuration. | |
| 96 | 107 | 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) | |
| 98 | 110 | } | |
| 99 | 111 | ||
| 100 | 112 | // DatabaseHistoryProvider implements HistoryProvider using the database. | |
| @@ -123,10 +135,11 @@ func (h *DatabaseHistoryProvider) GetRecentHistory(limit int) ([]*db.History, er | |||
| 123 | 135 | ||
| 124 | 136 | // GetAllHistory returns all history entries for fuzzy search. | |
| 125 | 137 | 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) | |
| 130 | 143 | if err != nil { | |
| 131 | 144 | return nil, err | |
| 132 | 145 | } | |
| @@ -182,4 +195,4 @@ func (h *DatabaseHistoryProvider) GetHistoryByURL(url string) (*db.History, erro | |||
| 182 | 195 | } | |
| 183 | 196 | ||
| 184 | 197 | 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 ( | |||
| 10 | 10 | "github.com/bnema/dumber/services" | |
| 11 | 11 | "github.com/bnema/dumber/internal/config" | |
| 12 | 12 | mock_db "github.com/bnema/dumber/internal/db/mocks" | |
| 13 | - | "github.com/wailsapp/wails/v3/pkg/application" | |
| 14 | 13 | "github.com/golang/mock/gomock" | |
| 15 | 14 | ) | |
| 16 | 15 | ||
| @@ -42,10 +41,9 @@ type MockWindowTitleUpdater struct { | |||
| 42 | 41 | callCount int | |
| 43 | 42 | } | |
| 44 | 43 | ||
| 45 | - | func (m *MockWindowTitleUpdater) SetTitle(title string) application.Window { | |
| 44 | + | func (m *MockWindowTitleUpdater) SetTitle(title string) { | |
| 46 | 45 | m.lastTitle = title | |
| 47 | 46 | m.callCount++ | |
| 48 | - | return nil | |
| 49 | 47 | } | |
| 50 | 48 | ||
| 51 | 49 | // Contract Test: BrowserService Zoom Controls | |
| @@ -304,4 +302,4 @@ func TestBrowserService_ErrorHandling_Contract(t *testing.T) { | |||
| 304 | 302 | t.Logf("GoBack returned error with nil config (may be expected): %v", err) | |
| 305 | 303 | } | |
| 306 | 304 | }) | |
| 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 ( | |||
| 12 | 12 | "github.com/bnema/dumber/internal/config" | |
| 13 | 13 | "github.com/bnema/dumber/internal/db" | |
| 14 | 14 | "github.com/bnema/dumber/services" | |
| 15 | - | "github.com/wailsapp/wails/v3/pkg/application" | |
| 16 | 15 | _ "github.com/ncruces/go-sqlite3/driver" // SQLite driver | |
| 17 | 16 | _ "github.com/ncruces/go-sqlite3/embed" // Embed SQLite | |
| 18 | 17 | ) | |
| @@ -24,11 +23,10 @@ type MockWindowUpdater struct { | |||
| 24 | 23 | } | |
| 25 | 24 | ||
| 26 | 25 | // Implement the WindowTitleUpdater interface method | |
| 27 | - | func (m *MockWindowUpdater) SetTitle(title string) application.Window { | |
| 26 | + | func (m *MockWindowUpdater) SetTitle(title string) { | |
| 28 | 27 | m.mutex.Lock() | |
| 29 | 28 | defer m.mutex.Unlock() | |
| 30 | 29 | m.titles = append(m.titles, title) | |
| 31 | - | return nil | |
| 32 | 30 | } | |
| 33 | 31 | ||
| 34 | 32 | // setupTestDB creates a temporary SQLite database for integration testing | |
| @@ -43,31 +41,30 @@ func setupTestDB(t *testing.T) (*sql.DB, *db.Queries, func()) { | |||
| 43 | 41 | } | |
| 44 | 42 | ||
| 45 | 43 | // 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 | + | ` | |
| 71 | 68 | ||
| 72 | 69 | if _, err := database.Exec(createTablesSQL); err != nil { | |
| 73 | 70 | t.Fatalf("Failed to create test tables: %v", err) | |
| @@ -370,4 +367,4 @@ func verifyZoomInDatabase(t *testing.T, queries *db.Queries, url string, expecte | |||
| 370 | 367 | // if zoom != expectedZoom { | |
| 371 | 368 | // t.Errorf("Database zoom mismatch. Expected %f, got %f", expectedZoom, zoom) | |
| 372 | 369 | // } | |
| 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 | - | }⏎̸ | |