merge: integrate WebKit2GTK migration and homepage API into main
68 files · +3515 -1240
M .gitignore +2 -0
| @@ -57,6 +57,8 @@ config.local.* | |||||
| 57 | # Build artifacts and caches | 57 | # Build artifacts and caches | ||
| 58 | .cache/ | 58 | .cache/ | ||
| 59 | *.prof | 59 | *.prof | ||
| 60 | + | tmp/go-cache/ | |||
| 61 | + | tmp/go-mod/ | |||
| 60 | 62 | ||||
| 61 | # Node/TypeScript related | 63 | # Node/TypeScript related | ||
| 62 | node_modules/ | 64 | node_modules/ | ||
M CLAUDE.md +23 -14
| @@ -1,25 +1,26 @@ | |||||
| 1 | # dumb-browser Development Guidelines | 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 | 5 | + | ## Active Technologies |
| 6 | - | - Go 1.25.1 + Wails v3-alpha.28, Cobra, Viper, SQLite3 (002-browser-controls-ui) | 6 | + | - Go 1.25.1 + WebKit2GTK (native), Cobra, Viper, SQLite3 (003-webkit2gtk-migration) |
| 7 | + | - CGO bindings for WebKit2GTK C API integration | |||
| 7 | 8 | ||||
| 8 | ## Project Structure | 9 | ## Project Structure | ||
| 9 | ``` | 10 | ``` | ||
| 11 | + | pkg/ | |||
| 12 | + | └── webkit/ # WebKit2GTK Go bindings (extractable) | |||
| 13 | + | ||||
| 10 | src/ | 14 | src/ | ||
| 11 | - | ├── models/ # Data models and entities | 15 | + | ├── models/ # Data models and entities |
| 12 | - | ├── services/ # Business logic services | 16 | + | ├── services/ # Business logic services |
| 13 | - | ├── cli/ # Command line interface | 17 | + | ├── cli/ # Command line interface |
| 14 | - | └── lib/ # Shared libraries | 18 | + | └── lib/ # Shared libraries |
| 15 | 19 | ||||
| 16 | tests/ | 20 | tests/ | ||
| 17 | - | ├── contract/ # Contract tests | 21 | + | ├── contract/ # Contract tests for WebKit bindings |
| 18 | - | ├── integration/ # Integration tests | 22 | + | ├── integration/ # Integration tests with WebKit2GTK |
| 19 | - | └── unit/ # Unit tests | 23 | + | └── unit/ # Unit tests |
| 20 | - | ||||
| 21 | - | frontend/ | |||
| 22 | - | └── dist/ # Embedded WebView assets | |||
| 23 | ``` | 24 | ``` | ||
| 24 | 25 | ||||
| 25 | ## Commands | 26 | ## Commands | ||
| @@ -45,11 +46,19 @@ sqlite3 ~/.config/dumber/browser.db | |||||
| 45 | Go 1.25.1: Follow standard Go conventions with gofmt | 46 | Go 1.25.1: Follow standard Go conventions with gofmt | ||
| 46 | - Use structured logging (slog) | 47 | - Use structured logging (slog) | ||
| 47 | - Proper error handling (no panics) | 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 | - Constitutional simplicity principles | 51 | - Constitutional simplicity principles | ||
| 50 | 52 | ||||
| 51 | ## Recent Changes | 53 | ## Recent Changes | ||
| 54 | + | - 003-webkit2gtk-migration: Migrating from Wails to native WebKit2GTK with custom CGO bindings | |||
| 52 | - 002-browser-controls-ui: Added keyboard/mouse controls, zoom persistence, clipboard integration | 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 | <!-- MANUAL ADDITIONS START --> | 63 | <!-- MANUAL ADDITIONS START --> | ||
| 55 | <!-- MANUAL ADDITIONS END -->⏎̸ | 64 | <!-- MANUAL ADDITIONS END -->⏎̸ | ||
M Makefile +16 -8
| @@ -1,6 +1,6 @@ | |||||
| 1 | # Makefile for dumber | 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 | # Variables | 5 | # Variables | ||
| 6 | BINARY_NAME=dumber | 6 | BINARY_NAME=dumber | ||
| @@ -21,14 +21,14 @@ help: ## Show this help message | |||||
| 21 | @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) | 21 | @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) | ||
| 22 | 22 | ||||
| 23 | # Build targets | 23 | # Build targets | ||
| 24 | - | build: build-frontend ## Build the Wails app (builds frontend, then wails3 build) | 24 | + | build: build-frontend ## Build the application (frontend assets, then Go binary) |
| 25 | - | @echo "Building $(BINARY_NAME) $(VERSION) with Wails..." | 25 | + | @echo "Building $(BINARY_NAME) $(VERSION)..." |
| 26 | @mkdir -p $(DIST_DIR) | 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 | build-frontend: ## Build TypeScript frontend | 29 | build-frontend: ## Build TypeScript frontend | ||
| 30 | @echo "Building TypeScript frontend..." | 30 | @echo "Building TypeScript frontend..." | ||
| 31 | - | @cd frontend && npm ci --silent && npm run build | 31 | + | @cd frontend && npm install --silent && npm run build |
| 32 | @echo "Frontend build complete" | 32 | @echo "Frontend build complete" | ||
| 33 | 33 | ||||
| 34 | build-static: ## Build static binary (CGO disabled, CLI-only functionality) | 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 | @mkdir -p $(DIST_DIR) | 36 | @mkdir -p $(DIST_DIR) | ||
| 37 | CGO_ENABLED=0 go build $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME)-static $(MAIN_PATH) | 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 | # Development targets | 50 | # Development targets | ||
| 40 | dev: ## Run in development mode | 51 | dev: ## Run in development mode | ||
| 41 | @echo "Running in development mode..." | 52 | @echo "Running in development mode..." | ||
| @@ -68,7 +79,6 @@ lint-fix: ## Run golangci-lint with --fix | |||||
| 68 | install-tools: ## Install development tools | 79 | install-tools: ## Install development tools | ||
| 69 | @echo "Installing development tools..." | 80 | @echo "Installing development tools..." | ||
| 70 | go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest | 81 | go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest | ||
| 71 | - | go install github.com/wailsapp/wails/v3/cmd/wails3@latest | |||
| 72 | go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest | 82 | go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest | ||
| 73 | @echo "Tools installed successfully" | 83 | @echo "Tools installed successfully" | ||
| 74 | 84 | ||||
| @@ -93,8 +103,6 @@ check: ## Check that all tools and dependencies are working | |||||
| 93 | @go version | 103 | @go version | ||
| 94 | @echo "\nSQLC version:" | 104 | @echo "\nSQLC version:" | ||
| 95 | @sqlc version | 105 | @sqlc version | ||
| 96 | - | @echo "\nWails version:" | |||
| 97 | - | @wails3 version | |||
| 98 | @echo "\nGolangci-lint version:" | 106 | @echo "\nGolangci-lint version:" | ||
| 99 | @golangci-lint version | 107 | @golangci-lint version | ||
| 100 | @echo "\nBuilding project..." | 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 | "typescript": "^5.3.0", | 19 | "typescript": "^5.3.0", | ||
| 20 | "typescript-eslint": "^8.43.0" | 20 | "typescript-eslint": "^8.43.0" | ||
| 21 | }, | 21 | }, | ||
| 22 | - | "dependencies": { | 22 | + | "dependencies": {} |
| 23 | - | "@wailsio/runtime": "^3.0.0-alpha.66" | |||
| 24 | - | } | |||
| 25 | } | 23 | } | ||
M frontend/src/injected-controls.ts +2 -2
| @@ -9,7 +9,7 @@ | |||||
| 9 | * - URL copying | 9 | * - URL copying | ||
| 10 | * - Mouse navigation buttons | 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 | (function() { | 15 | (function() { | ||
| @@ -121,4 +121,4 @@ | |||||
| 121 | } catch (error) { | 121 | } catch (error) { | ||
| 122 | console.error('❌ Failed to initialize Dumber Browser controls:', error); | 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 | export class AppService { | 10 | export class AppService { | ||
| 4 | private history: HistoryEntry[] = []; | 11 | private history: HistoryEntry[] = []; | ||
| 5 | private shortcuts: Record<string, any> = {}; | 12 | private shortcuts: Record<string, any> = {}; | ||
| 6 | 13 | ||||
| 7 | - | constructor() { | 14 | + | constructor() {} |
| 8 | - | // Using Wails v3 service bindings directly | |||
| 9 | - | } | |||
| 10 | 15 | ||||
| 11 | async initialize(): Promise<void> { | 16 | async initialize(): Promise<void> { | ||
| 12 | try { | 17 | try { | ||
| @@ -21,7 +26,7 @@ export class AppService { | |||||
| 21 | 26 | ||||
| 22 | async loadHistory(): Promise<void> { | 27 | async loadHistory(): Promise<void> { | ||
| 23 | try { | 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 | this.history = recentHistory; | 30 | this.history = recentHistory; | ||
| 26 | } catch (error) { | 31 | } catch (error) { | ||
| 27 | console.error('Failed to load history:', error); | 32 | console.error('Failed to load history:', error); | ||
| @@ -57,8 +62,8 @@ export class AppService { | |||||
| 57 | 62 | ||||
| 58 | async loadShortcuts(): Promise<void> { | 63 | async loadShortcuts(): Promise<void> { | ||
| 59 | try { | 64 | try { | ||
| 60 | - | const shortcutsData = await BrowserService.GetSearchShortcuts(); | 65 | + | const cfg = await fetch('/api/config').then(r => r.json()); |
| 61 | - | this.shortcuts = shortcutsData; | 66 | + | this.shortcuts = cfg?.search_shortcuts || {}; |
| 62 | } catch (error) { | 67 | } catch (error) { | ||
| 63 | console.error('Failed to load shortcuts:', error); | 68 | console.error('Failed to load shortcuts:', error); | ||
| 64 | // Mock shortcuts for development/fallback | 69 | // Mock shortcuts for development/fallback | ||
| @@ -87,4 +92,4 @@ export class AppService { | |||||
| 87 | console.error('Failed to copy:', err); | 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 | import { NotificationService } from './notifications.js'; | 1 | import { NotificationService } from './notifications.js'; | ||
| 4 | import { DebugConsoleService } from './debug-console.js'; | 2 | import { DebugConsoleService } from './debug-console.js'; | ||
| 5 | 3 | ||||
| 6 | export class BrowserControlsService { | 4 | export class BrowserControlsService { | ||
| 7 | private currentURL = ''; | 5 | private currentURL = ''; | ||
| 8 | - | private currentZoom = 1.0; | |||
| 9 | private notifications = new NotificationService(); | 6 | private notifications = new NotificationService(); | ||
| 10 | private debugConsole = new DebugConsoleService(); | 7 | private debugConsole = new DebugConsoleService(); | ||
| 11 | 8 | ||||
| 12 | constructor() { | 9 | constructor() { | ||
| 13 | // Debug logging with enhanced details | 10 | // Debug logging with enhanced details | ||
| 14 | this.debugConsole.addLog('info', 'frontend', 'init', 'BrowserControlsService initializing...'); | 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 | this.initializeEventListeners(); | 14 | this.initializeEventListeners(); | ||
| 18 | this.debugConsole.addLog('info', 'frontend', 'init', 'BrowserControlsService initialized ✓'); | 15 | this.debugConsole.addLog('info', 'frontend', 'init', 'BrowserControlsService initialized ✓'); | ||
| @@ -42,26 +39,9 @@ export class BrowserControlsService { | |||||
| 42 | const { ctrlKey, metaKey, shiftKey, altKey, key } = event; | 39 | const { ctrlKey, metaKey, shiftKey, altKey, key } = event; | ||
| 43 | const isCmd = ctrlKey || metaKey; | 40 | const isCmd = ctrlKey || metaKey; | ||
| 44 | 41 | ||||
| 45 | - | // Zoom controls | 42 | + | // Zoom controls handled natively by GTK; don't intercept |
| 46 | - | if (isCmd && (key === '+' || key === '=')) { | 43 | + | if (isCmd && (key === '+' || key === '=' || key === '-' || key === '0')) { |
| 47 | - | this.debugConsole.addLog('info', 'frontend', 'action', 'Zoom In triggered'); | 44 | + | return; // allow native handler to process |
| 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; | |||
| 65 | } | 45 | } | ||
| 66 | 46 | ||||
| 67 | // Copy URL | 47 | // Copy URL | ||
| @@ -76,14 +56,14 @@ export class BrowserControlsService { | |||||
| 76 | if (altKey && key === 'ArrowLeft') { | 56 | if (altKey && key === 'ArrowLeft') { | ||
| 77 | this.debugConsole.addLog('info', 'frontend', 'action', 'Go Back triggered'); | 57 | this.debugConsole.addLog('info', 'frontend', 'action', 'Go Back triggered'); | ||
| 78 | event.preventDefault(); | 58 | event.preventDefault(); | ||
| 79 | - | await this.goBack(); | 59 | + | history.back(); |
| 80 | return; | 60 | return; | ||
| 81 | } | 61 | } | ||
| 82 | 62 | ||||
| 83 | if (altKey && key === 'ArrowRight') { | 63 | if (altKey && key === 'ArrowRight') { | ||
| 84 | this.debugConsole.addLog('info', 'frontend', 'action', 'Go Forward triggered'); | 64 | this.debugConsole.addLog('info', 'frontend', 'action', 'Go Forward triggered'); | ||
| 85 | event.preventDefault(); | 65 | event.preventDefault(); | ||
| 86 | - | await this.goForward(); | 66 | + | history.forward(); |
| 87 | return; | 67 | return; | ||
| 88 | } | 68 | } | ||
| 89 | } | 69 | } | ||
| @@ -114,82 +94,7 @@ export class BrowserControlsService { | |||||
| 114 | this.loadSavedZoom(); | 94 | this.loadSavedZoom(); | ||
| 115 | } | 95 | } | ||
| 116 | 96 | ||||
| 117 | - | async zoomIn(): Promise<void> { | 97 | + | // Zoom is handled natively; no JS-based zoom adjustments needed. |
| 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 | - | } | |||
| 193 | 98 | ||||
| 194 | async copyCurrentURL(): Promise<void> { | 99 | async copyCurrentURL(): Promise<void> { | ||
| 195 | try { | 100 | try { | ||
| @@ -253,60 +158,22 @@ export class BrowserControlsService { | |||||
| 253 | 158 | ||||
| 254 | document.title = title; | 159 | document.title = title; | ||
| 255 | 160 | ||||
| 256 | - | // Notify backend service | 161 | + | // Backend title updates handled natively; no frontend call |
| 257 | - | try { | |||
| 258 | - | await BrowserService.UpdatePageTitle(url, title); | |||
| 259 | - | } catch (error) { | |||
| 260 | - | console.warn('Failed to update page title on backend:', error); | |||
| 261 | - | } | |||
| 262 | } catch (error) { | 162 | } catch (error) { | ||
| 263 | console.error('Failed to update title:', error); | 163 | console.error('Failed to update title:', error); | ||
| 264 | } | 164 | } | ||
| 265 | } | 165 | } | ||
| 266 | 166 | ||||
| 267 | async loadSavedZoom(): Promise<void> { | 167 | async loadSavedZoom(): Promise<void> { | ||
| 268 | - | try { | 168 | + | // Zoom persistence applied natively on page load |
| 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 | - | } | |||
| 276 | } | 169 | } | ||
| 277 | 170 | ||||
| 278 | async initialize(): Promise<void> { | 171 | async initialize(): Promise<void> { | ||
| 279 | // Check if there's an initial URL from backend (direct navigation) | 172 | // Check if there's an initial URL from backend (direct navigation) | ||
| 280 | - | try { | 173 | + | // Set initial URL from browser location (homepage) |
| 281 | - | const initialURL = await BrowserService.GetInitialURL(); | 174 | + | this.setCurrentURL(window.location.href); |
| 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 | - | } | |||
| 308 | 175 | ||||
| 309 | // Load saved zoom level (only if we're staying on homepage) | 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 | event.preventDefault(); | 83 | event.preventDefault(); | ||
| 84 | this.toggle(); | 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 | private interceptConsole(): void { | 89 | private interceptConsole(): void { | ||
| 132 | const originalConsole = { | 90 | const originalConsole = { | ||
| 133 | log: console.log, | 91 | log: console.log, | ||
| @@ -297,4 +255,4 @@ export class DebugConsoleService { | |||||
| 297 | public isDebugVisible(): boolean { | 255 | public isDebugVisible(): boolean { | ||
| 298 | return this.isVisible; | 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'; | 1 | + | type HistoryEntry = { id: number; url: string; title: string }; |
| 2 | - | import { BrowserService } from '../../bindings/github.com/bnema/dumber/services/index'; | 2 | + | type SearchShortcut = { description: string; url: string }; |
| 3 | - | import { Window } from '@wailsio/runtime'; | |||
| 4 | 3 | ||||
| 5 | export class DOMRenderer { | 4 | export class DOMRenderer { | ||
| 6 | private historyListElement: HTMLElement | null = null; | 5 | private historyListElement: HTMLElement | null = null; | ||
| @@ -73,9 +72,11 @@ export class DOMRenderer { | |||||
| 73 | private createHistoryItem(item: HistoryEntry): HTMLElement { | 72 | private createHistoryItem(item: HistoryEntry): HTMLElement { | ||
| 74 | const historyItem = document.createElement('div'); | 73 | const historyItem = document.createElement('div'); | ||
| 75 | historyItem.className = 'history-item'; | 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 | historyItem.innerHTML = ` | 77 | historyItem.innerHTML = ` | ||
| 77 | <div class="history-url">${this.escapeHtml(item.url)}</div> | 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 | historyItem.addEventListener('click', () => { | 82 | historyItem.addEventListener('click', () => { | ||
| @@ -104,14 +105,6 @@ export class DOMRenderer { | |||||
| 104 | 105 | ||||
| 105 | private async navigateToUrl(url: string): Promise<void> { | 106 | private async navigateToUrl(url: string): Promise<void> { | ||
| 106 | console.log('Navigating to:', url); | 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 | window.location.href = url; | 108 | window.location.href = url; | ||
| 116 | } | 109 | } | ||
| 117 | 110 | ||||
| @@ -148,4 +141,13 @@ export class DOMRenderer { | |||||
| 148 | div.textContent = text; | 141 | div.textContent = text; | ||
| 149 | return div.innerHTML; | 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 | bundle: true, | 15 | bundle: true, | ||
| 16 | splitting: false, | 16 | splitting: false, | ||
| 17 | external: [], | 17 | external: [], | ||
| 18 | - | noExternal: ['@wailsio/runtime'], | 18 | + | // noExternal: [], |
| 19 | }, | 19 | }, | ||
| 20 | // Injectable controls script (minified for injection) | 20 | // Injectable controls script (minified for injection) | ||
| 21 | { | 21 | { | ||
| @@ -51,4 +51,4 @@ export default defineConfig([ | |||||
| 51 | console.log('✓ Frontend TypeScript compiled successfully') | 51 | console.log('✓ Frontend TypeScript compiled successfully') | ||
| 52 | } | 52 | } | ||
| 53 | } | 53 | } | ||
| 54 | - | ])⏎̸ | 54 | + | ]) |
M go.mod +3 -39
| @@ -8,60 +8,24 @@ require ( | |||||
| 8 | github.com/ncruces/go-sqlite3 v0.29.0 | 8 | github.com/ncruces/go-sqlite3 v0.29.0 | ||
| 9 | github.com/spf13/cobra v1.10.1 | 9 | github.com/spf13/cobra v1.10.1 | ||
| 10 | github.com/spf13/viper v1.21.0 | 10 | github.com/spf13/viper v1.21.0 | ||
| 11 | - | github.com/wailsapp/wails/v3 v3.0.0-alpha.28 | |||
| 12 | ) | 11 | ) | ||
| 13 | 12 | ||||
| 14 | require ( | 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 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect | 14 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect | ||
| 29 | - | github.com/godbus/dbus/v5 v5.1.0 // indirect | 15 | + | github.com/google/go-cmp v0.7.0 // indirect |
| 30 | - | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect | |||
| 31 | - | github.com/google/uuid v1.6.0 // indirect | |||
| 32 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | 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 | github.com/ncruces/julianday v1.0.0 // indirect | 17 | github.com/ncruces/julianday v1.0.0 // indirect | ||
| 42 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect | 18 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect | ||
| 43 | - | github.com/pjbgf/sha1cd v0.3.2 // indirect | 19 | + | github.com/rogpeppe/go-internal v1.12.0 // 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 | |||
| 47 | github.com/sagikazarmark/locafero v0.11.0 // indirect | 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 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect | 21 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect | ||
| 52 | github.com/spf13/afero v1.15.0 // indirect | 22 | github.com/spf13/afero v1.15.0 // indirect | ||
| 53 | github.com/spf13/cast v1.10.0 // indirect | 23 | github.com/spf13/cast v1.10.0 // indirect | ||
| 54 | github.com/spf13/pflag v1.0.10 // indirect | 24 | github.com/spf13/pflag v1.0.10 // indirect | ||
| 55 | github.com/subosito/gotenv v1.6.0 // indirect | 25 | github.com/subosito/gotenv v1.6.0 // indirect | ||
| 56 | github.com/tetratelabs/wazero v1.9.0 // indirect | 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 | go.yaml.in/yaml/v3 v3.0.4 // indirect | 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 | golang.org/x/sys v0.36.0 // indirect | 28 | golang.org/x/sys v0.36.0 // indirect | ||
| 64 | golang.org/x/text v0.29.0 // indirect | 29 | golang.org/x/text v0.29.0 // indirect | ||
| 65 | - | gopkg.in/ini.v1 v1.67.0 // indirect | 30 | + | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect |
| 66 | - | gopkg.in/warnings.v0 v0.1.2 // indirect | |||
| 67 | ) | 31 | ) | ||
M go.sum +1 -120 generated file: collapsed
M internal/cli/browse.go +1 -1
| @@ -157,7 +157,7 @@ func openURL(url string) error { | |||||
| 157 | return openURLWithConfig(url, nil) | 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 | func openURLWithConfig(url string, cfg *config.Config) error { | 161 | func openURLWithConfig(url string, cfg *config.Config) error { | ||
| 162 | // Get the path to our own executable | 162 | // Get the path to our own executable | ||
| 163 | executable, err := os.Executable() | 163 | executable, err := os.Executable() | ||
M internal/config/config.go +32 -15
| @@ -16,11 +16,12 @@ import ( | |||||
| 16 | 16 | ||||
| 17 | // Config represents the complete configuration for dumber. | 17 | // Config represents the complete configuration for dumber. | ||
| 18 | type Config struct { | 18 | type Config struct { | ||
| 19 | - | Database DatabaseConfig `mapstructure:"database" yaml:"database"` | 19 | + | Database DatabaseConfig `mapstructure:"database" yaml:"database"` |
| 20 | - | History HistoryConfig `mapstructure:"history" yaml:"history"` | 20 | + | History HistoryConfig `mapstructure:"history" yaml:"history"` |
| 21 | - | SearchShortcuts map[string]SearchShortcut `mapstructure:"search_shortcuts" yaml:"search_shortcuts"` | 21 | + | SearchShortcuts map[string]SearchShortcut `mapstructure:"search_shortcuts" yaml:"search_shortcuts"` |
| 22 | - | Dmenu DmenuConfig `mapstructure:"dmenu" yaml:"dmenu"` | 22 | + | Dmenu DmenuConfig `mapstructure:"dmenu" yaml:"dmenu"` |
| 23 | - | Logging LoggingConfig `mapstructure:"logging" yaml:"logging"` | 23 | + | Logging LoggingConfig `mapstructure:"logging" yaml:"logging"` |
| 24 | + | Appearance AppearanceConfig `mapstructure:"appearance" yaml:"appearance"` | |||
| 24 | } | 25 | } | ||
| 25 | 26 | ||||
| 26 | // DatabaseConfig holds database-related configuration. | 27 | // DatabaseConfig holds database-related configuration. | ||
| @@ -67,6 +68,16 @@ type LoggingConfig struct { | |||||
| 67 | Compress bool `mapstructure:"compress" yaml:"compress"` | 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 | // Manager handles configuration loading, watching, and reloading. | 81 | // Manager handles configuration loading, watching, and reloading. | ||
| 71 | type Manager struct { | 82 | type Manager struct { | ||
| 72 | config *Config | 83 | config *Config | ||
| @@ -258,14 +269,14 @@ func (m *Manager) reload() error { | |||||
| 258 | 269 | ||||
| 259 | // setDefaults sets default configuration values in Viper. | 270 | // setDefaults sets default configuration values in Viper. | ||
| 260 | func (m *Manager) setDefaults() { | 271 | func (m *Manager) setDefaults() { | ||
| 261 | - | defaults := DefaultConfig() | 272 | + | defaults := DefaultConfig() |
| 262 | 273 | ||||
| 263 | // Database defaults | 274 | // Database defaults | ||
| 264 | m.viper.SetDefault("database.max_connections", defaults.Database.MaxConnections) | 275 | m.viper.SetDefault("database.max_connections", defaults.Database.MaxConnections) | ||
| 265 | m.viper.SetDefault("database.max_idle_time", defaults.Database.MaxIdleTime) | 276 | m.viper.SetDefault("database.max_idle_time", defaults.Database.MaxIdleTime) | ||
| 266 | m.viper.SetDefault("database.query_timeout", defaults.Database.QueryTimeout) | 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 | // History defaults | 281 | // History defaults | ||
| 271 | m.viper.SetDefault("history.max_entries", defaults.History.MaxEntries) | 282 | m.viper.SetDefault("history.max_entries", defaults.History.MaxEntries) | ||
| @@ -285,14 +296,20 @@ func (m *Manager) setDefaults() { | |||||
| 285 | m.viper.SetDefault("dmenu.date_format", defaults.Dmenu.DateFormat) | 296 | m.viper.SetDefault("dmenu.date_format", defaults.Dmenu.DateFormat) | ||
| 286 | m.viper.SetDefault("dmenu.sort_by_visit_count", defaults.Dmenu.SortByVisitCount) | 297 | m.viper.SetDefault("dmenu.sort_by_visit_count", defaults.Dmenu.SortByVisitCount) | ||
| 287 | 298 | ||||
| 288 | - | // Logging defaults | 299 | + | // Logging defaults |
| 289 | - | m.viper.SetDefault("logging.level", defaults.Logging.Level) | 300 | + | m.viper.SetDefault("logging.level", defaults.Logging.Level) |
| 290 | - | m.viper.SetDefault("logging.format", defaults.Logging.Format) | 301 | + | m.viper.SetDefault("logging.format", defaults.Logging.Format) |
| 291 | - | m.viper.SetDefault("logging.filename", defaults.Logging.Filename) | 302 | + | m.viper.SetDefault("logging.filename", defaults.Logging.Filename) |
| 292 | - | m.viper.SetDefault("logging.max_size", defaults.Logging.MaxSize) | 303 | + | m.viper.SetDefault("logging.max_size", defaults.Logging.MaxSize) |
| 293 | - | m.viper.SetDefault("logging.max_backups", defaults.Logging.MaxBackups) | 304 | + | m.viper.SetDefault("logging.max_backups", defaults.Logging.MaxBackups) |
| 294 | - | m.viper.SetDefault("logging.max_age", defaults.Logging.MaxAge) | 305 | + | m.viper.SetDefault("logging.max_age", defaults.Logging.MaxAge) |
| 295 | - | m.viper.SetDefault("logging.compress", defaults.Logging.Compress) | 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 | // createDefaultConfig creates a default configuration file. | 315 | // createDefaultConfig creates a default configuration file. | ||
M internal/config/defaults.go +18 -12
| @@ -7,7 +7,7 @@ import ( | |||||
| 7 | 7 | ||||
| 8 | // DefaultConfig returns the default configuration values for dumber. | 8 | // DefaultConfig returns the default configuration values for dumber. | ||
| 9 | func DefaultConfig() *Config { | 9 | func DefaultConfig() *Config { | ||
| 10 | - | return &Config{ | 10 | + | return &Config{ |
| 11 | Database: DatabaseConfig{ | 11 | Database: DatabaseConfig{ | ||
| 12 | MaxConnections: 1, | 12 | MaxConnections: 1, | ||
| 13 | MaxIdleTime: time.Minute * 5, | 13 | MaxIdleTime: time.Minute * 5, | ||
| @@ -46,16 +46,22 @@ func DefaultConfig() *Config { | |||||
| 46 | DateFormat: "2006-01-02 15:04", | 46 | DateFormat: "2006-01-02 15:04", | ||
| 47 | SortByVisitCount: true, | 47 | SortByVisitCount: true, | ||
| 48 | }, | 48 | }, | ||
| 49 | - | Logging: LoggingConfig{ | 49 | + | Logging: LoggingConfig{ |
| 50 | - | Level: "info", | 50 | + | Level: "info", |
| 51 | - | Format: "text", // text or json | 51 | + | Format: "text", // text or json |
| 52 | - | Filename: "", // empty means stdout | 52 | + | Filename: "", // empty means stdout |
| 53 | - | MaxSize: 100, // MB | 53 | + | MaxSize: 100, // MB |
| 54 | - | MaxBackups: 3, | 54 | + | MaxBackups: 3, |
| 55 | - | MaxAge: 7, // days | 55 | + | MaxAge: 7, // days |
| 56 | - | Compress: true, | 56 | + | Compress: true, |
| 57 | - | }, | 57 | + | }, |
| 58 | - | } | 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 | // GetDefaultSearchShortcuts returns the default search shortcuts. | 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 | package main | 1 | package main | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "context" | 4 | + | "context" |
| 5 | - | "embed" | 5 | + | "embed" |
| 6 | - | "fmt" | 6 | + | "encoding/json" |
| 7 | - | "log" | 7 | + | "fmt" |
| 8 | - | "log/slog" | 8 | + | "mime" |
| 9 | - | "os" | 9 | + | "log" |
| 10 | - | "time" | 10 | + | "os" |
| 11 | - | 11 | + | "runtime" | |
| 12 | - | "github.com/wailsapp/wails/v3/pkg/application" | 12 | + | neturl "net/url" |
| 13 | - | 13 | + | "path/filepath" | |
| 14 | - | "github.com/bnema/dumber/internal/cli" | 14 | + | "strings" |
| 15 | - | "github.com/bnema/dumber/internal/config" | 15 | + | "strconv" |
| 16 | - | "github.com/bnema/dumber/internal/db" | 16 | + | |
| 17 | - | "github.com/bnema/dumber/services" | 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 | // Build information set via ldflags | 25 | // Build information set via ldflags | ||
| 26 | var ( | 26 | var ( | ||
| 27 | version = "dev" | 27 | version = "dev" | ||
| @@ -36,8 +36,8 @@ func main() { | |||||
| 36 | return | 36 | return | ||
| 37 | } | 37 | } | ||
| 38 | 38 | ||||
| 39 | - | // Otherwise run the GUI browser | 39 | + | // Otherwise run the GUI browser |
| 40 | - | runBrowser() | 40 | + | runBrowser() |
| 41 | } | 41 | } | ||
| 42 | 42 | ||||
| 43 | // shouldRunCLI determines if we should run in CLI mode based on arguments | 43 | // shouldRunCLI determines if we should run in CLI mode based on arguments | ||
| @@ -106,148 +106,376 @@ func runCLI() { | |||||
| 106 | if err := rootCmd.Execute(); err != nil { | 106 | if err := rootCmd.Execute(); err != nil { | ||
| 107 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) | 107 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) | ||
| 108 | os.Exit(1) | 108 | os.Exit(1) | ||
| 109 | - | } | 109 | + | } |
| 110 | } | 110 | } | ||
| 111 | 111 | ||||
| 112 | - | // runBrowser executes the browser GUI functionality | 112 | + | //go:embed frontend/dist |
| 113 | - | func runBrowser() { | 113 | + | var assets embed.FS |
| 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 | - | }() | |||
| 247 | 114 | ||||
| 248 | - | // Run the application (blocking call) | 115 | + | // runBrowser launches a minimal native WebKit window and integrates services. |
| 249 | - | err = app.Run() | 116 | + | func runBrowser() { |
| 250 | - | if err != nil { | 117 | + | log.Printf("Starting GUI mode (webkit_cgo=%v)", webkit.IsNativeAvailable()) |
| 251 | - | log.Fatal(err) | 118 | + | // GTK requires all UI calls to run on the main OS thread |
| 252 | - | } | 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 | package services | 2 | package services | ||
| 2 | 3 | ||||
| 3 | import ( | 4 | import ( | ||
| 4 | - | "context" | 5 | + | "context" |
| 5 | - | "database/sql" | 6 | + | "database/sql" |
| 6 | - | "embed" | 7 | + | "embed" |
| 7 | - | "fmt" | 8 | + | "fmt" |
| 8 | - | "net/url" | 9 | + | "errors" |
| 9 | - | "time" | 10 | + | "math" |
| 10 | - | 11 | + | "time" | |
| 11 | - | "github.com/wailsapp/wails/v3/pkg/application" | 12 | + | neturl "net/url" |
| 12 | - | "github.com/bnema/dumber/internal/config" | 13 | + | |
| 13 | - | "github.com/bnema/dumber/internal/db" | 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 | 19 | + | func clampToInt32(i int64) int32 { |
| 17 | - | // This matches the Wails v3 WebviewWindow.SetTitle method signature that returns application.Window | 20 | + | if i > math.MaxInt32 { |
| 18 | - | type WindowTitleUpdater interface { | 21 | + | i = math.MaxInt32 |
| 19 | - | SetTitle(title string) application.Window | 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 | 29 | + | // WindowTitleUpdater interface allows the service to update the window title |
| 23 | - | type ScriptInjector interface { | 30 | + | // Decoupled from any specific GUI framework. |
| 24 | - | ExecJS(js string) | 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 | type BrowserService struct { | 36 | type BrowserService struct { | ||
| 29 | - | config *config.Config | 37 | + | config *config.Config |
| 30 | - | dbQueries db.DatabaseQuerier | 38 | + | dbQueries db.DatabaseQuerier |
| 31 | - | windowTitleUpdater WindowTitleUpdater | 39 | + | windowTitleUpdater WindowTitleUpdater |
| 32 | - | scriptInjector ScriptInjector | 40 | + | webView *webkit.WebView |
| 33 | - | initialURL string | 41 | + | initialURL string |
| 34 | - | injectableScript 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 | func (s *BrowserService) ServiceName() string { | 46 | func (s *BrowserService) ServiceName() string { | ||
| 39 | return "BrowserService" | 47 | return "BrowserService" | ||
| 40 | } | 48 | } | ||
| @@ -61,14 +69,14 @@ type HistoryEntry struct { | |||||
| 61 | 69 | ||||
| 62 | // NewBrowserService creates a new BrowserService instance. | 70 | // NewBrowserService creates a new BrowserService instance. | ||
| 63 | func NewBrowserService(cfg *config.Config, queries db.DatabaseQuerier) *BrowserService { | 71 | func NewBrowserService(cfg *config.Config, queries db.DatabaseQuerier) *BrowserService { | ||
| 64 | - | return &BrowserService{ | 72 | + | return &BrowserService{ |
| 65 | - | config: cfg, | 73 | + | config: cfg, |
| 66 | - | dbQueries: queries, | 74 | + | dbQueries: queries, |
| 67 | - | windowTitleUpdater: nil, | 75 | + | windowTitleUpdater: nil, |
| 68 | - | scriptInjector: nil, | 76 | + | webView: nil, |
| 69 | - | initialURL: "", | 77 | + | initialURL: "", |
| 70 | - | injectableScript: "", | 78 | + | injectableScript: "", |
| 71 | - | } | 79 | + | } |
| 72 | } | 80 | } | ||
| 73 | 81 | ||||
| 74 | // SetWindowTitleUpdater sets the window title updater interface | 82 | // SetWindowTitleUpdater sets the window title updater interface | ||
| @@ -76,9 +84,9 @@ func (s *BrowserService) SetWindowTitleUpdater(updater WindowTitleUpdater) { | |||||
| 76 | s.windowTitleUpdater = updater | 84 | s.windowTitleUpdater = updater | ||
| 77 | } | 85 | } | ||
| 78 | 86 | ||||
| 79 | - | // SetScriptInjector sets the script injector interface | 87 | + | // AttachWebView connects a native WebKit WebView to this service for integration. |
| 80 | - | func (s *BrowserService) SetScriptInjector(injector ScriptInjector) { | 88 | + | func (s *BrowserService) AttachWebView(view *webkit.WebView) { |
| 81 | - | s.scriptInjector = injector | 89 | + | s.webView = view |
| 82 | } | 90 | } | ||
| 83 | 91 | ||||
| 84 | // LoadInjectableScript loads the minified injectable controls script from assets | 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 | // InjectControlScript injects the global controls script into the current page | 102 | // InjectControlScript injects the global controls script into the current page | ||
| 95 | func (s *BrowserService) InjectControlScript(ctx context.Context) error { | 103 | func (s *BrowserService) InjectControlScript(ctx context.Context) error { | ||
| 96 | - | if s.scriptInjector == nil { | 104 | + | _ = ctx |
| 97 | - | return fmt.Errorf("script injector not set") | 105 | + | if s.webView == nil { |
| 98 | - | } | 106 | + | return fmt.Errorf("webview not attached") |
| 99 | - | 107 | + | } | |
| 100 | - | if s.injectableScript == "" { | 108 | + | if s.injectableScript == "" { |
| 101 | - | return fmt.Errorf("injectable script not loaded") | 109 | + | return fmt.Errorf("injectable script not loaded") |
| 102 | - | } | 110 | + | } |
| 103 | - | 111 | + | wrappedScript := "(function(){try{" + | |
| 104 | - | // Wrap the script in a try-catch and DOM ready check for better compatibility | 112 | + | "if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',function(){try{" + |
| 105 | - | wrappedScript := fmt.Sprintf(` | 113 | + | s.injectableScript + |
| 106 | - | (function() { | 114 | + | "}catch(e){console.warn('Dumber Browser controls failed to load on DOMContentLoaded:', e);}});}else{" + |
| 107 | - | try { | 115 | + | s.injectableScript + |
| 108 | - | // Check if DOM is ready | 116 | + | "}}catch(e){console.warn('Dumber Browser controls injection failed:', e);}})();" |
| 109 | - | if (document.readyState === 'loading') { | 117 | + | return s.webView.InjectScript(wrappedScript) |
| 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 | |||
| 130 | } | 118 | } | ||
| 131 | 119 | ||||
| 132 | // Navigate handles navigation to a URL and records it in history. | 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 | result := make([]HistoryEntry, len(entries)) | 183 | result := make([]HistoryEntry, len(entries)) | ||
| 196 | for i, entry := range entries { | 184 | for i, entry := range entries { | ||
| 197 | - | result[i] = HistoryEntry{ | 185 | + | // Defensive cast to int32 to prevent overflow |
| 198 | - | ID: entry.ID, | 186 | + | vc := clampToInt32(entry.VisitCount.Int64) |
| 199 | - | URL: entry.Url, | 187 | + | result[i] = HistoryEntry{ |
| 200 | - | Title: entry.Title.String, | 188 | + | ID: entry.ID, |
| 201 | - | VisitCount: int32(entry.VisitCount.Int64), | 189 | + | URL: entry.Url, |
| 202 | - | LastVisited: entry.LastVisited.Time, | 190 | + | Title: entry.Title.String, |
| 203 | - | CreatedAt: entry.CreatedAt.Time, | 191 | + | VisitCount: vc, |
| 204 | - | } | 192 | + | LastVisited: entry.LastVisited.Time, |
| 193 | + | CreatedAt: entry.CreatedAt.Time, | |||
| 194 | + | } | |||
| 205 | } | 195 | } | ||
| 206 | 196 | ||||
| 207 | return result, nil | 197 | return result, nil | ||
| @@ -227,14 +217,15 @@ func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit | |||||
| 227 | 217 | ||||
| 228 | result := make([]HistoryEntry, len(entries)) | 218 | result := make([]HistoryEntry, len(entries)) | ||
| 229 | for i, entry := range entries { | 219 | for i, entry := range entries { | ||
| 230 | - | result[i] = HistoryEntry{ | 220 | + | vc := clampToInt32(entry.VisitCount.Int64) |
| 231 | - | ID: entry.ID, | 221 | + | result[i] = HistoryEntry{ |
| 232 | - | URL: entry.Url, | 222 | + | ID: entry.ID, |
| 233 | - | Title: entry.Title.String, | 223 | + | URL: entry.Url, |
| 234 | - | VisitCount: int32(entry.VisitCount.Int64), | 224 | + | Title: entry.Title.String, |
| 235 | - | LastVisited: entry.LastVisited.Time, | 225 | + | VisitCount: vc, |
| 236 | - | CreatedAt: entry.CreatedAt.Time, | 226 | + | LastVisited: entry.LastVisited.Time, |
| 237 | - | } | 227 | + | CreatedAt: entry.CreatedAt.Time, |
| 228 | + | } | |||
| 238 | } | 229 | } | ||
| 239 | 230 | ||||
| 240 | return result, nil | 231 | return result, nil | ||
| @@ -242,22 +233,25 @@ func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit | |||||
| 242 | 233 | ||||
| 243 | // DeleteHistoryEntry removes a specific history entry. | 234 | // DeleteHistoryEntry removes a specific history entry. | ||
| 244 | func (s *BrowserService) DeleteHistoryEntry(ctx context.Context, id int64) error { | 235 | func (s *BrowserService) DeleteHistoryEntry(ctx context.Context, id int64) error { | ||
| 245 | - | // Note: DeleteHistory method doesn't exist in current schema | 236 | + | _ = ctx |
| 246 | - | // This would need to be implemented in the database layer | 237 | + | _ = id |
| 247 | - | return fmt.Errorf("delete history not implemented yet") | 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 | // ClearHistory removes all history entries. | 243 | // ClearHistory removes all history entries. | ||
| 251 | func (s *BrowserService) ClearHistory(ctx context.Context) error { | 244 | func (s *BrowserService) ClearHistory(ctx context.Context) error { | ||
| 252 | - | // Note: ClearAllHistory method doesn't exist in current schema | 245 | + | _ = ctx |
| 253 | - | // This would need to be implemented in the database layer | 246 | + | // Note: ClearAllHistory method doesn't exist in current schema |
| 254 | - | return fmt.Errorf("clear all history not implemented yet") | 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 | // GetHistoryStats returns statistics about browser history. | 251 | // GetHistoryStats returns statistics about browser history. | ||
| 258 | func (s *BrowserService) GetHistoryStats(ctx context.Context) (map[string]interface{}, error) { | 252 | func (s *BrowserService) GetHistoryStats(ctx context.Context) (map[string]interface{}, error) { | ||
| 259 | - | // Get recent entries for basic stats | 253 | + | // Get recent entries for basic stats |
| 260 | - | recentEntries, err := s.dbQueries.GetHistory(ctx, 1000) | 254 | + | recentEntries, err := s.dbQueries.GetHistory(ctx, recentHistoryLimit) |
| 261 | if err != nil { | 255 | if err != nil { | ||
| 262 | return nil, err | 256 | return nil, err | ||
| 263 | } | 257 | } | ||
| @@ -279,14 +273,16 @@ func (s *BrowserService) GetHistoryStats(ctx context.Context) (map[string]interf | |||||
| 279 | 273 | ||||
| 280 | // GetConfig returns the current browser configuration. | 274 | // GetConfig returns the current browser configuration. | ||
| 281 | func (s *BrowserService) GetConfig(ctx context.Context) (*config.Config, error) { | 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 | // UpdateConfig updates the browser configuration. | 280 | // UpdateConfig updates the browser configuration. | ||
| 286 | func (s *BrowserService) UpdateConfig(ctx context.Context, newConfig *config.Config) error { | 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 | 282 | + | _ = ctx |
| 288 | - | s.config = newConfig | 283 | + | // In a real implementation, you'd want to validate and persist the config |
| 289 | - | return nil | 284 | + | s.config = newConfig |
| 285 | + | return nil | |||
| 290 | } | 286 | } | ||
| 291 | 287 | ||||
| 292 | // GetSearchShortcuts returns available search shortcuts. | 288 | // GetSearchShortcuts returns available search shortcuts. | ||
| @@ -347,47 +343,51 @@ func (s *BrowserService) ResetZoom(ctx context.Context, url string) (float64, er | |||||
| 347 | 343 | ||||
| 348 | // GetZoomLevel retrieves the saved zoom level for a URL. | 344 | // GetZoomLevel retrieves the saved zoom level for a URL. | ||
| 349 | func (s *BrowserService) GetZoomLevel(ctx context.Context, url string) (float64, error) { | 345 | func (s *BrowserService) GetZoomLevel(ctx context.Context, url string) (float64, error) { | ||
| 350 | - | if url == "" { | 346 | + | if url == "" { |
| 351 | - | return 1.0, nil | 347 | + | return 1.0, nil |
| 352 | - | } | 348 | + | } |
| 353 | - | 349 | + | ||
| 354 | - | // Extract domain for consistent storage across pages of the same site | 350 | + | key := zoomKeyFromURL(url) |
| 355 | - | domain := s.extractDomain(url) | 351 | + | zoomLevel, err := s.dbQueries.GetZoomLevel(ctx, key) |
| 356 | - | 352 | + | if err != nil { | |
| 357 | - | zoomLevel, err := s.dbQueries.GetZoomLevel(ctx, domain) | 353 | + | if errors.Is(err, sql.ErrNoRows) { |
| 358 | - | if err != nil { | 354 | + | // Fallback to exact URL record if present (backward compatibility) |
| 359 | - | if err == sql.ErrNoRows { | 355 | + | zl, err2 := s.dbQueries.GetZoomLevel(ctx, url) |
| 360 | - | // No zoom setting found, return default | 356 | + | if err2 == nil { |
| 361 | - | return 1.0, nil | 357 | + | return zl, nil |
| 362 | - | } | 358 | + | } |
| 363 | - | return 1.0, err | 359 | + | // No zoom setting found, return default |
| 364 | - | } | 360 | + | return 1.0, nil |
| 365 | - | 361 | + | } | |
| 366 | - | return zoomLevel, nil | 362 | + | return 1.0, err |
| 363 | + | } | |||
| 364 | + | ||||
| 365 | + | return zoomLevel, nil | |||
| 367 | } | 366 | } | ||
| 368 | 367 | ||||
| 369 | // SetZoomLevel sets the zoom level for a URL. | 368 | // SetZoomLevel sets the zoom level for a URL. | ||
| 370 | func (s *BrowserService) SetZoomLevel(ctx context.Context, url string, zoomLevel float64) error { | 369 | func (s *BrowserService) SetZoomLevel(ctx context.Context, url string, zoomLevel float64) error { | ||
| 371 | - | if url == "" { | 370 | + | if url == "" { |
| 372 | - | return fmt.Errorf("URL cannot be empty") | 371 | + | return fmt.Errorf("URL cannot be empty") |
| 373 | - | } | 372 | + | } |
| 374 | 373 | ||||
| 375 | - | // Clamp zoom level to reasonable bounds | 374 | + | // Clamp zoom level to reasonable bounds |
| 376 | - | if zoomLevel < 0.25 { | 375 | + | if zoomLevel < zoomMin { |
| 377 | - | zoomLevel = 0.25 | 376 | + | zoomLevel = zoomMin |
| 378 | - | } else if zoomLevel > 5.0 { | 377 | + | } else if zoomLevel > zoomMax { |
| 379 | - | zoomLevel = 5.0 | 378 | + | zoomLevel = zoomMax |
| 380 | - | } | 379 | + | } |
| 381 | 380 | ||||
| 382 | - | domain := s.extractDomain(url) | 381 | + | key := zoomKeyFromURL(url) |
| 383 | - | return s.dbQueries.SetZoomLevel(ctx, domain, zoomLevel) | 382 | + | return s.dbQueries.SetZoomLevel(ctx, key, zoomLevel) |
| 384 | } | 383 | } | ||
| 385 | 384 | ||||
| 386 | // GetCurrentURL returns the current URL (this would be implemented by the frontend) | 385 | // GetCurrentURL returns the current URL (this would be implemented by the frontend) | ||
| 387 | func (s *BrowserService) GetCurrentURL(ctx context.Context) (string, error) { | 386 | func (s *BrowserService) GetCurrentURL(ctx context.Context) (string, error) { | ||
| 388 | - | // This would be implemented by getting the current URL from the webview | 387 | + | if s.webView == nil { |
| 389 | - | // For now, return empty as this will be called by the frontend | 388 | + | return "", nil |
| 390 | - | return "", nil | 389 | + | } |
| 390 | + | return s.webView.GetCurrentURL(), nil | |||
| 391 | } | 391 | } | ||
| 392 | 392 | ||||
| 393 | // GetInitialURL returns the initially navigated URL for frontend synchronization | 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 | // GoBack provides navigation back functionality | 409 | // GoBack provides navigation back functionality | ||
| 410 | func (s *BrowserService) GoBack(ctx context.Context) error { | 410 | func (s *BrowserService) GoBack(ctx context.Context) error { | ||
| 411 | - | // This will be handled by the frontend's webview | 411 | + | if s.webView != nil { |
| 412 | - | // Backend just logs the action | 412 | + | return s.webView.GoBack() |
| 413 | - | fmt.Println("Navigation: Go back") | 413 | + | } |
| 414 | - | return nil | 414 | + | fmt.Println("Navigation: Go back") |
| 415 | + | return nil | |||
| 415 | } | 416 | } | ||
| 416 | 417 | ||||
| 417 | // GoForward provides navigation forward functionality | 418 | // GoForward provides navigation forward functionality | ||
| 418 | func (s *BrowserService) GoForward(ctx context.Context) error { | 419 | func (s *BrowserService) GoForward(ctx context.Context) error { | ||
| 419 | - | // This will be handled by the frontend's webview | 420 | + | if s.webView != nil { |
| 420 | - | // Backend just logs the action | 421 | + | return s.webView.GoForward() |
| 421 | - | fmt.Println("Navigation: Go forward") | 422 | + | } |
| 422 | - | return nil | 423 | + | fmt.Println("Navigation: Go forward") |
| 424 | + | return nil | |||
| 423 | } | 425 | } | ||
| 424 | 426 | ||||
| 425 | // getNextZoomLevel returns the next Firefox zoom level in the specified direction | 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 | func (s *BrowserService) getNextZoomLevel(currentZoom float64, zoomIn bool) float64 { | 434 | func (s *BrowserService) getNextZoomLevel(currentZoom float64, zoomIn bool) float64 { | ||
| 427 | // Find the closest current zoom level | 435 | // Find the closest current zoom level | ||
| 428 | closestIndex := 0 | 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 { | 450 | + | if zoomIn { |
| 443 | - | // Move to next higher zoom level | 451 | + | // Move to next higher zoom level |
| 444 | - | if closestIndex < len(firefoxZoomLevels)-1 { | 452 | + | if closestIndex < len(firefoxZoomLevels)-1 { |
| 445 | - | return firefoxZoomLevels[closestIndex+1] | 453 | + | return firefoxZoomLevels[closestIndex+1] |
| 446 | - | } | 454 | + | } |
| 447 | - | return firefoxZoomLevels[closestIndex] // Already at max | 455 | + | return firefoxZoomLevels[closestIndex] // Already at max |
| 448 | - | } else { | 456 | + | } |
| 449 | - | // Move to next lower zoom level | 457 | + | // Move to next lower zoom level |
| 450 | - | if closestIndex > 0 { | 458 | + | if closestIndex > 0 { |
| 451 | - | return firefoxZoomLevels[closestIndex-1] | 459 | + | return firefoxZoomLevels[closestIndex-1] |
| 452 | - | } | 460 | + | } |
| 453 | - | return firefoxZoomLevels[closestIndex] // Already at min | 461 | + | return firefoxZoomLevels[closestIndex] // Already at min |
| 454 | - | } | |||
| 455 | } | 462 | } | ||
| 456 | 463 | ||||
| 457 | // setZoom sets the zoom level to a specific value | 464 | // setZoom sets the zoom level to a specific value | ||
| 458 | func (s *BrowserService) setZoom(ctx context.Context, url string, zoomLevel float64) (float64, error) { | 465 | func (s *BrowserService) setZoom(ctx context.Context, url string, zoomLevel float64) (float64, error) { | ||
| 459 | - | err := s.SetZoomLevel(ctx, url, zoomLevel) | 466 | + | err := s.SetZoomLevel(ctx, url, zoomLevel) |
| 460 | - | if err != nil { | 467 | + | if err != nil { |
| 461 | - | return 1.0, err | 468 | + | return 1.0, err |
| 462 | - | } | 469 | + | } |
| 463 | - | return zoomLevel, nil | 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 | // extractDomain extracts the domain from a URL for zoom level storage | 483 | // extractDomain extracts the domain from a URL for zoom level storage | ||
| 467 | // Domain-based zoom allows settings to persist across different pages of the same site | 484 | // Domain-based zoom allows settings to persist across different pages of the same site | ||
| 468 | - | func (s *BrowserService) extractDomain(rawURL string) string { | 485 | + | // extractDomain is no longer used; zoom is stored per URL for contract parity. |
| 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 | - | }⏎̸ | |||
M services/config_service.go +67 -49
| @@ -1,20 +1,22 @@ | |||||
| 1 | package services | 1 | package services | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "context" | 4 | + | "context" |
| 5 | - | "fmt" | 5 | + | "fmt" |
| 6 | - | "os" | 6 | + | "os" |
| 7 | - | "path/filepath" | 7 | + | "path/filepath" |
| 8 | 8 | ||||
| 9 | "github.com/bnema/dumber/internal/config" | 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 | type ConfigService struct { | 13 | type ConfigService struct { | ||
| 14 | - | config *config.Config | 14 | + | config *config.Config |
| 15 | - | configPath string | 15 | + | configPath string |
| 16 | } | 16 | } | ||
| 17 | 17 | ||||
| 18 | + | const defaultDirPerm = 0o750 | |||
| 19 | + | ||||
| 18 | // ConfigInfo represents configuration information for the frontend. | 20 | // ConfigInfo represents configuration information for the frontend. | ||
| 19 | type ConfigInfo struct { | 21 | type ConfigInfo struct { | ||
| 20 | ConfigPath string `json:"config_path"` | 22 | ConfigPath string `json:"config_path"` | ||
| @@ -33,24 +35,27 @@ func NewConfigService(cfg *config.Config, configPath string) *ConfigService { | |||||
| 33 | 35 | ||||
| 34 | // GetConfigInfo returns comprehensive configuration information. | 36 | // GetConfigInfo returns comprehensive configuration information. | ||
| 35 | func (s *ConfigService) GetConfigInfo(ctx context.Context) (*ConfigInfo, error) { | 37 | func (s *ConfigService) GetConfigInfo(ctx context.Context) (*ConfigInfo, error) { | ||
| 36 | - | return &ConfigInfo{ | 38 | + | _ = ctx |
| 37 | - | ConfigPath: s.configPath, | 39 | + | return &ConfigInfo{ |
| 38 | - | DatabasePath: s.config.Database.Path, | 40 | + | ConfigPath: s.configPath, |
| 39 | - | SearchShortcuts: s.config.SearchShortcuts, | 41 | + | DatabasePath: s.config.Database.Path, |
| 40 | - | DmenuSettings: &s.config.Dmenu, | 42 | + | SearchShortcuts: s.config.SearchShortcuts, |
| 41 | - | }, nil | 43 | + | DmenuSettings: &s.config.Dmenu, |
| 44 | + | }, nil | |||
| 42 | } | 45 | } | ||
| 43 | 46 | ||||
| 44 | // GetSearchShortcuts returns all configured search shortcuts. | 47 | // GetSearchShortcuts returns all configured search shortcuts. | ||
| 45 | func (s *ConfigService) GetSearchShortcuts(ctx context.Context) (map[string]config.SearchShortcut, error) { | 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 | // AddSearchShortcut adds a new search shortcut. | 53 | // AddSearchShortcut adds a new search shortcut. | ||
| 50 | func (s *ConfigService) AddSearchShortcut(ctx context.Context, key string, shortcut config.SearchShortcut) error { | 54 | func (s *ConfigService) AddSearchShortcut(ctx context.Context, key string, shortcut config.SearchShortcut) error { | ||
| 51 | - | if key == "" { | 55 | + | _ = ctx |
| 52 | - | return fmt.Errorf("shortcut key cannot be empty") | 56 | + | if key == "" { |
| 53 | - | } | 57 | + | return fmt.Errorf("shortcut key cannot be empty") |
| 58 | + | } | |||
| 54 | 59 | ||||
| 55 | if shortcut.URL == "" { | 60 | if shortcut.URL == "" { | ||
| 56 | return fmt.Errorf("shortcut URL cannot be empty") | 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 | // UpdateSearchShortcut updates an existing search shortcut. | 72 | // UpdateSearchShortcut updates an existing search shortcut. | ||
| 68 | func (s *ConfigService) UpdateSearchShortcut(ctx context.Context, key string, shortcut config.SearchShortcut) error { | 73 | func (s *ConfigService) UpdateSearchShortcut(ctx context.Context, key string, shortcut config.SearchShortcut) error { | ||
| 69 | - | if key == "" { | 74 | + | _ = ctx |
| 70 | - | return fmt.Errorf("shortcut key cannot be empty") | 75 | + | if key == "" { |
| 71 | - | } | 76 | + | return fmt.Errorf("shortcut key cannot be empty") |
| 77 | + | } | |||
| 72 | 78 | ||||
| 73 | if _, exists := s.config.SearchShortcuts[key]; !exists { | 79 | if _, exists := s.config.SearchShortcuts[key]; !exists { | ||
| 74 | return fmt.Errorf("shortcut '%s' does not exist", key) | 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 | // DeleteSearchShortcut removes a search shortcut. | 91 | // DeleteSearchShortcut removes a search shortcut. | ||
| 86 | func (s *ConfigService) DeleteSearchShortcut(ctx context.Context, key string) error { | 92 | func (s *ConfigService) DeleteSearchShortcut(ctx context.Context, key string) error { | ||
| 87 | - | if key == "" { | 93 | + | _ = ctx |
| 88 | - | return fmt.Errorf("shortcut key cannot be empty") | 94 | + | if key == "" { |
| 89 | - | } | 95 | + | return fmt.Errorf("shortcut key cannot be empty") |
| 96 | + | } | |||
| 90 | 97 | ||||
| 91 | if _, exists := s.config.SearchShortcuts[key]; !exists { | 98 | if _, exists := s.config.SearchShortcuts[key]; !exists { | ||
| 92 | return fmt.Errorf("shortcut '%s' does not exist", key) | 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 | // GetDmenuSettings returns current dmenu settings. | 106 | // GetDmenuSettings returns current dmenu settings. | ||
| 100 | func (s *ConfigService) GetDmenuSettings(ctx context.Context) (*config.DmenuConfig, error) { | 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 | // UpdateDmenuSettings updates dmenu configuration. | 112 | // UpdateDmenuSettings updates dmenu configuration. | ||
| 105 | func (s *ConfigService) UpdateDmenuSettings(ctx context.Context, settings *config.DmenuConfig) error { | 113 | func (s *ConfigService) UpdateDmenuSettings(ctx context.Context, settings *config.DmenuConfig) error { | ||
| 106 | - | if settings == nil { | 114 | + | _ = ctx |
| 107 | - | return fmt.Errorf("dmenu settings cannot be nil") | 115 | + | if settings == nil { |
| 108 | - | } | 116 | + | return fmt.Errorf("dmenu settings cannot be nil") |
| 117 | + | } | |||
| 109 | 118 | ||||
| 110 | s.config.Dmenu = *settings | 119 | s.config.Dmenu = *settings | ||
| 111 | return s.saveConfig() | 120 | return s.saveConfig() | ||
| @@ -113,20 +122,22 @@ func (s *ConfigService) UpdateDmenuSettings(ctx context.Context, settings *confi | |||||
| 113 | 122 | ||||
| 114 | // GetDatabasePath returns the current database path. | 123 | // GetDatabasePath returns the current database path. | ||
| 115 | func (s *ConfigService) GetDatabasePath(ctx context.Context) (string, error) { | 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 | // SetDatabasePath updates the database path. | 129 | // SetDatabasePath updates the database path. | ||
| 120 | func (s *ConfigService) SetDatabasePath(ctx context.Context, path string) error { | 130 | func (s *ConfigService) SetDatabasePath(ctx context.Context, path string) error { | ||
| 121 | - | if path == "" { | 131 | + | _ = ctx |
| 122 | - | return fmt.Errorf("database path cannot be empty") | 132 | + | if path == "" { |
| 123 | - | } | 133 | + | return fmt.Errorf("database path cannot be empty") |
| 134 | + | } | |||
| 124 | 135 | ||||
| 125 | // Validate that the directory exists or can be created | 136 | // Validate that the directory exists or can be created | ||
| 126 | dir := filepath.Dir(path) | 137 | dir := filepath.Dir(path) | ||
| 127 | - | if err := os.MkdirAll(dir, 0755); err != nil { | 138 | + | if err := os.MkdirAll(dir, defaultDirPerm); err != nil { |
| 128 | - | return fmt.Errorf("failed to create database directory: %w", err) | 139 | + | return fmt.Errorf("failed to create database directory: %w", err) |
| 129 | - | } | 140 | + | } |
| 130 | 141 | ||||
| 131 | s.config.Database.Path = path | 142 | s.config.Database.Path = path | ||
| 132 | return s.saveConfig() | 143 | return s.saveConfig() | ||
| @@ -134,23 +145,27 @@ func (s *ConfigService) SetDatabasePath(ctx context.Context, path string) error | |||||
| 134 | 145 | ||||
| 135 | // GetDefaultBrowser returns the default browser setting. | 146 | // GetDefaultBrowser returns the default browser setting. | ||
| 136 | func (s *ConfigService) GetDefaultBrowser(ctx context.Context) (string, error) { | 147 | func (s *ConfigService) GetDefaultBrowser(ctx context.Context) (string, error) { | ||
| 137 | - | // Default browser is now built into the app | 148 | + | _ = ctx |
| 138 | - | return "dumber (built-in)", nil | 149 | + | // Default browser is now built into the app |
| 150 | + | return "dumber (built-in)", nil | |||
| 139 | } | 151 | } | ||
| 140 | 152 | ||||
| 141 | // SetDefaultBrowser updates the default browser setting. | 153 | // SetDefaultBrowser updates the default browser setting. | ||
| 142 | func (s *ConfigService) SetDefaultBrowser(ctx context.Context, browser string) error { | 154 | func (s *ConfigService) SetDefaultBrowser(ctx context.Context, browser string) error { | ||
| 143 | - | // Default browser setting is not needed for built-in browser | 155 | + | _ = ctx |
| 144 | - | return fmt.Errorf("default browser is built-in and cannot be changed") | 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 | // ReloadConfig reloads configuration from file. | 161 | // ReloadConfig reloads configuration from file. | ||
| 148 | func (s *ConfigService) ReloadConfig(ctx context.Context) error { | 162 | func (s *ConfigService) ReloadConfig(ctx context.Context) error { | ||
| 149 | - | // Create new manager and load config | 163 | + | _ = ctx |
| 150 | - | manager, err := config.NewManager() | 164 | + | // Create new manager and load config |
| 151 | - | if err != nil { | 165 | + | manager, err := config.NewManager() |
| 152 | - | return fmt.Errorf("failed to create config manager: %w", err) | 166 | + | if err != nil { |
| 153 | - | } | 167 | + | return fmt.Errorf("failed to create config manager: %w", err) |
| 168 | + | } | |||
| 154 | 169 | ||||
| 155 | if err := manager.Load(); err != nil { | 170 | if err := manager.Load(); err != nil { | ||
| 156 | return fmt.Errorf("failed to load config: %w", err) | 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 | // ValidateConfig validates the current configuration. | 178 | // ValidateConfig validates the current configuration. | ||
| 164 | func (s *ConfigService) ValidateConfig(ctx context.Context) ([]string, error) { | 179 | func (s *ConfigService) ValidateConfig(ctx context.Context) ([]string, error) { | ||
| 165 | - | var errors []string | 180 | + | _ = ctx |
| 181 | + | var errors []string | |||
| 166 | 182 | ||||
| 167 | // Validate database path | 183 | // Validate database path | ||
| 168 | if s.config.Database.Path == "" { | 184 | if s.config.Database.Path == "" { | ||
| @@ -200,14 +216,16 @@ func (s *ConfigService) ValidateConfig(ctx context.Context) ([]string, error) { | |||||
| 200 | 216 | ||||
| 201 | // GetConfigPath returns the path to the configuration file. | 217 | // GetConfigPath returns the path to the configuration file. | ||
| 202 | func (s *ConfigService) GetConfigPath(ctx context.Context) (string, error) { | 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 | // ExportConfig returns the current configuration as JSON. | 223 | // ExportConfig returns the current configuration as JSON. | ||
| 207 | func (s *ConfigService) ExportConfig(ctx context.Context) (*config.Config, error) { | 224 | func (s *ConfigService) ExportConfig(ctx context.Context) (*config.Config, error) { | ||
| 208 | - | // Return a copy of the config to prevent modifications | 225 | + | _ = ctx |
| 209 | - | configCopy := *s.config | 226 | + | // Return a copy of the config to prevent modifications |
| 210 | - | return &configCopy, nil | 227 | + | configCopy := *s.config |
| 228 | + | return &configCopy, nil | |||
| 211 | } | 229 | } | ||
| 212 | 230 | ||||
| 213 | // ImportConfig imports configuration from a Config object. | 231 | // ImportConfig imports configuration from a Config object. | ||
| @@ -237,4 +255,4 @@ func (s *ConfigService) saveConfig() error { | |||||
| 237 | // For now, return success - implementing config saving would require | 255 | // For now, return success - implementing config saving would require | ||
| 238 | // extending the config package with save functionality | 256 | // extending the config package with save functionality | ||
| 239 | return nil | 257 | return nil | ||
| 240 | - | }⏎̸ | 258 | + | } |
M services/parser_service.go +41 -28
| @@ -10,7 +10,7 @@ import ( | |||||
| 10 | "github.com/bnema/dumber/internal/parser" | 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 | type ParserService struct { | 14 | type ParserService struct { | ||
| 15 | parser *parser.Parser | 15 | parser *parser.Parser | ||
| 16 | dbQueries *db.Queries | 16 | dbQueries *db.Queries | ||
| @@ -32,69 +32,81 @@ func NewParserService(cfg *config.Config, queries *db.Queries) *ParserService { | |||||
| 32 | 32 | ||||
| 33 | // ParseInput parses user input and returns navigation result. | 33 | // ParseInput parses user input and returns navigation result. | ||
| 34 | func (s *ParserService) ParseInput(ctx context.Context, input string) (*parser.ParseResult, error) { | 34 | func (s *ParserService) ParseInput(ctx context.Context, input string) (*parser.ParseResult, error) { | ||
| 35 | - | if input == "" { | 35 | + | _ = ctx |
| 36 | - | return nil, fmt.Errorf("input cannot be empty") | 36 | + | if input == "" { |
| 37 | - | } | 37 | + | return nil, fmt.Errorf("input cannot be empty") |
| 38 | + | } | |||
| 38 | 39 | ||||
| 39 | return s.parser.ParseInput(input) | 40 | return s.parser.ParseInput(input) | ||
| 40 | } | 41 | } | ||
| 41 | 42 | ||||
| 42 | // GetCompletions provides URL/search completions based on input. | 43 | // GetCompletions provides URL/search completions based on input. | ||
| 43 | func (s *ParserService) GetCompletions(ctx context.Context, input string, limit int) ([]string, error) { | 44 | func (s *ParserService) GetCompletions(ctx context.Context, input string, limit int) ([]string, error) { | ||
| 44 | - | if limit <= 0 { | 45 | + | _ = ctx |
| 45 | - | limit = 10 // Default limit | 46 | + | if limit <= 0 { |
| 46 | - | } | 47 | + | limit = 10 // Default limit |
| 48 | + | } | |||
| 47 | 49 | ||||
| 48 | return s.parser.SuggestCompletions(input, limit) | 50 | return s.parser.SuggestCompletions(input, limit) | ||
| 49 | } | 51 | } | ||
| 50 | 52 | ||||
| 51 | // FuzzySearchHistory performs fuzzy search on history entries. | 53 | // FuzzySearchHistory performs fuzzy search on history entries. | ||
| 52 | - | func (s *ParserService) FuzzySearchHistory(ctx context.Context, query string, threshold float64) ([]parser.FuzzyMatch, error) { | 54 | + | func (s *ParserService) FuzzySearchHistory( |
| 53 | - | if threshold <= 0 { | 55 | + | ctx context.Context, query string, threshold float64, |
| 54 | - | threshold = 0.3 // Default threshold | 56 | + | ) ([]parser.FuzzyMatch, error) { |
| 55 | - | } | 57 | + | _ = ctx |
| 58 | + | if threshold <= 0 { | |||
| 59 | + | threshold = 0.3 // Default threshold | |||
| 60 | + | } | |||
| 56 | 61 | ||||
| 57 | return s.parser.FuzzySearchHistory(query, threshold) | 62 | return s.parser.FuzzySearchHistory(query, threshold) | ||
| 58 | } | 63 | } | ||
| 59 | 64 | ||||
| 60 | // GetSupportedShortcuts returns configured search shortcuts. | 65 | // GetSupportedShortcuts returns configured search shortcuts. | ||
| 61 | func (s *ParserService) GetSupportedShortcuts(ctx context.Context) (map[string]config.SearchShortcut, error) { | 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 | // ProcessShortcut processes a shortcut and returns the resulting URL. | 71 | // ProcessShortcut processes a shortcut and returns the resulting URL. | ||
| 66 | func (s *ParserService) ProcessShortcut(ctx context.Context, shortcut, query string) (string, error) { | 72 | func (s *ParserService) ProcessShortcut(ctx context.Context, shortcut, query string) (string, error) { | ||
| 67 | - | shortcuts := s.parser.GetSupportedShortcuts() | 73 | + | _ = ctx |
| 68 | - | return s.parser.ProcessShortcut(shortcut, query, shortcuts) | 74 | + | shortcuts := s.parser.GetSupportedShortcuts() |
| 75 | + | return s.parser.ProcessShortcut(shortcut, query, shortcuts) | |||
| 69 | } | 76 | } | ||
| 70 | 77 | ||||
| 71 | // CalculateSimilarity calculates similarity between two strings. | 78 | // CalculateSimilarity calculates similarity between two strings. | ||
| 72 | func (s *ParserService) CalculateSimilarity(ctx context.Context, s1, s2 string) (float64, error) { | 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 | // ValidateURL checks if the input represents a valid URL. | 84 | // ValidateURL checks if the input represents a valid URL. | ||
| 77 | func (s *ParserService) ValidateURL(ctx context.Context, input string) (bool, error) { | 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 | // PreviewParse provides a preview of what ParseInput would return. | 90 | // PreviewParse provides a preview of what ParseInput would return. | ||
| 82 | func (s *ParserService) PreviewParse(ctx context.Context, input string) (*parser.ParseResult, error) { | 91 | func (s *ParserService) PreviewParse(ctx context.Context, input string) (*parser.ParseResult, error) { | ||
| 83 | - | if input == "" { | 92 | + | _ = ctx |
| 84 | - | return nil, fmt.Errorf("input cannot be empty") | 93 | + | if input == "" { |
| 85 | - | } | 94 | + | return nil, fmt.Errorf("input cannot be empty") |
| 86 | - | 95 | + | } | |
| 87 | - | return s.parser.PreviewParse(input) | 96 | + | |
| 97 | + | return s.parser.PreviewParse(input) | |||
| 88 | } | 98 | } | ||
| 89 | 99 | ||||
| 90 | // GetFuzzyConfig returns current fuzzy matching configuration. | 100 | // GetFuzzyConfig returns current fuzzy matching configuration. | ||
| 91 | func (s *ParserService) GetFuzzyConfig(ctx context.Context) (*parser.FuzzyConfig, error) { | 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 | // UpdateFuzzyConfig updates the fuzzy matching configuration. | 106 | // UpdateFuzzyConfig updates the fuzzy matching configuration. | ||
| 96 | func (s *ParserService) UpdateFuzzyConfig(ctx context.Context, config *parser.FuzzyConfig) error { | 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 | // DatabaseHistoryProvider implements HistoryProvider using the database. | 112 | // DatabaseHistoryProvider implements HistoryProvider using the database. | ||
| @@ -123,10 +135,11 @@ func (h *DatabaseHistoryProvider) GetRecentHistory(limit int) ([]*db.History, er | |||||
| 123 | 135 | ||||
| 124 | // GetAllHistory returns all history entries for fuzzy search. | 136 | // GetAllHistory returns all history entries for fuzzy search. | ||
| 125 | func (h *DatabaseHistoryProvider) GetAllHistory() ([]*db.History, error) { | 137 | func (h *DatabaseHistoryProvider) GetAllHistory() ([]*db.History, error) { | ||
| 126 | - | ctx := context.Background() | 138 | + | ctx := context.Background() |
| 127 | - | 139 | + | ||
| 128 | - | // Use a large limit to get all history | 140 | + | // Use a large limit to get all history |
| 129 | - | entries, err := h.queries.GetHistory(ctx, 10000) | 141 | + | const allHistoryLimit = 10000 |
| 142 | + | entries, err := h.queries.GetHistory(ctx, allHistoryLimit) | |||
| 130 | if err != nil { | 143 | if err != nil { | ||
| 131 | return nil, err | 144 | return nil, err | ||
| 132 | } | 145 | } | ||
| @@ -182,4 +195,4 @@ func (h *DatabaseHistoryProvider) GetHistoryByURL(url string) (*db.History, erro | |||||
| 182 | } | 195 | } | ||
| 183 | 196 | ||||
| 184 | return &entries[0], nil | 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 | "github.com/bnema/dumber/services" | 10 | "github.com/bnema/dumber/services" | ||
| 11 | "github.com/bnema/dumber/internal/config" | 11 | "github.com/bnema/dumber/internal/config" | ||
| 12 | mock_db "github.com/bnema/dumber/internal/db/mocks" | 12 | mock_db "github.com/bnema/dumber/internal/db/mocks" | ||
| 13 | - | "github.com/wailsapp/wails/v3/pkg/application" | |||
| 14 | "github.com/golang/mock/gomock" | 13 | "github.com/golang/mock/gomock" | ||
| 15 | ) | 14 | ) | ||
| 16 | 15 | ||||
| @@ -42,10 +41,9 @@ type MockWindowTitleUpdater struct { | |||||
| 42 | callCount int | 41 | callCount int | ||
| 43 | } | 42 | } | ||
| 44 | 43 | ||||
| 45 | - | func (m *MockWindowTitleUpdater) SetTitle(title string) application.Window { | 44 | + | func (m *MockWindowTitleUpdater) SetTitle(title string) { |
| 46 | m.lastTitle = title | 45 | m.lastTitle = title | ||
| 47 | m.callCount++ | 46 | m.callCount++ | ||
| 48 | - | return nil | |||
| 49 | } | 47 | } | ||
| 50 | 48 | ||||
| 51 | // Contract Test: BrowserService Zoom Controls | 49 | // Contract Test: BrowserService Zoom Controls | ||
| @@ -304,4 +302,4 @@ func TestBrowserService_ErrorHandling_Contract(t *testing.T) { | |||||
| 304 | t.Logf("GoBack returned error with nil config (may be expected): %v", err) | 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 | "github.com/bnema/dumber/internal/config" | 12 | "github.com/bnema/dumber/internal/config" | ||
| 13 | "github.com/bnema/dumber/internal/db" | 13 | "github.com/bnema/dumber/internal/db" | ||
| 14 | "github.com/bnema/dumber/services" | 14 | "github.com/bnema/dumber/services" | ||
| 15 | - | "github.com/wailsapp/wails/v3/pkg/application" | |||
| 16 | _ "github.com/ncruces/go-sqlite3/driver" // SQLite driver | 15 | _ "github.com/ncruces/go-sqlite3/driver" // SQLite driver | ||
| 17 | _ "github.com/ncruces/go-sqlite3/embed" // Embed SQLite | 16 | _ "github.com/ncruces/go-sqlite3/embed" // Embed SQLite | ||
| 18 | ) | 17 | ) | ||
| @@ -24,11 +23,10 @@ type MockWindowUpdater struct { | |||||
| 24 | } | 23 | } | ||
| 25 | 24 | ||||
| 26 | // Implement the WindowTitleUpdater interface method | 25 | // Implement the WindowTitleUpdater interface method | ||
| 27 | - | func (m *MockWindowUpdater) SetTitle(title string) application.Window { | 26 | + | func (m *MockWindowUpdater) SetTitle(title string) { |
| 28 | m.mutex.Lock() | 27 | m.mutex.Lock() | ||
| 29 | defer m.mutex.Unlock() | 28 | defer m.mutex.Unlock() | ||
| 30 | m.titles = append(m.titles, title) | 29 | m.titles = append(m.titles, title) | ||
| 31 | - | return nil | |||
| 32 | } | 30 | } | ||
| 33 | 31 | ||||
| 34 | // setupTestDB creates a temporary SQLite database for integration testing | 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 | // Create tables using the migration schema | 43 | // Create tables using the migration schema | ||
| 46 | - | createTablesSQL := ` | 44 | + | createTablesSQL := ` |
| 47 | - | CREATE TABLE IF NOT EXISTS history ( | 45 | + | CREATE TABLE IF NOT EXISTS history ( |
| 48 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, | 46 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, |
| 49 | - | url TEXT NOT NULL, | 47 | + | url TEXT NOT NULL, |
| 50 | - | title TEXT, | 48 | + | title TEXT, |
| 51 | - | visit_count INTEGER DEFAULT 1, | 49 | + | visit_count INTEGER DEFAULT 1, |
| 52 | - | last_visited DATETIME DEFAULT CURRENT_TIMESTAMP, | 50 | + | last_visited DATETIME DEFAULT CURRENT_TIMESTAMP, |
| 53 | - | created_at DATETIME DEFAULT CURRENT_TIMESTAMP | 51 | + | created_at DATETIME DEFAULT CURRENT_TIMESTAMP |
| 54 | - | ); | 52 | + | ); |
| 55 | - | 53 | + | ||
| 56 | - | CREATE TABLE IF NOT EXISTS shortcuts ( | 54 | + | CREATE TABLE IF NOT EXISTS shortcuts ( |
| 57 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, | 55 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, |
| 58 | - | shortcut TEXT NOT NULL UNIQUE, | 56 | + | shortcut TEXT NOT NULL UNIQUE, |
| 59 | - | url_template TEXT NOT NULL, | 57 | + | url_template TEXT NOT NULL, |
| 60 | - | description TEXT, | 58 | + | description TEXT, |
| 61 | - | created_at DATETIME DEFAULT CURRENT_TIMESTAMP | 59 | + | created_at DATETIME DEFAULT CURRENT_TIMESTAMP |
| 62 | - | ); | 60 | + | ); |
| 63 | - | 61 | + | ||
| 64 | - | CREATE TABLE IF NOT EXISTS zoom_settings ( | 62 | + | CREATE TABLE IF NOT EXISTS zoom_levels ( |
| 65 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, | 63 | + | domain TEXT NOT NULL UNIQUE, |
| 66 | - | url TEXT NOT NULL UNIQUE, | 64 | + | zoom_factor REAL NOT NULL DEFAULT 1.0, |
| 67 | - | zoom_level REAL NOT NULL DEFAULT 1.0, | 65 | + | updated_at DATETIME DEFAULT CURRENT_TIMESTAMP |
| 68 | - | last_updated DATETIME DEFAULT CURRENT_TIMESTAMP | 66 | + | ); |
| 69 | - | ); | 67 | + | ` |
| 70 | - | ` | |||
| 71 | 68 | ||||
| 72 | if _, err := database.Exec(createTablesSQL); err != nil { | 69 | if _, err := database.Exec(createTablesSQL); err != nil { | ||
| 73 | t.Fatalf("Failed to create test tables: %v", err) | 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 | // if zoom != expectedZoom { | 367 | // if zoom != expectedZoom { | ||
| 371 | // t.Errorf("Database zoom mismatch. Expected %f, got %f", expectedZoom, zoom) | 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 | - | }⏎̸ | |||