feat(browser): implement Ctrl+Shift+C URL copy with reusable toast system
- commit
- 79d72a808e3311bc6ef04dbb22d49f5125616501
- author
- Brice · · 2025-09-10 20:45 UTC
- parent
- 5fcbde5
- signature
- unsigned
9 files · +366 -18
M CHANGELOG.md +5 -0
| @@ -22,6 +22,11 @@ All notable changes to this project will be documented in this file. | |||
| 22 | 22 | - Browser data includes cookies, localStorage, sessionStorage | |
| 23 | 23 | - Confirmation prompt with `--force` flag to skip | |
| 24 | 24 | - Targets all data locations: SQLite database, WebKit cache/data, dmenu fuzzy cache, configuration files | |
| 25 | + | - Ctrl+Shift+C keyboard shortcut to copy current URL to clipboard: | |
| 26 | + | - Cross-platform clipboard support with wl-copy (Wayland) and xclip (X11) fallback | |
| 27 | + | - Reusable toast notification system for user feedback | |
| 28 | + | - Injectable JavaScript component for consistent styling across all websites | |
| 29 | + | - Native backend integration for reliable clipboard access | |
| 25 | 30 | ||
| 26 | 31 | ### Changed | |
| 27 | 32 | - Dmenu performance dramatically improved: 5 seconds → 4ms (99.92% faster) | |
| @@ -22,6 +22,11 @@ All notable changes to this project will be documented in this file. | |||||
| 22 | - Browser data includes cookies, localStorage, sessionStorage | 22 | - Browser data includes cookies, localStorage, sessionStorage | ||
| 23 | - Confirmation prompt with `--force` flag to skip | 23 | - Confirmation prompt with `--force` flag to skip | ||
| 24 | - Targets all data locations: SQLite database, WebKit cache/data, dmenu fuzzy cache, configuration files | 24 | - Targets all data locations: SQLite database, WebKit cache/data, dmenu fuzzy cache, configuration files | ||
| 25 | + | - Ctrl+Shift+C keyboard shortcut to copy current URL to clipboard: | |||
| 26 | + | - Cross-platform clipboard support with wl-copy (Wayland) and xclip (X11) fallback | |||
| 27 | + | - Reusable toast notification system for user feedback | |||
| 28 | + | - Injectable JavaScript component for consistent styling across all websites | |||
| 29 | + | - Native backend integration for reliable clipboard access | |||
| 25 | 30 | ||||
| 26 | ### Changed | 31 | ### Changed | ||
| 27 | - Dmenu performance dramatically improved: 5 seconds → 4ms (99.92% faster) | 32 | - Dmenu performance dramatically improved: 5 seconds → 4ms (99.92% faster) | ||
M README.md +1 -0
| @@ -39,6 +39,7 @@ Dumber is a minimalist browser and launcher companion focused on speed and simpl | |||
| 39 | 39 | | **Ctrl/Cmd++** | Zoom In | Alternative plus key | | |
| 40 | 40 | | **Ctrl/Cmd+-** | Zoom Out | Works across keyboard layouts | | |
| 41 | 41 | | **Ctrl/Cmd+0** | Reset Zoom | Return to 100% zoom | | |
| 42 | + | | **Ctrl/Cmd+Shift+C** | Copy URL | Copy current URL to clipboard with toast | | |
| 42 | 43 | | **Alt+←** | Navigate Back | Go to previous page | | |
| 43 | 44 | | **Alt+→** | Navigate Forward | Go to next page | | |
| 44 | 45 | ||
| @@ -39,6 +39,7 @@ Dumber is a minimalist browser and launcher companion focused on speed and simpl | |||||
| 39 | | **Ctrl/Cmd++** | Zoom In | Alternative plus key | | 39 | | **Ctrl/Cmd++** | Zoom In | Alternative plus key | | ||
| 40 | | **Ctrl/Cmd+-** | Zoom Out | Works across keyboard layouts | | 40 | | **Ctrl/Cmd+-** | Zoom Out | Works across keyboard layouts | | ||
| 41 | | **Ctrl/Cmd+0** | Reset Zoom | Return to 100% zoom | | 41 | | **Ctrl/Cmd+0** | Reset Zoom | Return to 100% zoom | | ||
| 42 | + | | **Ctrl/Cmd+Shift+C** | Copy URL | Copy current URL to clipboard with toast | | |||
| 42 | | **Alt+←** | Navigate Back | Go to previous page | | 43 | | **Alt+←** | Navigate Back | Go to previous page | | ||
| 43 | | **Alt+→** | Navigate Forward | Go to next page | | 44 | | **Alt+→** | Navigate Forward | Go to next page | | ||
| 44 | 45 | ||||
M frontend/src/injected-controls.ts +0 -17
| @@ -6,7 +6,6 @@ | |||
| 6 | 6 | * | |
| 7 | 7 | * Only includes essential controls that can work cross-origin: | |
| 8 | 8 | * - Navigation (back/forward) | |
| 9 | - | * - URL copying | |
| 10 | 9 | * - Mouse navigation buttons | |
| 11 | 10 | * | |
| 12 | 11 | * Zoom controls are handled natively by the GTK backend | |
| @@ -31,22 +30,6 @@ | |||
| 31 | 30 | const isCmd = ctrlKey || metaKey; | |
| 32 | 31 | ||
| 33 | 32 | try { | |
| 34 | - | // Copy URL (Cmd+Shift+C) | |
| 35 | - | if (isCmd && shiftKey && key === 'C') { | |
| 36 | - | event.preventDefault(); | |
| 37 | - | event.stopPropagation(); | |
| 38 | - | ||
| 39 | - | // Copy current URL to clipboard | |
| 40 | - | if (navigator.clipboard && navigator.clipboard.writeText) { | |
| 41 | - | navigator.clipboard.writeText(window.location.href) | |
| 42 | - | .then(() => console.log('🔗 URL copied:', window.location.href)) | |
| 43 | - | .catch((err) => console.error('❌ Failed to copy URL:', err)); | |
| 44 | - | } else { | |
| 45 | - | console.warn('⚠️ Clipboard API not available'); | |
| 46 | - | } | |
| 47 | - | return; | |
| 48 | - | } | |
| 49 | - | ||
| 50 | 33 | // Navigation: Alt + Left Arrow (Back) | |
| 51 | 34 | if (altKey && key === 'ArrowLeft') { | |
| 52 | 35 | event.preventDefault(); | |
| @@ -6,7 +6,6 @@ | |||||
| 6 | * | 6 | * | ||
| 7 | * Only includes essential controls that can work cross-origin: | 7 | * Only includes essential controls that can work cross-origin: | ||
| 8 | * - Navigation (back/forward) | 8 | * - Navigation (back/forward) | ||
| 9 | - | * - URL copying | |||
| 10 | * - Mouse navigation buttons | 9 | * - Mouse navigation buttons | ||
| 11 | * | 10 | * | ||
| 12 | * Zoom controls are handled natively by the GTK backend | 11 | * Zoom controls are handled natively by the GTK backend | ||
| @@ -31,22 +30,6 @@ | |||||
| 31 | const isCmd = ctrlKey || metaKey; | 30 | const isCmd = ctrlKey || metaKey; | ||
| 32 | 31 | ||||
| 33 | try { | 32 | try { | ||
| 34 | - | // Copy URL (Cmd+Shift+C) | |||
| 35 | - | if (isCmd && shiftKey && key === 'C') { | |||
| 36 | - | event.preventDefault(); | |||
| 37 | - | event.stopPropagation(); | |||
| 38 | - | ||||
| 39 | - | // Copy current URL to clipboard | |||
| 40 | - | if (navigator.clipboard && navigator.clipboard.writeText) { | |||
| 41 | - | navigator.clipboard.writeText(window.location.href) | |||
| 42 | - | .then(() => console.log('🔗 URL copied:', window.location.href)) | |||
| 43 | - | .catch((err) => console.error('❌ Failed to copy URL:', err)); | |||
| 44 | - | } else { | |||
| 45 | - | console.warn('⚠️ Clipboard API not available'); | |||
| 46 | - | } | |||
| 47 | - | return; | |||
| 48 | - | } | |||
| 49 | - | ||||
| 50 | // Navigation: Alt + Left Arrow (Back) | 33 | // Navigation: Alt + Left Arrow (Back) | ||
| 51 | if (altKey && key === 'ArrowLeft') { | 34 | if (altKey && key === 'ArrowLeft') { | ||
| 52 | event.preventDefault(); | 35 | event.preventDefault(); | ||
M main.go +19 -0
| @@ -20,6 +20,7 @@ import ( | |||
| 20 | 20 | "github.com/bnema/dumber/internal/cli" | |
| 21 | 21 | "github.com/bnema/dumber/internal/config" | |
| 22 | 22 | "github.com/bnema/dumber/internal/db" | |
| 23 | + | "github.com/bnema/dumber/pkg/clipboard" | |
| 23 | 24 | "github.com/bnema/dumber/pkg/webkit" | |
| 24 | 25 | "github.com/bnema/dumber/services" | |
| 25 | 26 | ) | |
| @@ -565,6 +566,24 @@ func runBrowser() { | |||
| 565 | 566 | log.Printf("Shortcut: Omnibox toggle") | |
| 566 | 567 | _ = view.InjectScript("window.__dumber_toggle && window.__dumber_toggle()") | |
| 567 | 568 | }) | |
| 569 | + | // Copy URL (Ctrl+Shift+C) | |
| 570 | + | _ = view.RegisterKeyboardShortcut("cmdorctrl+shift+c", func() { | |
| 571 | + | log.Printf("Shortcut: Copy URL") | |
| 572 | + | currentURL := view.GetCurrentURL() | |
| 573 | + | if currentURL == "" { | |
| 574 | + | log.Printf("No URL to copy") | |
| 575 | + | _ = view.InjectScript(`window.__dumber_showToast && window.__dumber_showToast("No URL to copy", 2000)`) | |
| 576 | + | return | |
| 577 | + | } | |
| 578 | + | ||
| 579 | + | if err := clipboard.CopyToClipboard(currentURL); err != nil { | |
| 580 | + | log.Printf("Failed to copy URL to clipboard: %v", err) | |
| 581 | + | _ = view.InjectScript(`window.__dumber_showToast && window.__dumber_showToast("Failed to copy URL", 2000)`) | |
| 582 | + | } else { | |
| 583 | + | log.Printf("URL copied to clipboard: %s", currentURL) | |
| 584 | + | _ = view.InjectScript(`window.__dumber_showToast && window.__dumber_showToast("URL copied to clipboard", 2000)`) | |
| 585 | + | } | |
| 586 | + | }) | |
| 568 | 587 | // Zoom handled natively in webkit package (built-in shortcuts) | |
| 569 | 588 | } | |
| 570 | 589 | ||
| @@ -20,6 +20,7 @@ import ( | |||||
| 20 | "github.com/bnema/dumber/internal/cli" | 20 | "github.com/bnema/dumber/internal/cli" | ||
| 21 | "github.com/bnema/dumber/internal/config" | 21 | "github.com/bnema/dumber/internal/config" | ||
| 22 | "github.com/bnema/dumber/internal/db" | 22 | "github.com/bnema/dumber/internal/db" | ||
| 23 | + | "github.com/bnema/dumber/pkg/clipboard" | |||
| 23 | "github.com/bnema/dumber/pkg/webkit" | 24 | "github.com/bnema/dumber/pkg/webkit" | ||
| 24 | "github.com/bnema/dumber/services" | 25 | "github.com/bnema/dumber/services" | ||
| 25 | ) | 26 | ) | ||
| @@ -565,6 +566,24 @@ func runBrowser() { | |||||
| 565 | log.Printf("Shortcut: Omnibox toggle") | 566 | log.Printf("Shortcut: Omnibox toggle") | ||
| 566 | _ = view.InjectScript("window.__dumber_toggle && window.__dumber_toggle()") | 567 | _ = view.InjectScript("window.__dumber_toggle && window.__dumber_toggle()") | ||
| 567 | }) | 568 | }) | ||
| 569 | + | // Copy URL (Ctrl+Shift+C) | |||
| 570 | + | _ = view.RegisterKeyboardShortcut("cmdorctrl+shift+c", func() { | |||
| 571 | + | log.Printf("Shortcut: Copy URL") | |||
| 572 | + | currentURL := view.GetCurrentURL() | |||
| 573 | + | if currentURL == "" { | |||
| 574 | + | log.Printf("No URL to copy") | |||
| 575 | + | _ = view.InjectScript(`window.__dumber_showToast && window.__dumber_showToast("No URL to copy", 2000)`) | |||
| 576 | + | return | |||
| 577 | + | } | |||
| 578 | + | ||||
| 579 | + | if err := clipboard.CopyToClipboard(currentURL); err != nil { | |||
| 580 | + | log.Printf("Failed to copy URL to clipboard: %v", err) | |||
| 581 | + | _ = view.InjectScript(`window.__dumber_showToast && window.__dumber_showToast("Failed to copy URL", 2000)`) | |||
| 582 | + | } else { | |||
| 583 | + | log.Printf("URL copied to clipboard: %s", currentURL) | |||
| 584 | + | _ = view.InjectScript(`window.__dumber_showToast && window.__dumber_showToast("URL copied to clipboard", 2000)`) | |||
| 585 | + | } | |||
| 586 | + | }) | |||
| 568 | // Zoom handled natively in webkit package (built-in shortcuts) | 587 | // Zoom handled natively in webkit package (built-in shortcuts) | ||
| 569 | } | 588 | } | ||
| 570 | 589 | ||||
A pkg/clipboard/clipboard.go +117 -0
| @@ -0,0 +1,117 @@ | |||
| 1 | + | package clipboard | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "os/exec" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // CopyToClipboard copies the given text to the system clipboard. | |
| 9 | + | // It tries wl-copy first (Wayland), then falls back to xclip (X11). | |
| 10 | + | // Returns an error if both fail. | |
| 11 | + | func CopyToClipboard(text string) error { | |
| 12 | + | if text == "" { | |
| 13 | + | return fmt.Errorf("cannot copy empty text to clipboard") | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | // Try wl-copy first (Wayland) | |
| 17 | + | if err := tryWlCopy(text); err == nil { | |
| 18 | + | return nil | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // Fallback to xclip (X11) | |
| 22 | + | if err := tryXclip(text); err == nil { | |
| 23 | + | return nil | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | return fmt.Errorf("clipboard copy failed: neither wl-copy nor xclip available") | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // tryWlCopy attempts to copy text using wl-copy (Wayland clipboard) | |
| 30 | + | func tryWlCopy(text string) error { | |
| 31 | + | cmd := exec.Command("wl-copy") | |
| 32 | + | cmd.Stdin = nil | |
| 33 | + | ||
| 34 | + | // Set up stdin pipe to pass the text | |
| 35 | + | stdin, err := cmd.StdinPipe() | |
| 36 | + | if err != nil { | |
| 37 | + | return fmt.Errorf("failed to create stdin pipe for wl-copy: %w", err) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // Start the command | |
| 41 | + | if err := cmd.Start(); err != nil { | |
| 42 | + | stdin.Close() | |
| 43 | + | return fmt.Errorf("failed to start wl-copy: %w", err) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // Write text to stdin and close it | |
| 47 | + | _, writeErr := stdin.Write([]byte(text)) | |
| 48 | + | closeErr := stdin.Close() | |
| 49 | + | ||
| 50 | + | // Wait for command to complete | |
| 51 | + | waitErr := cmd.Wait() | |
| 52 | + | ||
| 53 | + | // Return the first error encountered | |
| 54 | + | if writeErr != nil { | |
| 55 | + | return fmt.Errorf("failed to write to wl-copy stdin: %w", writeErr) | |
| 56 | + | } | |
| 57 | + | if closeErr != nil { | |
| 58 | + | return fmt.Errorf("failed to close wl-copy stdin: %w", closeErr) | |
| 59 | + | } | |
| 60 | + | if waitErr != nil { | |
| 61 | + | return fmt.Errorf("wl-copy command failed: %w", waitErr) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | return nil | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // tryXclip attempts to copy text using xclip (X11 clipboard) | |
| 68 | + | func tryXclip(text string) error { | |
| 69 | + | cmd := exec.Command("xclip", "-selection", "clipboard") | |
| 70 | + | ||
| 71 | + | // Set up stdin pipe to pass the text | |
| 72 | + | stdin, err := cmd.StdinPipe() | |
| 73 | + | if err != nil { | |
| 74 | + | return fmt.Errorf("failed to create stdin pipe for xclip: %w", err) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // Start the command | |
| 78 | + | if err := cmd.Start(); err != nil { | |
| 79 | + | stdin.Close() | |
| 80 | + | return fmt.Errorf("failed to start xclip: %w", err) | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // Write text to stdin and close it | |
| 84 | + | _, writeErr := stdin.Write([]byte(text)) | |
| 85 | + | closeErr := stdin.Close() | |
| 86 | + | ||
| 87 | + | // Wait for command to complete | |
| 88 | + | waitErr := cmd.Wait() | |
| 89 | + | ||
| 90 | + | // Return the first error encountered | |
| 91 | + | if writeErr != nil { | |
| 92 | + | return fmt.Errorf("failed to write to xclip stdin: %w", writeErr) | |
| 93 | + | } | |
| 94 | + | if closeErr != nil { | |
| 95 | + | return fmt.Errorf("failed to close xclip stdin: %w", closeErr) | |
| 96 | + | } | |
| 97 | + | if waitErr != nil { | |
| 98 | + | return fmt.Errorf("xclip command failed: %w", waitErr) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | return nil | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // IsAvailable checks if clipboard functionality is available on the system | |
| 105 | + | func IsAvailable() bool { | |
| 106 | + | // Check if wl-copy is available | |
| 107 | + | if _, err := exec.LookPath("wl-copy"); err == nil { | |
| 108 | + | return true | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | // Check if xclip is available | |
| 112 | + | if _, err := exec.LookPath("xclip"); err == nil { | |
| 113 | + | return true | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | return false | |
| 117 | + | }⏎̸ | |
| @@ -0,0 +1,117 @@ | |||||
| 1 | + | package clipboard | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "os/exec" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // CopyToClipboard copies the given text to the system clipboard. | |||
| 9 | + | // It tries wl-copy first (Wayland), then falls back to xclip (X11). | |||
| 10 | + | // Returns an error if both fail. | |||
| 11 | + | func CopyToClipboard(text string) error { | |||
| 12 | + | if text == "" { | |||
| 13 | + | return fmt.Errorf("cannot copy empty text to clipboard") | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | // Try wl-copy first (Wayland) | |||
| 17 | + | if err := tryWlCopy(text); err == nil { | |||
| 18 | + | return nil | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | // Fallback to xclip (X11) | |||
| 22 | + | if err := tryXclip(text); err == nil { | |||
| 23 | + | return nil | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | return fmt.Errorf("clipboard copy failed: neither wl-copy nor xclip available") | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // tryWlCopy attempts to copy text using wl-copy (Wayland clipboard) | |||
| 30 | + | func tryWlCopy(text string) error { | |||
| 31 | + | cmd := exec.Command("wl-copy") | |||
| 32 | + | cmd.Stdin = nil | |||
| 33 | + | ||||
| 34 | + | // Set up stdin pipe to pass the text | |||
| 35 | + | stdin, err := cmd.StdinPipe() | |||
| 36 | + | if err != nil { | |||
| 37 | + | return fmt.Errorf("failed to create stdin pipe for wl-copy: %w", err) | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // Start the command | |||
| 41 | + | if err := cmd.Start(); err != nil { | |||
| 42 | + | stdin.Close() | |||
| 43 | + | return fmt.Errorf("failed to start wl-copy: %w", err) | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // Write text to stdin and close it | |||
| 47 | + | _, writeErr := stdin.Write([]byte(text)) | |||
| 48 | + | closeErr := stdin.Close() | |||
| 49 | + | ||||
| 50 | + | // Wait for command to complete | |||
| 51 | + | waitErr := cmd.Wait() | |||
| 52 | + | ||||
| 53 | + | // Return the first error encountered | |||
| 54 | + | if writeErr != nil { | |||
| 55 | + | return fmt.Errorf("failed to write to wl-copy stdin: %w", writeErr) | |||
| 56 | + | } | |||
| 57 | + | if closeErr != nil { | |||
| 58 | + | return fmt.Errorf("failed to close wl-copy stdin: %w", closeErr) | |||
| 59 | + | } | |||
| 60 | + | if waitErr != nil { | |||
| 61 | + | return fmt.Errorf("wl-copy command failed: %w", waitErr) | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | return nil | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // tryXclip attempts to copy text using xclip (X11 clipboard) | |||
| 68 | + | func tryXclip(text string) error { | |||
| 69 | + | cmd := exec.Command("xclip", "-selection", "clipboard") | |||
| 70 | + | ||||
| 71 | + | // Set up stdin pipe to pass the text | |||
| 72 | + | stdin, err := cmd.StdinPipe() | |||
| 73 | + | if err != nil { | |||
| 74 | + | return fmt.Errorf("failed to create stdin pipe for xclip: %w", err) | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // Start the command | |||
| 78 | + | if err := cmd.Start(); err != nil { | |||
| 79 | + | stdin.Close() | |||
| 80 | + | return fmt.Errorf("failed to start xclip: %w", err) | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // Write text to stdin and close it | |||
| 84 | + | _, writeErr := stdin.Write([]byte(text)) | |||
| 85 | + | closeErr := stdin.Close() | |||
| 86 | + | ||||
| 87 | + | // Wait for command to complete | |||
| 88 | + | waitErr := cmd.Wait() | |||
| 89 | + | ||||
| 90 | + | // Return the first error encountered | |||
| 91 | + | if writeErr != nil { | |||
| 92 | + | return fmt.Errorf("failed to write to xclip stdin: %w", writeErr) | |||
| 93 | + | } | |||
| 94 | + | if closeErr != nil { | |||
| 95 | + | return fmt.Errorf("failed to close xclip stdin: %w", closeErr) | |||
| 96 | + | } | |||
| 97 | + | if waitErr != nil { | |||
| 98 | + | return fmt.Errorf("xclip command failed: %w", waitErr) | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | return nil | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | // IsAvailable checks if clipboard functionality is available on the system | |||
| 105 | + | func IsAvailable() bool { | |||
| 106 | + | // Check if wl-copy is available | |||
| 107 | + | if _, err := exec.LookPath("wl-copy"); err == nil { | |||
| 108 | + | return true | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | // Check if xclip is available | |||
| 112 | + | if _, err := exec.LookPath("xclip"); err == nil { | |||
| 113 | + | return true | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | return false | |||
| 117 | + | }⏎̸ | |||
A pkg/clipboard/clipboard_test.go +71 -0
| @@ -0,0 +1,71 @@ | |||
| 1 | + | package clipboard | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os/exec" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestCopyToClipboard(t *testing.T) { | |
| 9 | + | // Check if either wl-copy or xclip is available | |
| 10 | + | if !IsAvailable() { | |
| 11 | + | t.Skip("Neither wl-copy nor xclip is available, skipping clipboard tests") | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | testText := "https://example.com/test-url" | |
| 15 | + | ||
| 16 | + | err := CopyToClipboard(testText) | |
| 17 | + | if err != nil { | |
| 18 | + | t.Errorf("CopyToClipboard failed: %v", err) | |
| 19 | + | } | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func TestCopyToClipboard_EmptyString(t *testing.T) { | |
| 23 | + | err := CopyToClipboard("") | |
| 24 | + | if err == nil { | |
| 25 | + | t.Error("Expected error when copying empty string, got nil") | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func TestIsAvailable(t *testing.T) { | |
| 30 | + | available := IsAvailable() | |
| 31 | + | ||
| 32 | + | // Check if the result matches the actual availability | |
| 33 | + | wlCopyExists := false | |
| 34 | + | xclipExists := false | |
| 35 | + | ||
| 36 | + | if _, err := exec.LookPath("wl-copy"); err == nil { | |
| 37 | + | wlCopyExists = true | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | if _, err := exec.LookPath("xclip"); err == nil { | |
| 41 | + | xclipExists = true | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | expected := wlCopyExists || xclipExists | |
| 45 | + | ||
| 46 | + | if available != expected { | |
| 47 | + | t.Errorf("IsAvailable() = %v, expected %v", available, expected) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func TestTryWlCopy(t *testing.T) { | |
| 52 | + | if _, err := exec.LookPath("wl-copy"); err != nil { | |
| 53 | + | t.Skip("wl-copy not available, skipping test") | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | err := tryWlCopy("test content") | |
| 57 | + | if err != nil { | |
| 58 | + | t.Errorf("tryWlCopy failed: %v", err) | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func TestTryXclip(t *testing.T) { | |
| 63 | + | if _, err := exec.LookPath("xclip"); err != nil { | |
| 64 | + | t.Skip("xclip not available, skipping test") | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | err := tryXclip("test content") | |
| 68 | + | if err != nil { | |
| 69 | + | t.Errorf("tryXclip failed: %v", err) | |
| 70 | + | } | |
| 71 | + | }⏎̸ | |
| @@ -0,0 +1,71 @@ | |||||
| 1 | + | package clipboard | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os/exec" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestCopyToClipboard(t *testing.T) { | |||
| 9 | + | // Check if either wl-copy or xclip is available | |||
| 10 | + | if !IsAvailable() { | |||
| 11 | + | t.Skip("Neither wl-copy nor xclip is available, skipping clipboard tests") | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | testText := "https://example.com/test-url" | |||
| 15 | + | ||||
| 16 | + | err := CopyToClipboard(testText) | |||
| 17 | + | if err != nil { | |||
| 18 | + | t.Errorf("CopyToClipboard failed: %v", err) | |||
| 19 | + | } | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func TestCopyToClipboard_EmptyString(t *testing.T) { | |||
| 23 | + | err := CopyToClipboard("") | |||
| 24 | + | if err == nil { | |||
| 25 | + | t.Error("Expected error when copying empty string, got nil") | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func TestIsAvailable(t *testing.T) { | |||
| 30 | + | available := IsAvailable() | |||
| 31 | + | ||||
| 32 | + | // Check if the result matches the actual availability | |||
| 33 | + | wlCopyExists := false | |||
| 34 | + | xclipExists := false | |||
| 35 | + | ||||
| 36 | + | if _, err := exec.LookPath("wl-copy"); err == nil { | |||
| 37 | + | wlCopyExists = true | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | if _, err := exec.LookPath("xclip"); err == nil { | |||
| 41 | + | xclipExists = true | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | expected := wlCopyExists || xclipExists | |||
| 45 | + | ||||
| 46 | + | if available != expected { | |||
| 47 | + | t.Errorf("IsAvailable() = %v, expected %v", available, expected) | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func TestTryWlCopy(t *testing.T) { | |||
| 52 | + | if _, err := exec.LookPath("wl-copy"); err != nil { | |||
| 53 | + | t.Skip("wl-copy not available, skipping test") | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | err := tryWlCopy("test content") | |||
| 57 | + | if err != nil { | |||
| 58 | + | t.Errorf("tryWlCopy failed: %v", err) | |||
| 59 | + | } | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func TestTryXclip(t *testing.T) { | |||
| 63 | + | if _, err := exec.LookPath("xclip"); err != nil { | |||
| 64 | + | t.Skip("xclip not available, skipping test") | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | err := tryXclip("test content") | |||
| 68 | + | if err != nil { | |||
| 69 | + | t.Errorf("tryXclip failed: %v", err) | |||
| 70 | + | } | |||
| 71 | + | }⏎̸ | |||
M pkg/webkit/keyboard_cgo.go +7 -1
| @@ -127,6 +127,7 @@ func goOnKeyPress(id C.ulong, keyval C.guint, state C.GdkModifierType) { | |||
| 127 | 127 | func dispatchAccelerator(uid uintptr, keyval uint, state uint) { | |
| 128 | 128 | ctrl := (state & uint(C.GDK_CONTROL_MASK)) != 0 | |
| 129 | 129 | alt := (state & uint(C.GDK_ALT_MASK)) != 0 | |
| 130 | + | shift := (state & uint(C.GDK_SHIFT_MASK)) != 0 | |
| 130 | 131 | // Map keyval to string names | |
| 131 | 132 | var keyName string | |
| 132 | 133 | switch keyval { | |
| @@ -144,6 +145,8 @@ func dispatchAccelerator(uid uintptr, keyval uint, state uint) { | |||
| 144 | 145 | keyName = "ArrowRight" | |
| 145 | 146 | case uint(C.GDK_KEY_F12): | |
| 146 | 147 | keyName = "F12" | |
| 148 | + | case uint(C.GDK_KEY_c), uint(C.GDK_KEY_C): | |
| 149 | + | keyName = "c" | |
| 147 | 150 | default: | |
| 148 | 151 | // If Control held, log unknown keyval for diagnostics | |
| 149 | 152 | if ctrl { | |
| @@ -154,7 +157,10 @@ func dispatchAccelerator(uid uintptr, keyval uint, state uint) { | |||
| 154 | 157 | ||
| 155 | 158 | // Candidate accelerator strings in order | |
| 156 | 159 | candidates := []string{keyName} | |
| 157 | - | if ctrl { | |
| 160 | + | if ctrl && shift { | |
| 161 | + | // Ctrl+Shift combinations | |
| 162 | + | candidates = append([]string{"cmdorctrl+shift+" + keyName, "ctrl+shift+" + keyName}, candidates...) | |
| 163 | + | } else if ctrl { | |
| 158 | 164 | // Common style: modifier + '+' + keyName (e.g., cmdorctrl+=) | |
| 159 | 165 | candidates = append([]string{"cmdorctrl+" + keyName, "ctrl+" + keyName}, candidates...) | |
| 160 | 166 | // Also accept style without the extra '+' for punctuation keys (e.g., cmdorctrl-) | |
| @@ -127,6 +127,7 @@ func goOnKeyPress(id C.ulong, keyval C.guint, state C.GdkModifierType) { | |||||
| 127 | func dispatchAccelerator(uid uintptr, keyval uint, state uint) { | 127 | func dispatchAccelerator(uid uintptr, keyval uint, state uint) { | ||
| 128 | ctrl := (state & uint(C.GDK_CONTROL_MASK)) != 0 | 128 | ctrl := (state & uint(C.GDK_CONTROL_MASK)) != 0 | ||
| 129 | alt := (state & uint(C.GDK_ALT_MASK)) != 0 | 129 | alt := (state & uint(C.GDK_ALT_MASK)) != 0 | ||
| 130 | + | shift := (state & uint(C.GDK_SHIFT_MASK)) != 0 | |||
| 130 | // Map keyval to string names | 131 | // Map keyval to string names | ||
| 131 | var keyName string | 132 | var keyName string | ||
| 132 | switch keyval { | 133 | switch keyval { | ||
| @@ -144,6 +145,8 @@ func dispatchAccelerator(uid uintptr, keyval uint, state uint) { | |||||
| 144 | keyName = "ArrowRight" | 145 | keyName = "ArrowRight" | ||
| 145 | case uint(C.GDK_KEY_F12): | 146 | case uint(C.GDK_KEY_F12): | ||
| 146 | keyName = "F12" | 147 | keyName = "F12" | ||
| 148 | + | case uint(C.GDK_KEY_c), uint(C.GDK_KEY_C): | |||
| 149 | + | keyName = "c" | |||
| 147 | default: | 150 | default: | ||
| 148 | // If Control held, log unknown keyval for diagnostics | 151 | // If Control held, log unknown keyval for diagnostics | ||
| 149 | if ctrl { | 152 | if ctrl { | ||
| @@ -154,7 +157,10 @@ func dispatchAccelerator(uid uintptr, keyval uint, state uint) { | |||||
| 154 | 157 | ||||
| 155 | // Candidate accelerator strings in order | 158 | // Candidate accelerator strings in order | ||
| 156 | candidates := []string{keyName} | 159 | candidates := []string{keyName} | ||
| 157 | - | if ctrl { | 160 | + | if ctrl && shift { |
| 161 | + | // Ctrl+Shift combinations | |||
| 162 | + | candidates = append([]string{"cmdorctrl+shift+" + keyName, "ctrl+shift+" + keyName}, candidates...) | |||
| 163 | + | } else if ctrl { | |||
| 158 | // Common style: modifier + '+' + keyName (e.g., cmdorctrl+=) | 164 | // Common style: modifier + '+' + keyName (e.g., cmdorctrl+=) | ||
| 159 | candidates = append([]string{"cmdorctrl+" + keyName, "ctrl+" + keyName}, candidates...) | 165 | candidates = append([]string{"cmdorctrl+" + keyName, "ctrl+" + keyName}, candidates...) | ||
| 160 | // Also accept style without the extra '+' for punctuation keys (e.g., cmdorctrl-) | 166 | // Also accept style without the extra '+' for punctuation keys (e.g., cmdorctrl-) | ||
A pkg/webkit/toast_js_cgo.go +136 -0
| @@ -0,0 +1,136 @@ | |||
| 1 | + | //go:build webkit_cgo | |
| 2 | + | ||
| 3 | + | package webkit | |
| 4 | + | ||
| 5 | + | // getToastScript returns the injected toast notification component script. | |
| 6 | + | // Provides a reusable toast system that can display messages across all pages. | |
| 7 | + | func getToastScript() string { | |
| 8 | + | return `(() => { | |
| 9 | + | try { | |
| 10 | + | if (window.__dumber_toast_loaded) return; // idempotent | |
| 11 | + | window.__dumber_toast_loaded = true; | |
| 12 | + | ||
| 13 | + | const T = { | |
| 14 | + | container: null, | |
| 15 | + | toasts: [], // {id, element, timeout} | |
| 16 | + | counter: 0, | |
| 17 | + | ||
| 18 | + | init() { | |
| 19 | + | if (T.container) return; | |
| 20 | + | ||
| 21 | + | // Create toast container | |
| 22 | + | const container = document.createElement('div'); | |
| 23 | + | container.id = 'dumber-toast-container'; | |
| 24 | + | container.style.cssText = 'position:fixed;bottom:20px;right:20px;z-index:2147483646;pointer-events:none;'; | |
| 25 | + | document.documentElement.appendChild(container); | |
| 26 | + | T.container = container; | |
| 27 | + | ||
| 28 | + | // Add CSS styles | |
| 29 | + | const style = document.createElement('style'); | |
| 30 | + | style.textContent = ` + | |
| 31 | + | "`" + ` | |
| 32 | + | .dumber-toast { | |
| 33 | + | background: rgba(18, 18, 18, 0.95); | |
| 34 | + | color: #eee; | |
| 35 | + | padding: 12px 16px; | |
| 36 | + | border-radius: 8px; | |
| 37 | + | border: 1px solid #444; | |
| 38 | + | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); | |
| 39 | + | font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, "Helvetica Neue", Arial, sans-serif; | |
| 40 | + | font-size: 14px; | |
| 41 | + | line-height: 1.4; | |
| 42 | + | margin-bottom: 8px; | |
| 43 | + | max-width: 320px; | |
| 44 | + | word-wrap: break-word; | |
| 45 | + | backdrop-filter: blur(8px); | |
| 46 | + | -webkit-backdrop-filter: blur(8px); | |
| 47 | + | pointer-events: auto; | |
| 48 | + | cursor: default; | |
| 49 | + | transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); | |
| 50 | + | transform: translateX(100%); | |
| 51 | + | opacity: 0; | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | .dumber-toast.show { | |
| 55 | + | transform: translateX(0); | |
| 56 | + | opacity: 1; | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | .dumber-toast:hover { | |
| 60 | + | background: rgba(27, 27, 27, 0.95); | |
| 61 | + | border-color: #555; | |
| 62 | + | } | |
| 63 | + | ` + "`" + `; | |
| 64 | + | document.documentElement.appendChild(style); | |
| 65 | + | }, | |
| 66 | + | ||
| 67 | + | show(message, duration = 2500) { | |
| 68 | + | T.init(); | |
| 69 | + | ||
| 70 | + | const id = ++T.counter; | |
| 71 | + | const toast = document.createElement('div'); | |
| 72 | + | toast.className = 'dumber-toast'; | |
| 73 | + | toast.textContent = message || 'Notification'; | |
| 74 | + | toast.setAttribute('data-toast-id', id); | |
| 75 | + | ||
| 76 | + | // Click to dismiss | |
| 77 | + | toast.addEventListener('click', () => T.dismiss(id)); | |
| 78 | + | ||
| 79 | + | T.container.appendChild(toast); | |
| 80 | + | ||
| 81 | + | // Trigger show animation | |
| 82 | + | requestAnimationFrame(() => { | |
| 83 | + | toast.classList.add('show'); | |
| 84 | + | }); | |
| 85 | + | ||
| 86 | + | // Auto-dismiss | |
| 87 | + | const timeout = setTimeout(() => T.dismiss(id), duration); | |
| 88 | + | ||
| 89 | + | T.toasts.push({ id, element: toast, timeout }); | |
| 90 | + | ||
| 91 | + | console.log('📋 Toast:', message); | |
| 92 | + | return id; | |
| 93 | + | }, | |
| 94 | + | ||
| 95 | + | dismiss(id) { | |
| 96 | + | const toastIndex = T.toasts.findIndex(t => t.id === id); | |
| 97 | + | if (toastIndex === -1) return; | |
| 98 | + | ||
| 99 | + | const toast = T.toasts[toastIndex]; | |
| 100 | + | clearTimeout(toast.timeout); | |
| 101 | + | ||
| 102 | + | // Animate out | |
| 103 | + | toast.element.style.transform = 'translateX(100%)'; | |
| 104 | + | toast.element.style.opacity = '0'; | |
| 105 | + | ||
| 106 | + | setTimeout(() => { | |
| 107 | + | if (toast.element && toast.element.parentNode) { | |
| 108 | + | toast.element.parentNode.removeChild(toast.element); | |
| 109 | + | } | |
| 110 | + | T.toasts.splice(toastIndex, 1); | |
| 111 | + | }, 300); | |
| 112 | + | }, | |
| 113 | + | ||
| 114 | + | clear() { | |
| 115 | + | T.toasts.forEach(toast => { | |
| 116 | + | clearTimeout(toast.timeout); | |
| 117 | + | if (toast.element && toast.element.parentNode) { | |
| 118 | + | toast.element.parentNode.removeChild(toast.element); | |
| 119 | + | } | |
| 120 | + | }); | |
| 121 | + | T.toasts = []; | |
| 122 | + | } | |
| 123 | + | }; | |
| 124 | + | ||
| 125 | + | // Public API for native integration | |
| 126 | + | window.__dumber_showToast = (message, duration) => T.show(message, duration); | |
| 127 | + | window.__dumber_dismissToast = (id) => T.dismiss(id); | |
| 128 | + | window.__dumber_clearToasts = () => T.clear(); | |
| 129 | + | ||
| 130 | + | console.log('✅ Dumber Browser toast system loaded'); | |
| 131 | + | ||
| 132 | + | } catch (e) { | |
| 133 | + | console.error('❌ Failed to initialize Dumber toast system:', e); | |
| 134 | + | } | |
| 135 | + | })();` | |
| 136 | + | }⏎̸ | |
| @@ -0,0 +1,136 @@ | |||||
| 1 | + | //go:build webkit_cgo | |||
| 2 | + | ||||
| 3 | + | package webkit | |||
| 4 | + | ||||
| 5 | + | // getToastScript returns the injected toast notification component script. | |||
| 6 | + | // Provides a reusable toast system that can display messages across all pages. | |||
| 7 | + | func getToastScript() string { | |||
| 8 | + | return `(() => { | |||
| 9 | + | try { | |||
| 10 | + | if (window.__dumber_toast_loaded) return; // idempotent | |||
| 11 | + | window.__dumber_toast_loaded = true; | |||
| 12 | + | ||||
| 13 | + | const T = { | |||
| 14 | + | container: null, | |||
| 15 | + | toasts: [], // {id, element, timeout} | |||
| 16 | + | counter: 0, | |||
| 17 | + | ||||
| 18 | + | init() { | |||
| 19 | + | if (T.container) return; | |||
| 20 | + | ||||
| 21 | + | // Create toast container | |||
| 22 | + | const container = document.createElement('div'); | |||
| 23 | + | container.id = 'dumber-toast-container'; | |||
| 24 | + | container.style.cssText = 'position:fixed;bottom:20px;right:20px;z-index:2147483646;pointer-events:none;'; | |||
| 25 | + | document.documentElement.appendChild(container); | |||
| 26 | + | T.container = container; | |||
| 27 | + | ||||
| 28 | + | // Add CSS styles | |||
| 29 | + | const style = document.createElement('style'); | |||
| 30 | + | style.textContent = ` + | |||
| 31 | + | "`" + ` | |||
| 32 | + | .dumber-toast { | |||
| 33 | + | background: rgba(18, 18, 18, 0.95); | |||
| 34 | + | color: #eee; | |||
| 35 | + | padding: 12px 16px; | |||
| 36 | + | border-radius: 8px; | |||
| 37 | + | border: 1px solid #444; | |||
| 38 | + | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); | |||
| 39 | + | font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, "Helvetica Neue", Arial, sans-serif; | |||
| 40 | + | font-size: 14px; | |||
| 41 | + | line-height: 1.4; | |||
| 42 | + | margin-bottom: 8px; | |||
| 43 | + | max-width: 320px; | |||
| 44 | + | word-wrap: break-word; | |||
| 45 | + | backdrop-filter: blur(8px); | |||
| 46 | + | -webkit-backdrop-filter: blur(8px); | |||
| 47 | + | pointer-events: auto; | |||
| 48 | + | cursor: default; | |||
| 49 | + | transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); | |||
| 50 | + | transform: translateX(100%); | |||
| 51 | + | opacity: 0; | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | .dumber-toast.show { | |||
| 55 | + | transform: translateX(0); | |||
| 56 | + | opacity: 1; | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | .dumber-toast:hover { | |||
| 60 | + | background: rgba(27, 27, 27, 0.95); | |||
| 61 | + | border-color: #555; | |||
| 62 | + | } | |||
| 63 | + | ` + "`" + `; | |||
| 64 | + | document.documentElement.appendChild(style); | |||
| 65 | + | }, | |||
| 66 | + | ||||
| 67 | + | show(message, duration = 2500) { | |||
| 68 | + | T.init(); | |||
| 69 | + | ||||
| 70 | + | const id = ++T.counter; | |||
| 71 | + | const toast = document.createElement('div'); | |||
| 72 | + | toast.className = 'dumber-toast'; | |||
| 73 | + | toast.textContent = message || 'Notification'; | |||
| 74 | + | toast.setAttribute('data-toast-id', id); | |||
| 75 | + | ||||
| 76 | + | // Click to dismiss | |||
| 77 | + | toast.addEventListener('click', () => T.dismiss(id)); | |||
| 78 | + | ||||
| 79 | + | T.container.appendChild(toast); | |||
| 80 | + | ||||
| 81 | + | // Trigger show animation | |||
| 82 | + | requestAnimationFrame(() => { | |||
| 83 | + | toast.classList.add('show'); | |||
| 84 | + | }); | |||
| 85 | + | ||||
| 86 | + | // Auto-dismiss | |||
| 87 | + | const timeout = setTimeout(() => T.dismiss(id), duration); | |||
| 88 | + | ||||
| 89 | + | T.toasts.push({ id, element: toast, timeout }); | |||
| 90 | + | ||||
| 91 | + | console.log('📋 Toast:', message); | |||
| 92 | + | return id; | |||
| 93 | + | }, | |||
| 94 | + | ||||
| 95 | + | dismiss(id) { | |||
| 96 | + | const toastIndex = T.toasts.findIndex(t => t.id === id); | |||
| 97 | + | if (toastIndex === -1) return; | |||
| 98 | + | ||||
| 99 | + | const toast = T.toasts[toastIndex]; | |||
| 100 | + | clearTimeout(toast.timeout); | |||
| 101 | + | ||||
| 102 | + | // Animate out | |||
| 103 | + | toast.element.style.transform = 'translateX(100%)'; | |||
| 104 | + | toast.element.style.opacity = '0'; | |||
| 105 | + | ||||
| 106 | + | setTimeout(() => { | |||
| 107 | + | if (toast.element && toast.element.parentNode) { | |||
| 108 | + | toast.element.parentNode.removeChild(toast.element); | |||
| 109 | + | } | |||
| 110 | + | T.toasts.splice(toastIndex, 1); | |||
| 111 | + | }, 300); | |||
| 112 | + | }, | |||
| 113 | + | ||||
| 114 | + | clear() { | |||
| 115 | + | T.toasts.forEach(toast => { | |||
| 116 | + | clearTimeout(toast.timeout); | |||
| 117 | + | if (toast.element && toast.element.parentNode) { | |||
| 118 | + | toast.element.parentNode.removeChild(toast.element); | |||
| 119 | + | } | |||
| 120 | + | }); | |||
| 121 | + | T.toasts = []; | |||
| 122 | + | } | |||
| 123 | + | }; | |||
| 124 | + | ||||
| 125 | + | // Public API for native integration | |||
| 126 | + | window.__dumber_showToast = (message, duration) => T.show(message, duration); | |||
| 127 | + | window.__dumber_dismissToast = (id) => T.dismiss(id); | |||
| 128 | + | window.__dumber_clearToasts = () => T.clear(); | |||
| 129 | + | ||||
| 130 | + | console.log('✅ Dumber Browser toast system loaded'); | |||
| 131 | + | ||||
| 132 | + | } catch (e) { | |||
| 133 | + | console.error('❌ Failed to initialize Dumber toast system:', e); | |||
| 134 | + | } | |||
| 135 | + | })();` | |||
| 136 | + | }⏎̸ | |||
M pkg/webkit/webview_cgo.go +10 -0
| @@ -869,6 +869,16 @@ func (w *WebView) enableUserContentManager() { | |||
| 869 | 869 | log.Printf("[webkit] UCM omnibox script injected at document-start") | |
| 870 | 870 | } | |
| 871 | 871 | ||
| 872 | + | // Add user script at document-start (toast notification system) | |
| 873 | + | toastSrc := C.CString(getToastScript()) | |
| 874 | + | defer C.free(unsafe.Pointer(toastSrc)) | |
| 875 | + | toastScript := C.webkit_user_script_new((*C.gchar)(toastSrc), C.WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES, C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, nil, nil) | |
| 876 | + | if toastScript != nil { | |
| 877 | + | C.webkit_user_content_manager_add_script(w.native.ucm, toastScript) | |
| 878 | + | C.webkit_user_script_unref(toastScript) | |
| 879 | + | log.Printf("[webkit] UCM toast script injected at document-start") | |
| 880 | + | } | |
| 881 | + | ||
| 872 | 882 | // Inject Wails runtime fetch interceptor for homepage bridging | |
| 873 | 883 | 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(_){} })();` | |
| 874 | 884 | cWails := C.CString(wailsBridge) | |
| @@ -869,6 +869,16 @@ func (w *WebView) enableUserContentManager() { | |||||
| 869 | log.Printf("[webkit] UCM omnibox script injected at document-start") | 869 | log.Printf("[webkit] UCM omnibox script injected at document-start") | ||
| 870 | } | 870 | } | ||
| 871 | 871 | ||||
| 872 | + | // Add user script at document-start (toast notification system) | |||
| 873 | + | toastSrc := C.CString(getToastScript()) | |||
| 874 | + | defer C.free(unsafe.Pointer(toastSrc)) | |||
| 875 | + | toastScript := C.webkit_user_script_new((*C.gchar)(toastSrc), C.WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES, C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, nil, nil) | |||
| 876 | + | if toastScript != nil { | |||
| 877 | + | C.webkit_user_content_manager_add_script(w.native.ucm, toastScript) | |||
| 878 | + | C.webkit_user_script_unref(toastScript) | |||
| 879 | + | log.Printf("[webkit] UCM toast script injected at document-start") | |||
| 880 | + | } | |||
| 881 | + | ||||
| 872 | // Inject Wails runtime fetch interceptor for homepage bridging | 882 | // Inject Wails runtime fetch interceptor for homepage bridging | ||
| 873 | 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(_){} })();` | 883 | 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(_){} })();` | ||
| 874 | cWails := C.CString(wailsBridge) | 884 | cWails := C.CString(wailsBridge) | ||