go/pkg/webview/webview.go

1119 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")
}
}