1120 lines
32 KiB
Go
1120 lines
32 KiB
Go
|
|
// Package webview provides WebView interaction capabilities for the MCP server.
|
||
|
|
// It enables JavaScript execution, console capture, screenshots, and DOM interaction
|
||
|
|
// in running Wails windows.
|
||
|
|
package webview
|
||
|
|
|
||
|
|
import (
|
||
|
|
"encoding/base64"
|
||
|
|
"encoding/json"
|
||
|
|
"fmt"
|
||
|
|
"sync"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/wailsapp/wails/v3/pkg/application"
|
||
|
|
)
|
||
|
|
|
||
|
|
// ConsoleMessage represents a captured console message.
|
||
|
|
type ConsoleMessage struct {
|
||
|
|
Level string `json:"level"`
|
||
|
|
Message string `json:"message"`
|
||
|
|
Timestamp time.Time `json:"timestamp"`
|
||
|
|
Source string `json:"source,omitempty"`
|
||
|
|
Line int `json:"line,omitempty"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// Service provides WebView interaction capabilities.
|
||
|
|
type Service struct {
|
||
|
|
app *application.App
|
||
|
|
consoleBuffer []ConsoleMessage
|
||
|
|
consoleMu sync.RWMutex
|
||
|
|
maxConsoleSize int
|
||
|
|
onConsole func(ConsoleMessage)
|
||
|
|
}
|
||
|
|
|
||
|
|
// New creates a new WebView service.
|
||
|
|
func New() *Service {
|
||
|
|
return &Service{
|
||
|
|
consoleBuffer: make([]ConsoleMessage, 0, 1000),
|
||
|
|
maxConsoleSize: 1000,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// SetApp sets the Wails application reference.
|
||
|
|
// This must be called after the app is initialized.
|
||
|
|
func (s *Service) SetApp(app *application.App) {
|
||
|
|
s.app = app
|
||
|
|
}
|
||
|
|
|
||
|
|
// OnConsole sets a callback for console messages.
|
||
|
|
func (s *Service) OnConsole(cb func(ConsoleMessage)) {
|
||
|
|
s.onConsole = cb
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetWindow returns a window by name, or the first window if name is empty.
|
||
|
|
func (s *Service) GetWindow(name string) *application.WebviewWindow {
|
||
|
|
if s.app == nil {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
windows := s.app.Window.GetAll()
|
||
|
|
if len(windows) == 0 {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
if name == "" {
|
||
|
|
// Return first WebviewWindow
|
||
|
|
for _, w := range windows {
|
||
|
|
if wv, ok := w.(*application.WebviewWindow); ok {
|
||
|
|
return wv
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Find by name
|
||
|
|
for _, w := range windows {
|
||
|
|
if wv, ok := w.(*application.WebviewWindow); ok {
|
||
|
|
if wv.Name() == name {
|
||
|
|
return wv
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// ListWindows returns info about all open windows.
|
||
|
|
func (s *Service) ListWindows() []WindowInfo {
|
||
|
|
if s.app == nil {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
windows := s.app.Window.GetAll()
|
||
|
|
result := make([]WindowInfo, 0, len(windows))
|
||
|
|
|
||
|
|
for _, w := range windows {
|
||
|
|
if wv, ok := w.(*application.WebviewWindow); ok {
|
||
|
|
result = append(result, WindowInfo{
|
||
|
|
Name: wv.Name(),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return result
|
||
|
|
}
|
||
|
|
|
||
|
|
// WindowInfo contains information about a window.
|
||
|
|
type WindowInfo struct {
|
||
|
|
Name string `json:"name"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// ExecJS executes JavaScript in the specified window and returns the result.
|
||
|
|
func (s *Service) ExecJS(windowName string, code string) (string, error) {
|
||
|
|
window := s.GetWindow(windowName)
|
||
|
|
if window == nil {
|
||
|
|
return "", fmt.Errorf("window not found: %s", windowName)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Wrap code to capture return value
|
||
|
|
wrappedCode := fmt.Sprintf(`
|
||
|
|
(function() {
|
||
|
|
try {
|
||
|
|
const result = (function() { %s })();
|
||
|
|
return JSON.stringify({ success: true, result: result });
|
||
|
|
} catch (e) {
|
||
|
|
return JSON.stringify({ success: false, error: e.message, stack: e.stack });
|
||
|
|
}
|
||
|
|
})()
|
||
|
|
`, code)
|
||
|
|
|
||
|
|
window.ExecJS(wrappedCode)
|
||
|
|
|
||
|
|
// Note: Wails v3 ExecJS is fire-and-forget
|
||
|
|
// For return values, we need to use events or a different mechanism
|
||
|
|
return "executed", nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// ExecJSAsync executes JavaScript and returns result via callback.
|
||
|
|
// This uses events to get the return value.
|
||
|
|
func (s *Service) ExecJSAsync(windowName string, code string, callback func(result string, err error)) {
|
||
|
|
window := s.GetWindow(windowName)
|
||
|
|
if window == nil {
|
||
|
|
callback("", fmt.Errorf("window not found: %s", windowName))
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// Generate unique callback ID
|
||
|
|
callbackID := fmt.Sprintf("mcp_eval_%d", time.Now().UnixNano())
|
||
|
|
|
||
|
|
// Register one-time event handler
|
||
|
|
var unsubscribe func()
|
||
|
|
unsubscribe = s.app.Event.On(callbackID, func(event *application.CustomEvent) {
|
||
|
|
unsubscribe()
|
||
|
|
if data, ok := event.Data.(string); ok {
|
||
|
|
callback(data, nil)
|
||
|
|
} else {
|
||
|
|
callback("", fmt.Errorf("invalid response type"))
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
// Execute with callback
|
||
|
|
wrappedCode := fmt.Sprintf(`
|
||
|
|
(async function() {
|
||
|
|
try {
|
||
|
|
const result = await (async function() { %s })();
|
||
|
|
window.wails.Events.Emit('%s', JSON.stringify({ success: true, result: result }));
|
||
|
|
} catch (e) {
|
||
|
|
window.wails.Events.Emit('%s', JSON.stringify({ success: false, error: e.message }));
|
||
|
|
}
|
||
|
|
})()
|
||
|
|
`, code, callbackID, callbackID)
|
||
|
|
|
||
|
|
window.ExecJS(wrappedCode)
|
||
|
|
|
||
|
|
// Timeout after 30 seconds
|
||
|
|
go func() {
|
||
|
|
time.Sleep(30 * time.Second)
|
||
|
|
unsubscribe()
|
||
|
|
}()
|
||
|
|
}
|
||
|
|
|
||
|
|
// InjectConsoleCapture injects JavaScript to capture console output.
|
||
|
|
func (s *Service) InjectConsoleCapture(windowName string) error {
|
||
|
|
window := s.GetWindow(windowName)
|
||
|
|
if window == nil {
|
||
|
|
return fmt.Errorf("window not found: %s", windowName)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Inject console interceptor
|
||
|
|
code := `
|
||
|
|
(function() {
|
||
|
|
if (window.__mcpConsoleInjected) return;
|
||
|
|
window.__mcpConsoleInjected = true;
|
||
|
|
|
||
|
|
const originalConsole = {
|
||
|
|
log: console.log,
|
||
|
|
warn: console.warn,
|
||
|
|
error: console.error,
|
||
|
|
info: console.info,
|
||
|
|
debug: console.debug
|
||
|
|
};
|
||
|
|
|
||
|
|
function intercept(level) {
|
||
|
|
return function(...args) {
|
||
|
|
originalConsole[level].apply(console, args);
|
||
|
|
try {
|
||
|
|
const message = args.map(a => {
|
||
|
|
if (typeof a === 'object') return JSON.stringify(a);
|
||
|
|
return String(a);
|
||
|
|
}).join(' ');
|
||
|
|
window.wails.Events.Emit('mcp:console', JSON.stringify({
|
||
|
|
level: level,
|
||
|
|
message: message,
|
||
|
|
timestamp: new Date().toISOString()
|
||
|
|
}));
|
||
|
|
} catch (e) {}
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log = intercept('log');
|
||
|
|
console.warn = intercept('warn');
|
||
|
|
console.error = intercept('error');
|
||
|
|
console.info = intercept('info');
|
||
|
|
console.debug = intercept('debug');
|
||
|
|
|
||
|
|
// Capture uncaught errors
|
||
|
|
window.addEventListener('error', function(e) {
|
||
|
|
window.wails.Events.Emit('mcp:console', JSON.stringify({
|
||
|
|
level: 'error',
|
||
|
|
message: e.message + ' at ' + e.filename + ':' + e.lineno,
|
||
|
|
timestamp: new Date().toISOString(),
|
||
|
|
source: e.filename,
|
||
|
|
line: e.lineno
|
||
|
|
}));
|
||
|
|
});
|
||
|
|
|
||
|
|
// Capture unhandled promise rejections
|
||
|
|
window.addEventListener('unhandledrejection', function(e) {
|
||
|
|
window.wails.Events.Emit('mcp:console', JSON.stringify({
|
||
|
|
level: 'error',
|
||
|
|
message: 'Unhandled rejection: ' + (e.reason?.message || e.reason),
|
||
|
|
timestamp: new Date().toISOString()
|
||
|
|
}));
|
||
|
|
});
|
||
|
|
})()
|
||
|
|
`
|
||
|
|
|
||
|
|
window.ExecJS(code)
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// SetupConsoleListener sets up the Go-side listener for console events.
|
||
|
|
func (s *Service) SetupConsoleListener() {
|
||
|
|
if s.app == nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
s.app.Event.On("mcp:console", func(event *application.CustomEvent) {
|
||
|
|
if data, ok := event.Data.(string); ok {
|
||
|
|
var msg ConsoleMessage
|
||
|
|
if err := json.Unmarshal([]byte(data), &msg); err == nil {
|
||
|
|
s.addConsoleMessage(msg)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) addConsoleMessage(msg ConsoleMessage) {
|
||
|
|
s.consoleMu.Lock()
|
||
|
|
defer s.consoleMu.Unlock()
|
||
|
|
|
||
|
|
if len(s.consoleBuffer) >= s.maxConsoleSize {
|
||
|
|
// Remove oldest
|
||
|
|
s.consoleBuffer = s.consoleBuffer[1:]
|
||
|
|
}
|
||
|
|
s.consoleBuffer = append(s.consoleBuffer, msg)
|
||
|
|
|
||
|
|
// Notify callback
|
||
|
|
if s.onConsole != nil {
|
||
|
|
s.onConsole(msg)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetConsoleMessages returns captured console messages.
|
||
|
|
func (s *Service) GetConsoleMessages(level string, limit int) []ConsoleMessage {
|
||
|
|
s.consoleMu.RLock()
|
||
|
|
defer s.consoleMu.RUnlock()
|
||
|
|
|
||
|
|
if limit <= 0 {
|
||
|
|
limit = 100
|
||
|
|
}
|
||
|
|
|
||
|
|
result := make([]ConsoleMessage, 0, limit)
|
||
|
|
for i := len(s.consoleBuffer) - 1; i >= 0 && len(result) < limit; i-- {
|
||
|
|
msg := s.consoleBuffer[i]
|
||
|
|
if level == "" || msg.Level == level {
|
||
|
|
result = append(result, msg)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Reverse to chronological order
|
||
|
|
for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 {
|
||
|
|
result[i], result[j] = result[j], result[i]
|
||
|
|
}
|
||
|
|
|
||
|
|
return result
|
||
|
|
}
|
||
|
|
|
||
|
|
// ClearConsole clears the console buffer.
|
||
|
|
func (s *Service) ClearConsole() {
|
||
|
|
s.consoleMu.Lock()
|
||
|
|
defer s.consoleMu.Unlock()
|
||
|
|
s.consoleBuffer = s.consoleBuffer[:0]
|
||
|
|
}
|
||
|
|
|
||
|
|
// Click simulates a click on an element by selector.
|
||
|
|
func (s *Service) Click(windowName string, selector string) error {
|
||
|
|
code := fmt.Sprintf(`
|
||
|
|
const el = document.querySelector(%q);
|
||
|
|
if (!el) throw new Error('Element not found: %s');
|
||
|
|
el.click();
|
||
|
|
return 'clicked';
|
||
|
|
`, selector, selector)
|
||
|
|
|
||
|
|
_, err := s.ExecJS(windowName, code)
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
// Type types text into an element.
|
||
|
|
func (s *Service) Type(windowName string, selector string, text string) error {
|
||
|
|
code := fmt.Sprintf(`
|
||
|
|
const el = document.querySelector(%q);
|
||
|
|
if (!el) throw new Error('Element not found: %s');
|
||
|
|
el.focus();
|
||
|
|
el.value = %q;
|
||
|
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||
|
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||
|
|
return 'typed';
|
||
|
|
`, selector, selector, text)
|
||
|
|
|
||
|
|
_, err := s.ExecJS(windowName, code)
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
// QuerySelector returns info about elements matching a selector.
|
||
|
|
func (s *Service) QuerySelector(windowName string, selector string) (string, error) {
|
||
|
|
code := fmt.Sprintf(`
|
||
|
|
const els = document.querySelectorAll(%q);
|
||
|
|
return Array.from(els).map(el => ({
|
||
|
|
tag: el.tagName.toLowerCase(),
|
||
|
|
id: el.id,
|
||
|
|
class: el.className,
|
||
|
|
text: el.textContent?.substring(0, 100),
|
||
|
|
rect: el.getBoundingClientRect()
|
||
|
|
}));
|
||
|
|
`, selector)
|
||
|
|
|
||
|
|
return s.ExecJS(windowName, code)
|
||
|
|
}
|
||
|
|
|
||
|
|
// ScreenshotResult holds the result of a screenshot operation.
|
||
|
|
type ScreenshotResult struct {
|
||
|
|
Data string `json:"data,omitempty"` // Base64 PNG data
|
||
|
|
Error string `json:"error,omitempty"` // Error message if failed
|
||
|
|
}
|
||
|
|
|
||
|
|
// Screenshot captures a screenshot of the window.
|
||
|
|
// Returns base64-encoded PNG data via callback (async operation).
|
||
|
|
func (s *Service) Screenshot(windowName string) (string, error) {
|
||
|
|
window := s.GetWindow(windowName)
|
||
|
|
if window == nil {
|
||
|
|
return "", fmt.Errorf("window not found: %s", windowName)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Generate unique callback ID for this screenshot
|
||
|
|
callbackID := fmt.Sprintf("mcp_screenshot_%d", time.Now().UnixNano())
|
||
|
|
|
||
|
|
// Channel to receive result
|
||
|
|
resultChan := make(chan ScreenshotResult, 1)
|
||
|
|
|
||
|
|
// Register one-time event handler
|
||
|
|
var unsubscribe func()
|
||
|
|
unsubscribe = s.app.Event.On(callbackID, func(event *application.CustomEvent) {
|
||
|
|
unsubscribe()
|
||
|
|
if data, ok := event.Data.(string); ok {
|
||
|
|
var result ScreenshotResult
|
||
|
|
if err := json.Unmarshal([]byte(data), &result); err != nil {
|
||
|
|
resultChan <- ScreenshotResult{Error: "failed to parse result"}
|
||
|
|
} else {
|
||
|
|
resultChan <- result
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
resultChan <- ScreenshotResult{Error: "invalid response type"}
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
// Inject html2canvas if not present and capture screenshot
|
||
|
|
code := fmt.Sprintf(`
|
||
|
|
(async function() {
|
||
|
|
try {
|
||
|
|
// Load html2canvas dynamically if not present
|
||
|
|
if (typeof html2canvas === 'undefined') {
|
||
|
|
await new Promise((resolve, reject) => {
|
||
|
|
const script = document.createElement('script');
|
||
|
|
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js';
|
||
|
|
script.onload = resolve;
|
||
|
|
script.onerror = () => reject(new Error('Failed to load html2canvas'));
|
||
|
|
document.head.appendChild(script);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
const canvas = await html2canvas(document.body, {
|
||
|
|
useCORS: true,
|
||
|
|
allowTaint: true,
|
||
|
|
backgroundColor: null,
|
||
|
|
scale: window.devicePixelRatio || 1
|
||
|
|
});
|
||
|
|
const dataUrl = canvas.toDataURL('image/png');
|
||
|
|
window.wails.Events.Emit('%s', JSON.stringify({ data: dataUrl }));
|
||
|
|
} catch (e) {
|
||
|
|
window.wails.Events.Emit('%s', JSON.stringify({ error: e.message }));
|
||
|
|
}
|
||
|
|
})()
|
||
|
|
`, callbackID, callbackID)
|
||
|
|
|
||
|
|
window.ExecJS(code)
|
||
|
|
|
||
|
|
// Wait for result with timeout
|
||
|
|
select {
|
||
|
|
case result := <-resultChan:
|
||
|
|
if result.Error != "" {
|
||
|
|
return "", fmt.Errorf("screenshot failed: %s", result.Error)
|
||
|
|
}
|
||
|
|
return result.Data, nil
|
||
|
|
case <-time.After(15 * time.Second):
|
||
|
|
unsubscribe()
|
||
|
|
return "", fmt.Errorf("screenshot timeout")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ScreenshotElement captures a screenshot of a specific element.
|
||
|
|
func (s *Service) ScreenshotElement(windowName string, selector string) (string, error) {
|
||
|
|
window := s.GetWindow(windowName)
|
||
|
|
if window == nil {
|
||
|
|
return "", fmt.Errorf("window not found: %s", windowName)
|
||
|
|
}
|
||
|
|
|
||
|
|
callbackID := fmt.Sprintf("mcp_screenshot_%d", time.Now().UnixNano())
|
||
|
|
resultChan := make(chan ScreenshotResult, 1)
|
||
|
|
|
||
|
|
var unsubscribe func()
|
||
|
|
unsubscribe = s.app.Event.On(callbackID, func(event *application.CustomEvent) {
|
||
|
|
unsubscribe()
|
||
|
|
if data, ok := event.Data.(string); ok {
|
||
|
|
var result ScreenshotResult
|
||
|
|
if err := json.Unmarshal([]byte(data), &result); err != nil {
|
||
|
|
resultChan <- ScreenshotResult{Error: "failed to parse result"}
|
||
|
|
} else {
|
||
|
|
resultChan <- result
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
resultChan <- ScreenshotResult{Error: "invalid response type"}
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
code := fmt.Sprintf(`
|
||
|
|
(async function() {
|
||
|
|
try {
|
||
|
|
const el = document.querySelector(%q);
|
||
|
|
if (!el) throw new Error('Element not found: %s');
|
||
|
|
|
||
|
|
if (typeof html2canvas === 'undefined') {
|
||
|
|
await new Promise((resolve, reject) => {
|
||
|
|
const script = document.createElement('script');
|
||
|
|
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js';
|
||
|
|
script.onload = resolve;
|
||
|
|
script.onerror = () => reject(new Error('Failed to load html2canvas'));
|
||
|
|
document.head.appendChild(script);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
const canvas = await html2canvas(el, {
|
||
|
|
useCORS: true,
|
||
|
|
allowTaint: true,
|
||
|
|
backgroundColor: null,
|
||
|
|
scale: window.devicePixelRatio || 1
|
||
|
|
});
|
||
|
|
const dataUrl = canvas.toDataURL('image/png');
|
||
|
|
window.wails.Events.Emit('%s', JSON.stringify({ data: dataUrl }));
|
||
|
|
} catch (e) {
|
||
|
|
window.wails.Events.Emit('%s', JSON.stringify({ error: e.message }));
|
||
|
|
}
|
||
|
|
})()
|
||
|
|
`, selector, selector, callbackID, callbackID)
|
||
|
|
|
||
|
|
window.ExecJS(code)
|
||
|
|
|
||
|
|
select {
|
||
|
|
case result := <-resultChan:
|
||
|
|
if result.Error != "" {
|
||
|
|
return "", fmt.Errorf("screenshot failed: %s", result.Error)
|
||
|
|
}
|
||
|
|
return result.Data, nil
|
||
|
|
case <-time.After(15 * time.Second):
|
||
|
|
unsubscribe()
|
||
|
|
return "", fmt.Errorf("screenshot timeout")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetPageSource returns the current page HTML.
|
||
|
|
func (s *Service) GetPageSource(windowName string) (string, error) {
|
||
|
|
code := `return document.documentElement.outerHTML;`
|
||
|
|
return s.ExecJS(windowName, code)
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetURL returns the current page URL.
|
||
|
|
func (s *Service) GetURL(windowName string) (string, error) {
|
||
|
|
code := `return window.location.href;`
|
||
|
|
return s.ExecJS(windowName, code)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Navigate navigates to a URL.
|
||
|
|
func (s *Service) Navigate(windowName string, url string) error {
|
||
|
|
window := s.GetWindow(windowName)
|
||
|
|
if window == nil {
|
||
|
|
return fmt.Errorf("window not found: %s", windowName)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Use Angular router if available, otherwise location
|
||
|
|
code := fmt.Sprintf(`
|
||
|
|
if (window.ng && window.ng.getComponent) {
|
||
|
|
// Try Angular router
|
||
|
|
const router = window.ng.getComponent(document.querySelector('router-outlet'))?.router;
|
||
|
|
if (router) {
|
||
|
|
router.navigateByUrl(%q);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
window.location.href = %q;
|
||
|
|
`, url, url)
|
||
|
|
|
||
|
|
window.ExecJS(code)
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// EncodeBase64 is a helper to encode bytes to base64.
|
||
|
|
func EncodeBase64(data []byte) string {
|
||
|
|
return base64.StdEncoding.EncodeToString(data)
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetTitle returns the current page title.
|
||
|
|
func (s *Service) GetTitle(windowName string) (string, error) {
|
||
|
|
code := `return document.title;`
|
||
|
|
return s.ExecJS(windowName, code)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Scroll scrolls to an element or position.
|
||
|
|
// If selector is provided, scrolls to that element.
|
||
|
|
// Otherwise scrolls to the x,y position.
|
||
|
|
func (s *Service) Scroll(windowName string, selector string, x, y int) error {
|
||
|
|
var code string
|
||
|
|
if selector != "" {
|
||
|
|
code = fmt.Sprintf(`
|
||
|
|
const el = document.querySelector(%q);
|
||
|
|
if (!el) throw new Error('Element not found: %s');
|
||
|
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
|
|
return 'scrolled';
|
||
|
|
`, selector, selector)
|
||
|
|
} else {
|
||
|
|
code = fmt.Sprintf(`
|
||
|
|
window.scrollTo({ top: %d, left: %d, behavior: 'smooth' });
|
||
|
|
return 'scrolled';
|
||
|
|
`, y, x)
|
||
|
|
}
|
||
|
|
_, err := s.ExecJS(windowName, code)
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
// Hover simulates hovering over an element.
|
||
|
|
func (s *Service) Hover(windowName string, selector string) error {
|
||
|
|
code := fmt.Sprintf(`
|
||
|
|
const el = document.querySelector(%q);
|
||
|
|
if (!el) throw new Error('Element not found: %s');
|
||
|
|
const event = new MouseEvent('mouseenter', {
|
||
|
|
bubbles: true,
|
||
|
|
cancelable: true,
|
||
|
|
view: window
|
||
|
|
});
|
||
|
|
el.dispatchEvent(event);
|
||
|
|
const hoverEvent = new MouseEvent('mouseover', {
|
||
|
|
bubbles: true,
|
||
|
|
cancelable: true,
|
||
|
|
view: window
|
||
|
|
});
|
||
|
|
el.dispatchEvent(hoverEvent);
|
||
|
|
return 'hovered';
|
||
|
|
`, selector, selector)
|
||
|
|
_, err := s.ExecJS(windowName, code)
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
// Select selects an option in a dropdown/select element.
|
||
|
|
func (s *Service) Select(windowName string, selector string, value string) error {
|
||
|
|
code := fmt.Sprintf(`
|
||
|
|
const el = document.querySelector(%q);
|
||
|
|
if (!el) throw new Error('Element not found: %s');
|
||
|
|
if (el.tagName.toLowerCase() !== 'select') {
|
||
|
|
throw new Error('Element is not a select element');
|
||
|
|
}
|
||
|
|
el.value = %q;
|
||
|
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||
|
|
return 'selected';
|
||
|
|
`, selector, selector, value)
|
||
|
|
_, err := s.ExecJS(windowName, code)
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check sets the checked state of a checkbox or radio button.
|
||
|
|
func (s *Service) Check(windowName string, selector string, checked bool) error {
|
||
|
|
code := fmt.Sprintf(`
|
||
|
|
const el = document.querySelector(%q);
|
||
|
|
if (!el) throw new Error('Element not found: %s');
|
||
|
|
if (el.type !== 'checkbox' && el.type !== 'radio') {
|
||
|
|
throw new Error('Element is not a checkbox or radio button');
|
||
|
|
}
|
||
|
|
el.checked = %t;
|
||
|
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||
|
|
return 'checked';
|
||
|
|
`, selector, selector, checked)
|
||
|
|
_, err := s.ExecJS(windowName, code)
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetElementInfo returns detailed info about a specific element.
|
||
|
|
func (s *Service) GetElementInfo(windowName string, selector string) (string, error) {
|
||
|
|
code := fmt.Sprintf(`
|
||
|
|
const el = document.querySelector(%q);
|
||
|
|
if (!el) throw new Error('Element not found: %s');
|
||
|
|
const rect = el.getBoundingClientRect();
|
||
|
|
const styles = window.getComputedStyle(el);
|
||
|
|
return {
|
||
|
|
tag: el.tagName.toLowerCase(),
|
||
|
|
id: el.id,
|
||
|
|
className: el.className,
|
||
|
|
text: el.textContent?.substring(0, 500),
|
||
|
|
innerHTML: el.innerHTML?.substring(0, 1000),
|
||
|
|
value: el.value,
|
||
|
|
type: el.type,
|
||
|
|
href: el.href,
|
||
|
|
src: el.src,
|
||
|
|
checked: el.checked,
|
||
|
|
disabled: el.disabled,
|
||
|
|
visible: rect.width > 0 && rect.height > 0,
|
||
|
|
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
||
|
|
styles: {
|
||
|
|
display: styles.display,
|
||
|
|
visibility: styles.visibility,
|
||
|
|
color: styles.color,
|
||
|
|
backgroundColor: styles.backgroundColor,
|
||
|
|
fontSize: styles.fontSize
|
||
|
|
},
|
||
|
|
attributes: Object.fromEntries(
|
||
|
|
Array.from(el.attributes).map(a => [a.name, a.value])
|
||
|
|
)
|
||
|
|
};
|
||
|
|
`, selector, selector)
|
||
|
|
return s.ExecJS(windowName, code)
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetComputedStyle returns computed styles for an element.
|
||
|
|
func (s *Service) GetComputedStyle(windowName string, selector string, properties []string) (string, error) {
|
||
|
|
propsJSON, _ := json.Marshal(properties)
|
||
|
|
code := fmt.Sprintf(`
|
||
|
|
const el = document.querySelector(%q);
|
||
|
|
if (!el) throw new Error('Element not found: %s');
|
||
|
|
const styles = window.getComputedStyle(el);
|
||
|
|
const props = %s;
|
||
|
|
if (props.length === 0) {
|
||
|
|
// Return all computed styles
|
||
|
|
const result = {};
|
||
|
|
for (let i = 0; i < styles.length; i++) {
|
||
|
|
const prop = styles[i];
|
||
|
|
result[prop] = styles.getPropertyValue(prop);
|
||
|
|
}
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
// Return only requested properties
|
||
|
|
const result = {};
|
||
|
|
for (const prop of props) {
|
||
|
|
result[prop] = styles.getPropertyValue(prop);
|
||
|
|
}
|
||
|
|
return result;
|
||
|
|
`, selector, selector, string(propsJSON))
|
||
|
|
return s.ExecJS(windowName, code)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Highlight visually highlights an element for debugging.
|
||
|
|
func (s *Service) Highlight(windowName string, selector string, duration int) error {
|
||
|
|
if duration <= 0 {
|
||
|
|
duration = 2000
|
||
|
|
}
|
||
|
|
code := fmt.Sprintf(`
|
||
|
|
const el = document.querySelector(%q);
|
||
|
|
if (!el) throw new Error('Element not found: %s');
|
||
|
|
const originalOutline = el.style.outline;
|
||
|
|
const originalBackground = el.style.backgroundColor;
|
||
|
|
el.style.outline = '3px solid red';
|
||
|
|
el.style.backgroundColor = 'rgba(255, 0, 0, 0.2)';
|
||
|
|
setTimeout(() => {
|
||
|
|
el.style.outline = originalOutline;
|
||
|
|
el.style.backgroundColor = originalBackground;
|
||
|
|
}, %d);
|
||
|
|
return 'highlighted';
|
||
|
|
`, selector, selector, duration)
|
||
|
|
_, err := s.ExecJS(windowName, code)
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetDOMTree returns a simplified DOM tree structure.
|
||
|
|
func (s *Service) GetDOMTree(windowName string, maxDepth int) (string, error) {
|
||
|
|
if maxDepth <= 0 {
|
||
|
|
maxDepth = 5
|
||
|
|
}
|
||
|
|
code := fmt.Sprintf(`
|
||
|
|
function buildTree(node, depth = 0) {
|
||
|
|
if (depth > %d) return null;
|
||
|
|
if (node.nodeType !== Node.ELEMENT_NODE) return null;
|
||
|
|
|
||
|
|
const children = [];
|
||
|
|
for (const child of node.children) {
|
||
|
|
const childTree = buildTree(child, depth + 1);
|
||
|
|
if (childTree) children.push(childTree);
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
tag: node.tagName.toLowerCase(),
|
||
|
|
id: node.id || undefined,
|
||
|
|
class: node.className || undefined,
|
||
|
|
children: children.length > 0 ? children : undefined
|
||
|
|
};
|
||
|
|
}
|
||
|
|
return buildTree(document.body);
|
||
|
|
`, maxDepth)
|
||
|
|
return s.ExecJS(windowName, code)
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetErrors returns captured error messages (subset of console with level=error).
|
||
|
|
func (s *Service) GetErrors(limit int) []ConsoleMessage {
|
||
|
|
return s.GetConsoleMessages("error", limit)
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetPerformance returns performance metrics from the page.
|
||
|
|
func (s *Service) GetPerformance(windowName string) (string, error) {
|
||
|
|
code := `
|
||
|
|
const perf = window.performance;
|
||
|
|
const timing = perf.timing;
|
||
|
|
const memory = perf.memory || {};
|
||
|
|
const navigation = perf.getEntriesByType('navigation')[0] || {};
|
||
|
|
|
||
|
|
return {
|
||
|
|
loadTime: timing.loadEventEnd - timing.navigationStart,
|
||
|
|
domReady: timing.domContentLoadedEventEnd - timing.navigationStart,
|
||
|
|
firstPaint: perf.getEntriesByType('paint').find(p => p.name === 'first-paint')?.startTime || 0,
|
||
|
|
firstContentfulPaint: perf.getEntriesByType('paint').find(p => p.name === 'first-contentful-paint')?.startTime || 0,
|
||
|
|
memory: {
|
||
|
|
usedJSHeapSize: memory.usedJSHeapSize,
|
||
|
|
totalJSHeapSize: memory.totalJSHeapSize,
|
||
|
|
jsHeapSizeLimit: memory.jsHeapSizeLimit
|
||
|
|
},
|
||
|
|
resourceCount: perf.getEntriesByType('resource').length,
|
||
|
|
transferSize: navigation.transferSize || 0,
|
||
|
|
encodedBodySize: navigation.encodedBodySize || 0,
|
||
|
|
decodedBodySize: navigation.decodedBodySize || 0
|
||
|
|
};
|
||
|
|
`
|
||
|
|
return s.ExecJS(windowName, code)
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetResources returns a list of loaded resources (scripts, styles, images).
|
||
|
|
func (s *Service) GetResources(windowName string) (string, error) {
|
||
|
|
code := `
|
||
|
|
const resources = window.performance.getEntriesByType('resource');
|
||
|
|
return resources.map(r => ({
|
||
|
|
name: r.name,
|
||
|
|
type: r.initiatorType,
|
||
|
|
duration: r.duration,
|
||
|
|
transferSize: r.transferSize,
|
||
|
|
encodedBodySize: r.encodedBodySize,
|
||
|
|
decodedBodySize: r.decodedBodySize,
|
||
|
|
startTime: r.startTime,
|
||
|
|
responseEnd: r.responseEnd
|
||
|
|
}));
|
||
|
|
`
|
||
|
|
return s.ExecJS(windowName, code)
|
||
|
|
}
|
||
|
|
|
||
|
|
// NetworkRequest represents a captured network request.
|
||
|
|
type NetworkRequest struct {
|
||
|
|
URL string `json:"url"`
|
||
|
|
Method string `json:"method"`
|
||
|
|
Status int `json:"status"`
|
||
|
|
StatusText string `json:"statusText"`
|
||
|
|
Type string `json:"type"`
|
||
|
|
Duration float64 `json:"duration"`
|
||
|
|
TransferSize int64 `json:"transferSize"`
|
||
|
|
StartTime float64 `json:"startTime"`
|
||
|
|
ResponseEnd float64 `json:"responseEnd"`
|
||
|
|
Headers map[string]string `json:"headers,omitempty"`
|
||
|
|
Timestamp time.Time `json:"timestamp"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// networkBuffer stores captured network requests.
|
||
|
|
type networkBuffer struct {
|
||
|
|
requests []NetworkRequest
|
||
|
|
maxSize int
|
||
|
|
mu sync.RWMutex
|
||
|
|
}
|
||
|
|
|
||
|
|
var netBuffer = &networkBuffer{
|
||
|
|
requests: make([]NetworkRequest, 0, 500),
|
||
|
|
maxSize: 500,
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetNetworkRequests returns captured network requests.
|
||
|
|
// This uses the Performance API to get resource timing data.
|
||
|
|
func (s *Service) GetNetworkRequests(windowName string, limit int) (string, error) {
|
||
|
|
if limit <= 0 {
|
||
|
|
limit = 100
|
||
|
|
}
|
||
|
|
code := fmt.Sprintf(`
|
||
|
|
const entries = window.performance.getEntriesByType('resource');
|
||
|
|
const requests = entries.slice(-%d).map(entry => ({
|
||
|
|
url: entry.name,
|
||
|
|
type: entry.initiatorType,
|
||
|
|
duration: entry.duration,
|
||
|
|
transferSize: entry.transferSize || 0,
|
||
|
|
encodedBodySize: entry.encodedBodySize || 0,
|
||
|
|
decodedBodySize: entry.decodedBodySize || 0,
|
||
|
|
startTime: entry.startTime,
|
||
|
|
responseEnd: entry.responseEnd,
|
||
|
|
serverTiming: entry.serverTiming || [],
|
||
|
|
nextHopProtocol: entry.nextHopProtocol || '',
|
||
|
|
connectStart: entry.connectStart,
|
||
|
|
connectEnd: entry.connectEnd,
|
||
|
|
domainLookupStart: entry.domainLookupStart,
|
||
|
|
domainLookupEnd: entry.domainLookupEnd,
|
||
|
|
requestStart: entry.requestStart,
|
||
|
|
responseStart: entry.responseStart
|
||
|
|
}));
|
||
|
|
return requests;
|
||
|
|
`, limit)
|
||
|
|
return s.ExecJS(windowName, code)
|
||
|
|
}
|
||
|
|
|
||
|
|
// ClearNetworkRequests clears the network request buffer.
|
||
|
|
func (s *Service) ClearNetworkRequests(windowName string) error {
|
||
|
|
code := `
|
||
|
|
window.performance.clearResourceTimings();
|
||
|
|
return 'cleared';
|
||
|
|
`
|
||
|
|
_, err := s.ExecJS(windowName, code)
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
// InjectNetworkInterceptor injects a fetch/XHR interceptor to capture detailed request info.
|
||
|
|
// This provides more detail than Performance API alone.
|
||
|
|
func (s *Service) InjectNetworkInterceptor(windowName string) error {
|
||
|
|
window := s.GetWindow(windowName)
|
||
|
|
if window == nil {
|
||
|
|
return fmt.Errorf("window not found: %s", windowName)
|
||
|
|
}
|
||
|
|
|
||
|
|
code := `
|
||
|
|
(function() {
|
||
|
|
if (window.__mcpNetworkInjected) return;
|
||
|
|
window.__mcpNetworkInjected = true;
|
||
|
|
window.__mcpNetworkRequests = [];
|
||
|
|
|
||
|
|
// Intercept fetch
|
||
|
|
const originalFetch = window.fetch;
|
||
|
|
window.fetch = async function(...args) {
|
||
|
|
const startTime = performance.now();
|
||
|
|
const request = new Request(...args);
|
||
|
|
const requestInfo = {
|
||
|
|
url: request.url,
|
||
|
|
method: request.method,
|
||
|
|
type: 'fetch',
|
||
|
|
startTime: startTime,
|
||
|
|
timestamp: new Date().toISOString()
|
||
|
|
};
|
||
|
|
|
||
|
|
try {
|
||
|
|
const response = await originalFetch.apply(this, args);
|
||
|
|
requestInfo.status = response.status;
|
||
|
|
requestInfo.statusText = response.statusText;
|
||
|
|
requestInfo.duration = performance.now() - startTime;
|
||
|
|
requestInfo.responseEnd = performance.now();
|
||
|
|
|
||
|
|
// Emit event for Go to capture
|
||
|
|
window.wails.Events.Emit('mcp:network', JSON.stringify(requestInfo));
|
||
|
|
window.__mcpNetworkRequests.push(requestInfo);
|
||
|
|
|
||
|
|
// Keep buffer size limited
|
||
|
|
if (window.__mcpNetworkRequests.length > 500) {
|
||
|
|
window.__mcpNetworkRequests.shift();
|
||
|
|
}
|
||
|
|
|
||
|
|
return response;
|
||
|
|
} catch (error) {
|
||
|
|
requestInfo.error = error.message;
|
||
|
|
requestInfo.duration = performance.now() - startTime;
|
||
|
|
window.wails.Events.Emit('mcp:network', JSON.stringify(requestInfo));
|
||
|
|
window.__mcpNetworkRequests.push(requestInfo);
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// Intercept XMLHttpRequest
|
||
|
|
const originalXHROpen = XMLHttpRequest.prototype.open;
|
||
|
|
const originalXHRSend = XMLHttpRequest.prototype.send;
|
||
|
|
|
||
|
|
XMLHttpRequest.prototype.open = function(method, url, ...rest) {
|
||
|
|
this.__mcpMethod = method;
|
||
|
|
this.__mcpUrl = url;
|
||
|
|
return originalXHROpen.apply(this, [method, url, ...rest]);
|
||
|
|
};
|
||
|
|
|
||
|
|
XMLHttpRequest.prototype.send = function(...args) {
|
||
|
|
const xhr = this;
|
||
|
|
const startTime = performance.now();
|
||
|
|
|
||
|
|
xhr.addEventListener('loadend', function() {
|
||
|
|
const requestInfo = {
|
||
|
|
url: xhr.__mcpUrl,
|
||
|
|
method: xhr.__mcpMethod,
|
||
|
|
type: 'xhr',
|
||
|
|
status: xhr.status,
|
||
|
|
statusText: xhr.statusText,
|
||
|
|
startTime: startTime,
|
||
|
|
duration: performance.now() - startTime,
|
||
|
|
responseEnd: performance.now(),
|
||
|
|
timestamp: new Date().toISOString()
|
||
|
|
};
|
||
|
|
|
||
|
|
window.wails.Events.Emit('mcp:network', JSON.stringify(requestInfo));
|
||
|
|
window.__mcpNetworkRequests.push(requestInfo);
|
||
|
|
|
||
|
|
if (window.__mcpNetworkRequests.length > 500) {
|
||
|
|
window.__mcpNetworkRequests.shift();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
return originalXHRSend.apply(this, args);
|
||
|
|
};
|
||
|
|
})()
|
||
|
|
`
|
||
|
|
|
||
|
|
window.ExecJS(code)
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetInterceptedNetworkRequests returns requests captured by the injected interceptor.
|
||
|
|
func (s *Service) GetInterceptedNetworkRequests(windowName string, limit int) (string, error) {
|
||
|
|
if limit <= 0 {
|
||
|
|
limit = 100
|
||
|
|
}
|
||
|
|
code := fmt.Sprintf(`
|
||
|
|
const requests = window.__mcpNetworkRequests || [];
|
||
|
|
return requests.slice(-%d);
|
||
|
|
`, limit)
|
||
|
|
return s.ExecJS(windowName, code)
|
||
|
|
}
|
||
|
|
|
||
|
|
// SetupNetworkListener sets up the Go-side listener for network events.
|
||
|
|
func (s *Service) SetupNetworkListener() {
|
||
|
|
if s.app == nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
s.app.Event.On("mcp:network", func(event *application.CustomEvent) {
|
||
|
|
if data, ok := event.Data.(string); ok {
|
||
|
|
var req NetworkRequest
|
||
|
|
if err := json.Unmarshal([]byte(data), &req); err == nil {
|
||
|
|
netBuffer.mu.Lock()
|
||
|
|
if len(netBuffer.requests) >= netBuffer.maxSize {
|
||
|
|
netBuffer.requests = netBuffer.requests[1:]
|
||
|
|
}
|
||
|
|
netBuffer.requests = append(netBuffer.requests, req)
|
||
|
|
netBuffer.mu.Unlock()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetCachedNetworkRequests returns network requests from the Go-side buffer.
|
||
|
|
func (s *Service) GetCachedNetworkRequests(limit int) []NetworkRequest {
|
||
|
|
netBuffer.mu.RLock()
|
||
|
|
defer netBuffer.mu.RUnlock()
|
||
|
|
|
||
|
|
if limit <= 0 {
|
||
|
|
limit = 100
|
||
|
|
}
|
||
|
|
|
||
|
|
result := make([]NetworkRequest, 0, limit)
|
||
|
|
start := len(netBuffer.requests) - limit
|
||
|
|
if start < 0 {
|
||
|
|
start = 0
|
||
|
|
}
|
||
|
|
|
||
|
|
for i := start; i < len(netBuffer.requests); i++ {
|
||
|
|
result = append(result, netBuffer.requests[i])
|
||
|
|
}
|
||
|
|
return result
|
||
|
|
}
|
||
|
|
|
||
|
|
// ClearCachedNetworkRequests clears the Go-side network buffer.
|
||
|
|
func (s *Service) ClearCachedNetworkRequests() {
|
||
|
|
netBuffer.mu.Lock()
|
||
|
|
defer netBuffer.mu.Unlock()
|
||
|
|
netBuffer.requests = netBuffer.requests[:0]
|
||
|
|
}
|
||
|
|
|
||
|
|
// PrintToPDF triggers the browser print dialog (which can save as PDF).
|
||
|
|
// This uses the native Wails Print() method.
|
||
|
|
func (s *Service) PrintToPDF(windowName string) error {
|
||
|
|
window := s.GetWindow(windowName)
|
||
|
|
if window == nil {
|
||
|
|
return fmt.Errorf("window not found: %s", windowName)
|
||
|
|
}
|
||
|
|
return window.Print()
|
||
|
|
}
|
||
|
|
|
||
|
|
// ExportToPDF exports the page as a PDF using html2pdf.js library.
|
||
|
|
// Returns base64-encoded PDF data via async callback.
|
||
|
|
func (s *Service) ExportToPDF(windowName string, options map[string]any) (string, error) {
|
||
|
|
window := s.GetWindow(windowName)
|
||
|
|
if window == nil {
|
||
|
|
return "", fmt.Errorf("window not found: %s", windowName)
|
||
|
|
}
|
||
|
|
|
||
|
|
callbackID := fmt.Sprintf("mcp_pdf_%d", time.Now().UnixNano())
|
||
|
|
resultChan := make(chan struct {
|
||
|
|
data string
|
||
|
|
err string
|
||
|
|
}, 1)
|
||
|
|
|
||
|
|
var unsubscribe func()
|
||
|
|
unsubscribe = s.app.Event.On(callbackID, func(event *application.CustomEvent) {
|
||
|
|
unsubscribe()
|
||
|
|
if data, ok := event.Data.(string); ok {
|
||
|
|
var result struct {
|
||
|
|
Data string `json:"data"`
|
||
|
|
Error string `json:"error"`
|
||
|
|
}
|
||
|
|
if err := json.Unmarshal([]byte(data), &result); err != nil {
|
||
|
|
resultChan <- struct {
|
||
|
|
data string
|
||
|
|
err string
|
||
|
|
}{"", "failed to parse result"}
|
||
|
|
} else {
|
||
|
|
resultChan <- struct {
|
||
|
|
data string
|
||
|
|
err string
|
||
|
|
}{result.Data, result.Error}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
// Get options with defaults
|
||
|
|
filename := "document.pdf"
|
||
|
|
if fn, ok := options["filename"].(string); ok && fn != "" {
|
||
|
|
filename = fn
|
||
|
|
}
|
||
|
|
margin := 10
|
||
|
|
if m, ok := options["margin"].(float64); ok {
|
||
|
|
margin = int(m)
|
||
|
|
}
|
||
|
|
|
||
|
|
code := fmt.Sprintf(`
|
||
|
|
(async function() {
|
||
|
|
try {
|
||
|
|
// Load html2pdf.js if not present
|
||
|
|
if (typeof html2pdf === 'undefined') {
|
||
|
|
await new Promise((resolve, reject) => {
|
||
|
|
const script = document.createElement('script');
|
||
|
|
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js';
|
||
|
|
script.onload = resolve;
|
||
|
|
script.onerror = () => reject(new Error('Failed to load html2pdf.js'));
|
||
|
|
document.head.appendChild(script);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
const element = document.body;
|
||
|
|
const opt = {
|
||
|
|
margin: %d,
|
||
|
|
filename: %q,
|
||
|
|
image: { type: 'jpeg', quality: 0.98 },
|
||
|
|
html2canvas: { scale: 2, useCORS: true },
|
||
|
|
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }
|
||
|
|
};
|
||
|
|
|
||
|
|
const pdf = await html2pdf().set(opt).from(element).outputPdf('datauristring');
|
||
|
|
window.wails.Events.Emit('%s', JSON.stringify({ data: pdf }));
|
||
|
|
} catch (e) {
|
||
|
|
window.wails.Events.Emit('%s', JSON.stringify({ error: e.message }));
|
||
|
|
}
|
||
|
|
})()
|
||
|
|
`, margin, filename, callbackID, callbackID)
|
||
|
|
|
||
|
|
window.ExecJS(code)
|
||
|
|
|
||
|
|
select {
|
||
|
|
case result := <-resultChan:
|
||
|
|
if result.err != "" {
|
||
|
|
return "", fmt.Errorf("PDF export failed: %s", result.err)
|
||
|
|
}
|
||
|
|
return result.data, nil
|
||
|
|
case <-time.After(30 * time.Second):
|
||
|
|
unsubscribe()
|
||
|
|
return "", fmt.Errorf("PDF export timeout")
|
||
|
|
}
|
||
|
|
}
|