This commit is contained in:
Pandelis Zembashis 2026-03-01 11:23:32 +11:00 committed by GitHub
commit 63e8e737cf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1177 additions and 0 deletions

142
v3/WEBCONTENTSVIEW.md Normal file
View file

@ -0,0 +1,142 @@
# WebContentsView for Wails v3
`WebContentsView` is an implementation of Electron's `WebContentsView` (formerly `BrowserView`) for Wails v3. It allows you to embed a fully native, secondary OS-level Webview directly over your Wails application UI.
Unlike a standard HTML `<iframe>`, this native view:
- Bypasses restrictive `X-Frame-Options` and `Content-Security-Policy: frame-ancestors` headers.
- Can have web security (CORS) disabled independently of the main Wails app context.
- Maintains its own session, cookies, and caching behavior.
- Renders with native performance using the OS's underlying web engine (WKWebView on macOS, WebView2 on Windows, WebKitGTK on Linux).
## Architecture
The module is built with a clean separation between the Go API and the platform-specific native implementations.
### 1. Go API Layer
The `webcontentsview` package exposes a structured API for managing the view lifecycle.
- **`NewWebContentsView(options)`**: Initializes the native OS webview but does not display it.
- **`Attach(window)`**: Mounts the webview to the provided Wails `application.Window` using its raw `NativeWindow()` pointer.
- **`SetBounds(rect)`**: Dynamically positions and sizes the view.
- **`SetURL(url)`**: Navigates the view.
- **`ExecJS(js)`**: Evaluates JavaScript inside the context of the secondary view.
- **`Detach()`**: Unmounts and hides the view.
### 2. Platform Specific Implementations
* **macOS (`webcontentsview_darwin.m`)**: Creates a `WKWebView` via Objective-C. When attached, it gets added as a subview to the `NSWindow`'s `contentView`. To ensure it sits above the main Wails UI, it is backed by a CoreAnimation layer (`wantsLayer = YES`) and assigned an astronomical z-index (`zPosition = 9999.0`). It automatically adjusts web coordinates (top-left) to Cocoa coordinates (bottom-left).
* **Windows (`webcontentsview_windows.go`)**: Leverages the `github.com/wailsapp/go-webview2/pkg/edge` package to create an `edge.Chromium` instance, embedding it directly into the parent window's `HWND`.
* **Linux (`webcontentsview_linux.go`)**: Uses CGO and GTK to create a `WebKitSettings` and `GtkWidget` webview, packing it into the main `GtkBox` container.
### 3. Web Preferences
Inspired by Electron, `WebContentsViewOptions` accepts a `WebPreferences` struct. This passes down to `WKPreferences` / `ICoreWebView2Settings` to configure behavior:
- `DevTools`: Enable/disable the web inspector.
- `Javascript`: Enable/disable JS execution.
- `WebSecurity`: Disables cross-origin restrictions and allows local file URL access (crucial for local-dev previewing).
- `ZoomFactor`: Scales the viewport.
---
## Usage Guide
To use `WebContentsView`, you must coordinate between your Go backend and your JavaScript/React frontend.
### 1. Go Backend setup
Add the bridge methods to your Wails `App` struct so the frontend can control the view:
```go
import (
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/webcontentsview"
)
var browserView *webcontentsview.WebContentsView
func (a *App) InitBrowserView(x, y, width, height int, url string) {
// ALL UI creation MUST happen on the main thread
application.InvokeSync(func() {
browserView = webcontentsview.NewWebContentsView(webcontentsview.WebContentsViewOptions{
URL: url,
Bounds: application.Rect{ X: x, Y: y, Width: width, Height: height },
WebPreferences: webcontentsview.WebPreferences{
DevTools: application.Enabled,
Javascript: application.Enabled,
WebSecurity: application.Disabled, // Ideal for bypassing CORS during local dev
},
})
browserView.Attach(a.mainWindow)
})
}
func (a *App) SetBrowserViewBounds(x, y, width, height int) {
application.InvokeSync(func() {
browserView.SetBounds(application.Rect{ X: x, Y: y, Width: width, Height: height })
})
}
```
### 2. React Frontend setup
Instead of an `<iframe>`, render an empty `<div>` in React to act as a placeholder. Use a `ResizeObserver` to track the exact screen coordinates of the `<div>` and send them to the Go backend. Go will physically move the native window over the empty space.
```tsx
import { useRef, useEffect, useLayoutEffect } from "react";
import { desktopAPI } from "@/features/desktop/api";
export default function BrowserTab({ active, url }) {
const containerRef = useRef<HTMLDivElement>(null);
const isInitializedRef = useRef(false);
const updateBounds = () => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
// Hide by setting dimensions to 0 when inactive
if (!active || rect.width === 0) {
desktopAPI.setBrowserViewBounds(0, 0, 0, 0);
return;
}
desktopAPI.setBrowserViewBounds(
Math.round(rect.x),
Math.round(rect.y),
Math.round(rect.width),
Math.round(rect.height)
);
};
// Initialize on mount
useLayoutEffect(() => {
if (!containerRef.current) return;
if (!isInitializedRef.current) {
const rect = containerRef.current.getBoundingClientRect();
desktopAPI.initBrowserView(
Math.round(rect.x), Math.round(rect.y),
active ? Math.round(rect.width) : 0,
active ? Math.round(rect.height) : 0,
url
).then(() => { isInitializedRef.current = true; });
} else {
updateBounds();
}
}, [active]);
// Track window resizing and layout shifting
useEffect(() => {
if (!active) return;
const observer = new ResizeObserver(() => setTimeout(updateBounds, 10));
if (containerRef.current) observer.observe(containerRef.current);
window.addEventListener('resize', updateBounds);
return () => {
observer.disconnect();
window.removeEventListener('resize', updateBounds);
};
}, [active]);
return (
// The native webview will "float" exactly over this transparent div
<div className="flex-1 w-full relative bg-transparent" ref={containerRef} />
);
}
```
This pattern ensures the native `WebContentsView` stays perfectly synchronized with your React layout, mimicking the behaviour of a built-in browser component.

View file

@ -55,6 +55,7 @@ func (b *BrowserWindow) Info(message string, args ...any) {
// No-op methods - these don't apply to browser windows
func (b *BrowserWindow) Center() {}
func (b *BrowserWindow) Close() {}
func (b *BrowserWindow) DisableSizeConstraints() {}

View file

@ -7,6 +7,7 @@ import (
)
type Window interface {
Center()
Close()
DisableSizeConstraints()

View file

@ -0,0 +1,142 @@
# WebContentsView for Wails v3
`WebContentsView` is an implementation of Electron's `WebContentsView` (formerly `BrowserView`) for Wails v3. It allows you to embed a fully native, secondary OS-level Webview directly over your Wails application UI.
Unlike a standard HTML `<iframe>`, this native view:
- Bypasses restrictive `X-Frame-Options` and `Content-Security-Policy: frame-ancestors` headers.
- Can have web security (CORS) disabled independently of the main Wails app context.
- Maintains its own session, cookies, and caching behavior.
- Renders with native performance using the OS's underlying web engine (WKWebView on macOS, WebView2 on Windows, WebKitGTK on Linux).
## Architecture
The module is built with a clean separation between the Go API and the platform-specific native implementations.
### 1. Go API Layer
The `webcontentsview` package exposes a structured API for managing the view lifecycle.
- **`NewWebContentsView(options)`**: Initializes the native OS webview but does not display it.
- **`Attach(window)`**: Mounts the webview to the provided Wails `application.Window` using its raw `NativeWindow()` pointer.
- **`SetBounds(rect)`**: Dynamically positions and sizes the view.
- **`SetURL(url)`**: Navigates the view.
- **`ExecJS(js)`**: Evaluates JavaScript inside the context of the secondary view.
- **`Detach()`**: Unmounts and hides the view.
### 2. Platform Specific Implementations
* **macOS (`webcontentsview_darwin.m`)**: Creates a `WKWebView` via Objective-C. When attached, it gets added as a subview to the `NSWindow`'s `contentView`. To ensure it sits above the main Wails UI, it is backed by a CoreAnimation layer (`wantsLayer = YES`) and assigned an astronomical z-index (`zPosition = 9999.0`). It automatically adjusts web coordinates (top-left) to Cocoa coordinates (bottom-left).
* **Windows (`webcontentsview_windows.go`)**: Leverages the `github.com/wailsapp/go-webview2/pkg/edge` package to create an `edge.Chromium` instance, embedding it directly into the parent window's `HWND`.
* **Linux (`webcontentsview_linux.go`)**: Uses CGO and GTK to create a `WebKitSettings` and `GtkWidget` webview, packing it into the main `GtkBox` container.
### 3. Web Preferences
Inspired by Electron, `WebContentsViewOptions` accepts a `WebPreferences` struct. This passes down to `WKPreferences` / `ICoreWebView2Settings` to configure behavior:
- `DevTools`: Enable/disable the web inspector.
- `Javascript`: Enable/disable JS execution.
- `WebSecurity`: Disables cross-origin restrictions and allows local file URL access (crucial for local-dev previewing).
- `ZoomFactor`: Scales the viewport.
---
## Usage Guide
To use `WebContentsView`, you must coordinate between your Go backend and your JavaScript/React frontend.
### 1. Go Backend setup
Add the bridge methods to your Wails `App` struct so the frontend can control the view:
```go
import (
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/webcontentsview"
)
var browserView *webcontentsview.WebContentsView
func (a *App) InitBrowserView(x, y, width, height int, url string) {
// ALL UI creation MUST happen on the main thread
application.InvokeSync(func() {
browserView = webcontentsview.NewWebContentsView(webcontentsview.WebContentsViewOptions{
URL: url,
Bounds: application.Rect{ X: x, Y: y, Width: width, Height: height },
WebPreferences: webcontentsview.WebPreferences{
DevTools: application.Enabled,
Javascript: application.Enabled,
WebSecurity: application.Disabled, // Ideal for bypassing CORS during local dev
},
})
browserView.Attach(a.mainWindow)
})
}
func (a *App) SetBrowserViewBounds(x, y, width, height int) {
application.InvokeSync(func() {
browserView.SetBounds(application.Rect{ X: x, Y: y, Width: width, Height: height })
})
}
```
### 2. React Frontend setup
Instead of an `<iframe>`, render an empty `<div>` in React to act as a placeholder. Use a `ResizeObserver` to track the exact screen coordinates of the `<div>` and send them to the Go backend. Go will physically move the native window over the empty space.
```tsx
import { useRef, useEffect, useLayoutEffect } from "react";
import { desktopAPI } from "@/features/desktop/api";
export default function BrowserTab({ active, url }) {
const containerRef = useRef<HTMLDivElement>(null);
const isInitializedRef = useRef(false);
const updateBounds = () => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
// Hide by setting dimensions to 0 when inactive
if (!active || rect.width === 0) {
desktopAPI.setBrowserViewBounds(0, 0, 0, 0);
return;
}
desktopAPI.setBrowserViewBounds(
Math.round(rect.x),
Math.round(rect.y),
Math.round(rect.width),
Math.round(rect.height)
);
};
// Initialize on mount
useLayoutEffect(() => {
if (!containerRef.current) return;
if (!isInitializedRef.current) {
const rect = containerRef.current.getBoundingClientRect();
desktopAPI.initBrowserView(
Math.round(rect.x), Math.round(rect.y),
active ? Math.round(rect.width) : 0,
active ? Math.round(rect.height) : 0,
url
).then(() => { isInitializedRef.current = true; });
} else {
updateBounds();
}
}, [active]);
// Track window resizing and layout shifting
useEffect(() => {
if (!active) return;
const observer = new ResizeObserver(() => setTimeout(updateBounds, 10));
if (containerRef.current) observer.observe(containerRef.current);
window.addEventListener('resize', updateBounds);
return () => {
observer.disconnect();
window.removeEventListener('resize', updateBounds);
};
}, [active]);
return (
// The native webview will "float" exactly over this transparent div
<div className="flex-1 w-full relative bg-transparent" ref={containerRef} />
);
}
```
This pattern ensures the native `WebContentsView` stays perfectly synchronized with your React layout, mimicking the behaviour of a built-in browser component.

View file

@ -0,0 +1,22 @@
package webcontentsview
import (
"sync"
"sync/atomic"
)
var snapshotCallbacks sync.Map
var snapshotCallbackID uintptr
func registerSnapshotCallback(ch chan string) uintptr {
id := atomic.AddUintptr(&snapshotCallbackID, 1)
snapshotCallbacks.Store(id, ch)
return id
}
func dispatchSnapshotResult(id uintptr, data string) {
if ch, ok := snapshotCallbacks.Load(id); ok {
ch.(chan string) <- data
snapshotCallbacks.Delete(id)
}
}

View file

@ -0,0 +1,88 @@
package webcontentsview
import (
"sync/atomic"
"unsafe"
"github.com/wailsapp/wails/v3/pkg/application"
)
// WebContentsViewOptions represents the options for creating a WebContentsView.
type WebContentsViewOptions struct {
Name string
URL string
HTML string
Bounds application.Rect
WebPreferences WebPreferences
}
// WebContentsView represents a native webview that can be embedded into a window.
type WebContentsView struct {
options WebContentsViewOptions
id uint
impl webContentsViewImpl
}
var webContentsViewID uintptr
// NewWebContentsView creates a new WebContentsView with the given options.
func NewWebContentsView(options WebContentsViewOptions) *WebContentsView {
result := &WebContentsView{
id: uint(atomic.AddUintptr(&webContentsViewID, 1)),
options: options,
}
result.impl = newWebContentsViewImpl(result)
return result
}
// SetBounds sets the position and size of the WebContentsView relative to its parent.
func (v *WebContentsView) SetBounds(bounds application.Rect) {
v.impl.setBounds(bounds)
}
// SetURL loads the given URL into the WebContentsView.
func (v *WebContentsView) SetURL(url string) {
v.impl.setURL(url)
}
// ExecJS executes the given javascript in the WebContentsView.
func (v *WebContentsView) ExecJS(js string) {
v.impl.execJS(js)
}
// GoBack navigates to the previous page in history.
func (v *WebContentsView) GoBack() {
v.impl.goBack()
}
// GetURL returns the current URL of the view.
func (v *WebContentsView) GetURL() string {
return v.impl.getURL()
}
// TakeSnapshot returns a base64 encoded PNG of the current view.
func (v *WebContentsView) TakeSnapshot() string {
return v.impl.takeSnapshot()
}
// Attach binds the WebContentsView to a Wails Window.
func (v *WebContentsView) Attach(window application.Window) {
v.impl.attach(window)
}
// Detach removes the WebContentsView from the Wails Window.
func (v *WebContentsView) Detach() {
v.impl.detach()
}
// webContentsViewImpl is the interface that platform-specific implementations must satisfy.
type webContentsViewImpl interface {
setBounds(bounds application.Rect)
setURL(url string)
execJS(js string)
goBack()
getURL() string
takeSnapshot() string
attach(window application.Window)
detach()
nativeView() unsafe.Pointer
}

View file

@ -0,0 +1,28 @@
//go:build android
package webcontentsview
import (
"unsafe"
"github.com/wailsapp/wails/v3/pkg/application"
)
type androidWebContentsView struct {
parent *WebContentsView
}
func newWebContentsViewImpl(parent *WebContentsView) webContentsViewImpl {
return &androidWebContentsView{parent: parent}
}
func (w *androidWebContentsView) setBounds(bounds application.Rect) {}
func (w *androidWebContentsView) setURL(url string) {}
func (w *androidWebContentsView) execJS(js string) {}
func (w *androidWebContentsView) goBack() {}
func (w *androidWebContentsView) getURL() string { return "" }
func (w *androidWebContentsView) takeSnapshot() string { return "" }
func (w *androidWebContentsView) attach(window application.Window) {}
func (w *androidWebContentsView) detach() {}
func (w *androidWebContentsView) nativeView() unsafe.Pointer { return nil }

View file

@ -0,0 +1,130 @@
//go:build darwin
package webcontentsview
/*
#cgo CFLAGS: -x objective-c
#cgo LDFLAGS: -framework Foundation -framework Cocoa -framework WebKit
#import "webcontentsview_darwin.h"
#include <stdlib.h>
*/
import "C"
import (
"unsafe"
"github.com/wailsapp/wails/v3/pkg/application"
)
type macosWebContentsView struct {
parent *WebContentsView
nsView unsafe.Pointer
nsWindow unsafe.Pointer
}
func newWebContentsViewImpl(parent *WebContentsView) webContentsViewImpl {
var cUserAgent *C.char
if parent.options.WebPreferences.UserAgent != "" {
cUserAgent = C.CString(parent.options.WebPreferences.UserAgent)
defer C.free(unsafe.Pointer(cUserAgent))
}
prefs := C.WebContentsViewPreferences{
devTools: C.bool(parent.options.WebPreferences.DevTools != application.Disabled),
javascript: C.bool(parent.options.WebPreferences.Javascript != application.Disabled),
webSecurity: C.bool(parent.options.WebPreferences.WebSecurity != application.Disabled),
images: C.bool(parent.options.WebPreferences.Images != application.Disabled),
plugins: C.bool(parent.options.WebPreferences.Plugins == application.Enabled),
zoomFactor: C.double(parent.options.WebPreferences.ZoomFactor),
defaultFontSize: C.int(parent.options.WebPreferences.DefaultFontSize),
defaultMonospaceFontSize: C.int(parent.options.WebPreferences.DefaultMonospaceFontSize),
minimumFontSize: C.int(parent.options.WebPreferences.MinimumFontSize),
userAgent: cUserAgent,
}
if prefs.zoomFactor == 0 {
prefs.zoomFactor = 1.0
}
var view = C.createWebContentsView(
C.int(parent.options.Bounds.X),
C.int(parent.options.Bounds.Y),
C.int(parent.options.Bounds.Width),
C.int(parent.options.Bounds.Height),
prefs,
)
result := &macosWebContentsView{
parent: parent,
nsView: view,
}
if parent.options.URL != "" {
result.setURL(parent.options.URL)
}
return result
}
func (w *macosWebContentsView) setBounds(bounds application.Rect) {
C.webContentsViewSetBounds(w.nsView, C.int(bounds.X), C.int(bounds.Y), C.int(bounds.Width), C.int(bounds.Height))
}
func (w *macosWebContentsView) setURL(url string) {
cUrl := C.CString(url)
defer C.free(unsafe.Pointer(cUrl))
C.webContentsViewSetURL(w.nsView, cUrl)
}
func (w *macosWebContentsView) goBack() {
C.webContentsViewGoBack(w.nsView)
}
func (w *macosWebContentsView) takeSnapshot() string {
ch := make(chan string, 1)
id := registerSnapshotCallback(ch)
application.InvokeSync(func() {
C.webContentsViewTakeSnapshot(w.nsView, C.uintptr_t(id))
})
return <-ch
}
func (w *macosWebContentsView) getURL() string {
cUrl := C.webContentsViewGetURL(w.nsView)
if cUrl == nil {
return ""
}
defer C.free(unsafe.Pointer(cUrl))
return C.GoString(cUrl)
}
func (w *macosWebContentsView) execJS(js string) {
cJs := C.CString(js)
defer C.free(unsafe.Pointer(cJs))
C.webContentsViewExecJS(w.nsView, cJs)
}
func (w *macosWebContentsView) attach(window application.Window) {
w.nsWindow = window.NativeWindow()
if w.nsWindow != nil {
C.windowAddWebContentsView(w.nsWindow, w.nsView)
}
}
func (w *macosWebContentsView) detach() {
if w.nsWindow != nil {
C.windowRemoveWebContentsView(w.nsWindow, w.nsView)
w.nsWindow = nil
}
}
func (w *macosWebContentsView) nativeView() unsafe.Pointer {
return w.nsView
}
//export browserViewSnapshotCallback
func browserViewSnapshotCallback(callbackID C.uintptr_t, base64 *C.char) {
id := uintptr(callbackID)
str := ""
if base64 != nil {
str = C.GoString(base64)
}
dispatchSnapshotResult(id, str)
}

View file

@ -0,0 +1,35 @@
#ifndef webcontentsview_darwin_h
#define webcontentsview_darwin_h
#import <Foundation/Foundation.h>
#import <Cocoa/Cocoa.h>
#import <WebKit/WebKit.h>
typedef struct {
bool devTools;
bool javascript;
bool webSecurity;
bool images;
bool plugins;
double zoomFactor;
int defaultFontSize;
int defaultMonospaceFontSize;
int minimumFontSize;
const char* userAgent;
} WebContentsViewPreferences;
extern void* createWebContentsView(int x, int y, int w, int h, WebContentsViewPreferences prefs);
extern void webContentsViewSetBounds(void* view, int x, int y, int w, int h);
extern void webContentsViewSetURL(void* view, const char* url);
extern void webContentsViewExecJS(void* view, const char* js);
extern void webContentsViewGoBack(void* view);
extern const char* webContentsViewGetURL(void* view);
extern void windowAddWebContentsView(void* nsWindow, void* view);
extern void windowRemoveWebContentsView(void* nsWindow, void* view);
// Async snapshot
extern void webContentsViewTakeSnapshot(void* view, uintptr_t callbackID);
#endif /* webcontentsview_darwin_h */

View file

@ -0,0 +1,168 @@
#import "webcontentsview_darwin.h"
void* createWebContentsView(int x, int y, int w, int h, WebContentsViewPreferences prefs) {
NSRect frame = NSMakeRect(x, y, w, h);
WKWebViewConfiguration* config = [[WKWebViewConfiguration alloc] init];
WKPreferences *preferences = [[WKPreferences alloc] init];
@try {
if (@available(macOS 10.11, *)) {
[preferences setValue:@(prefs.devTools) forKey:@"developerExtrasEnabled"];
}
if (@available(macOS 11.0, *)) {
WKWebpagePreferences *webpagePreferences = [[WKWebpagePreferences alloc] init];
webpagePreferences.allowsContentJavaScript = prefs.javascript;
config.defaultWebpagePreferences = webpagePreferences;
} else {
preferences.javaScriptEnabled = prefs.javascript;
}
if (!prefs.webSecurity) {
@try {
[config.preferences setValue:@YES forKey:@"allowFileAccessFromFileURLs"];
[config.preferences setValue:@YES forKey:@"allowUniversalAccessFromFileURLs"];
} @catch (NSException *e) {}
}
config.preferences = preferences;
} @catch (NSException *e) {}
WKWebView* webView = [[WKWebView alloc] initWithFrame:frame configuration:config];
[webView setValue:@(NO) forKey:@"drawsTransparentBackground"];
if (prefs.userAgent != NULL) {
NSString* customUA = [NSString stringWithUTF8String:prefs.userAgent];
[webView setCustomUserAgent:customUA];
}
return webView;
}
void webContentsViewSetBounds(void* view, int x, int y, int w, int h) {
WKWebView* webView = (WKWebView*)view;
NSView* superview = [webView superview];
if (superview != nil) {
CGFloat superHeight = superview.bounds.size.height;
CGFloat cocoaY = superHeight - y - h;
[webView setFrame:NSMakeRect(x, cocoaY, w, h)];
} else {
[webView setFrame:NSMakeRect(x, y, w, h)];
}
}
void webContentsViewSetURL(void* view, const char* url) {
WKWebView* webView = (WKWebView*)view;
NSString* nsURL = [NSString stringWithUTF8String:url];
NSURLRequest* request = [NSURLRequest requestWithURL:[NSURL URLWithString:nsURL]];
dispatch_async(dispatch_get_main_queue(), ^{
[webView loadRequest:request];
});
}
void webContentsViewExecJS(void* view, const char* js) {
WKWebView* webView = (WKWebView*)view;
NSString* script = [NSString stringWithUTF8String:js];
dispatch_async(dispatch_get_main_queue(), ^{
[webView evaluateJavaScript:script completionHandler:nil];
});
}
void windowAddWebContentsView(void* nsWindow, void* view) {
NSWindow* window = (NSWindow*)nsWindow;
WKWebView* webView = (WKWebView*)view;
[window.contentView addSubview:webView];
[webView setWantsLayer:YES];
webView.layer.zPosition = 9999.0;
}
void windowRemoveWebContentsView(void* nsWindow, void* view) {
WKWebView* webView = (WKWebView*)view;
[webView removeFromSuperview];
}
void webContentsViewGoBack(void* view) {
WKWebView* webView = (WKWebView*)view;
dispatch_async(dispatch_get_main_queue(), ^{
if ([webView canGoBack]) {
[webView goBack];
}
});
}
const char* webContentsViewGetURL(void* view) {
__block const char* result = NULL;
WKWebView* webView = (WKWebView*)view;
if ([NSThread isMainThread]) {
if (webView.URL != nil) {
result = strdup(webView.URL.absoluteString.UTF8String);
}
} else {
dispatch_sync(dispatch_get_main_queue(), ^{
if (webView.URL != nil) {
result = strdup(webView.URL.absoluteString.UTF8String);
}
});
}
return result;
}
extern void browserViewSnapshotCallback(uintptr_t callbackID, const char* base64);
void webContentsViewTakeSnapshot(void* view, uintptr_t callbackID) {
WKWebView* webView = (WKWebView*)view;
dispatch_async(dispatch_get_main_queue(), ^{
@try {
if (@available(macOS 10.13, *)) {
WKSnapshotConfiguration *config = [[WKSnapshotConfiguration alloc] init];
[webView takeSnapshotWithConfiguration:config completionHandler:^(NSImage *image, NSError *error) {
if (error != nil || image == nil) {
browserViewSnapshotCallback(callbackID, NULL);
return;
}
@try {
CGImageRef cgRef = [image CGImageForProposedRect:NULL context:nil hints:nil];
if (cgRef == NULL) {
browserViewSnapshotCallback(callbackID, NULL);
return;
}
NSBitmapImageRep *newRep = [[NSBitmapImageRep alloc] initWithCGImage:cgRef];
if (newRep == nil) {
browserViewSnapshotCallback(callbackID, NULL);
return;
}
[newRep setSize:[image size]];
NSData *pngData = [newRep representationUsingType:NSBitmapImageFileTypePNG properties:@{}];
if (pngData == nil) {
browserViewSnapshotCallback(callbackID, NULL);
return;
}
NSString *base64String = [pngData base64EncodedStringWithOptions:0];
if (base64String == nil) {
browserViewSnapshotCallback(callbackID, NULL);
return;
}
NSString *fullBase64 = [NSString stringWithFormat:@"data:image/png;base64,%@", base64String];
browserViewSnapshotCallback(callbackID, [fullBase64 UTF8String]);
} @catch (NSException *innerException) {
NSLog(@"Error processing snapshot image: %@", innerException.reason);
browserViewSnapshotCallback(callbackID, NULL);
}
}];
} else {
browserViewSnapshotCallback(callbackID, NULL);
}
} @catch (NSException *e) {
NSLog(@"Exception in snapshot configuration: %@", e.reason);
browserViewSnapshotCallback(callbackID, NULL);
}
});
}

View file

@ -0,0 +1,78 @@
//go:build darwin
package webcontentsview
import (
"testing"
"unsafe"
"github.com/wailsapp/wails/v3/pkg/application"
)
// Dummy mock window that satisfies the interface
// and returns a nil NativeWindow so we can test the Attach nil-handling safely
// without spinning up the full NSApplication runloop in a headless test environment.
type mockWindow struct {
application.Window
}
func (m *mockWindow) NativeWindow() unsafe.Pointer {
return nil
}
func TestWebContentsView_APISurface(t *testing.T) {
// We primarily want to ensure that the API surface compiles and functions
// correctly at a struct level. Note: Full WKWebView instantiation without an NSApplication
// runloop will crash on macOS, so we test the struct wiring here instead of the native allocations.
options := WebContentsViewOptions{
Name: "TestBrowser",
URL: "https://example.com",
Bounds: application.Rect{
X: 0,
Y: 0,
Width: 800,
Height: 600,
},
WebPreferences: WebPreferences{
DevTools: application.Enabled,
Javascript: application.Enabled,
WebSecurity: application.Disabled, // Disable CORS
ZoomFactor: 1.2,
},
}
// Because calling NewWebContentsView invokes C.createWebContentsView which
// traps without a runloop during go test, we will just manually instantiate
// the Go wrapper to verify the methods.
view := &WebContentsView{
id: 1,
options: options,
impl: &mockWebContentsViewImpl{}, // Mock the impl to bypass Objective-C in headless test
}
// 2. Test SetBounds
view.SetBounds(application.Rect{X: 10, Y: 10, Width: 400, Height: 400})
// 3. Test SetURL
view.SetURL("https://google.com")
// 4. Test ExecJS
view.ExecJS("console.log('test');")
// 5. Test Attach and Detach using a mock window
win := &mockWindow{}
view.Attach(win)
view.Detach()
t.Log("macOS WebContentsView API surface tests passed successfully.")
}
type mockWebContentsViewImpl struct{}
func (m *mockWebContentsViewImpl) setBounds(bounds application.Rect) {}
func (m *mockWebContentsViewImpl) setURL(url string) {}
func (m *mockWebContentsViewImpl) execJS(js string) {}
func (m *mockWebContentsViewImpl) attach(window application.Window) {}
func (m *mockWebContentsViewImpl) detach() {}
func (m *mockWebContentsViewImpl) nativeView() unsafe.Pointer { return nil }

View file

@ -0,0 +1,28 @@
//go:build ios
package webcontentsview
import (
"unsafe"
"github.com/wailsapp/wails/v3/pkg/application"
)
type iosWebContentsView struct {
parent *WebContentsView
}
func newWebContentsViewImpl(parent *WebContentsView) webContentsViewImpl {
return &iosWebContentsView{parent: parent}
}
func (w *iosWebContentsView) setBounds(bounds application.Rect) {}
func (w *iosWebContentsView) setURL(url string) {}
func (w *iosWebContentsView) execJS(js string) {}
func (w *iosWebContentsView) goBack() {}
func (w *iosWebContentsView) getURL() string { return "" }
func (w *iosWebContentsView) takeSnapshot() string { return "" }
func (w *iosWebContentsView) attach(window application.Window) {}
func (w *iosWebContentsView) detach() {}
func (w *iosWebContentsView) nativeView() unsafe.Pointer { return nil }

View file

@ -0,0 +1,145 @@
//go:build linux && cgo && !gtk4 && !android && !server
package webcontentsview
/*
#cgo linux pkg-config: gtk+-3.0 webkit2gtk-4.1 gdk-3.0
#include <gtk/gtk.h>
#include <webkit2/webkit2.h>
static void* createWebContentsView_linux(int x, int y, int w, int h, int devTools, int js, int images) {
WebKitSettings *settings = webkit_settings_new();
webkit_settings_set_enable_developer_extras(settings, devTools ? TRUE : FALSE);
webkit_settings_set_enable_javascript(settings, js ? TRUE : FALSE);
webkit_settings_set_auto_load_images(settings, images ? TRUE : FALSE);
GtkWidget *webview = webkit_web_view_new_with_settings(settings);
gtk_widget_set_size_request(webview, w, h);
return webview;
}
static void webContentsViewSetBounds_linux(void* view, void* parentFixed, int x, int y, int w, int h) {
GtkWidget *webview = (GtkWidget*)view;
gtk_widget_set_size_request(webview, w, h);
if (parentFixed != NULL) {
gtk_fixed_move(GTK_FIXED(parentFixed), webview, x, y);
}
}
static void webContentsViewSetURL_linux(void* view, const char* url) {
webkit_web_view_load_uri(WEBKIT_WEB_VIEW((GtkWidget*)view), url);
}
static void webContentsViewExecJS_linux(void* view, const char* js) {
webkit_web_view_run_javascript(WEBKIT_WEB_VIEW((GtkWidget*)view), js, NULL, NULL, NULL);
}
static void webContentsViewAttach_linux(void* window, void* view) {
// Attempt to add to the main container. Wails v3 usually uses a vbox.
GtkWindow *gtkWindow = GTK_WINDOW(window);
GtkWidget *child = gtk_bin_get_child(GTK_BIN(gtkWindow));
if (child != NULL && GTK_IS_BOX(child)) {
gtk_box_pack_start(GTK_BOX(child), GTK_WIDGET(view), FALSE, FALSE, 0);
gtk_widget_show(GTK_WIDGET(view));
}
}
static void webContentsViewDetach_linux(void* view) {
GtkWidget *webview = (GtkWidget*)view;
GtkWidget *parent = gtk_widget_get_parent(webview);
if (parent != NULL) {
gtk_container_remove(GTK_CONTAINER(parent), webview);
}
}
*/
import "C"
import (
"unsafe"
"github.com/wailsapp/wails/v3/pkg/application"
)
type linuxWebContentsView struct {
parent *WebContentsView
widget unsafe.Pointer
}
func newWebContentsViewImpl(parent *WebContentsView) webContentsViewImpl {
devTools := 1
if parent.options.WebPreferences.DevTools == application.Disabled {
devTools = 0
}
js := 1
if parent.options.WebPreferences.Javascript == application.Disabled {
js = 0
}
images := 1
if parent.options.WebPreferences.Images == application.Disabled {
images = 0
}
view := C.createWebContentsView_linux(
C.int(parent.options.Bounds.X),
C.int(parent.options.Bounds.Y),
C.int(parent.options.Bounds.Width),
C.int(parent.options.Bounds.Height),
C.int(devTools),
C.int(js),
C.int(images),
)
result := &linuxWebContentsView{
parent: parent,
widget: view,
}
return result
}
func (w *linuxWebContentsView) setBounds(bounds application.Rect) {
C.webContentsViewSetBounds_linux(w.widget, nil, C.int(bounds.X), C.int(bounds.Y), C.int(bounds.Width), C.int(bounds.Height))
}
func (w *linuxWebContentsView) setURL(url string) {
cUrl := C.CString(url)
defer C.free(unsafe.Pointer(cUrl))
C.webContentsViewSetURL_linux(w.widget, cUrl)
}
func (w *linuxWebContentsView) goBack() {
// TODO: webkit_web_view_go_back
}
func (w *linuxWebContentsView) getURL() string {
return ""
}
func (w *linuxWebContentsView) takeSnapshot() string {
return ""
return ""
}
func (w *linuxWebContentsView) execJS(js string) {
cJs := C.CString(js)
defer C.free(unsafe.Pointer(cJs))
C.webContentsViewExecJS_linux(w.widget, cJs)
}
func (w *linuxWebContentsView) attach(window application.Window) {
if window.NativeWindow() != nil {
C.webContentsViewAttach_linux(window.NativeWindow(), w.widget)
if w.parent.options.URL != "" {
w.setURL(w.parent.options.URL)
}
}
}
func (w *linuxWebContentsView) detach() {
C.webContentsViewDetach_linux(w.widget)
}
func (w *linuxWebContentsView) nativeView() unsafe.Pointer {
return w.widget
}

View file

@ -0,0 +1,118 @@
//go:build windows
package webcontentsview
import (
"unsafe"
"github.com/wailsapp/go-webview2/pkg/edge"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/w32"
)
type windowsWebContentsView struct {
parent *WebContentsView
chromium *edge.Chromium
hwnd w32.HWND
}
func newWebContentsViewImpl(parent *WebContentsView) webContentsViewImpl {
chromium := edge.NewChromium()
result := &windowsWebContentsView{
parent: parent,
chromium: chromium,
}
settings, err := chromium.GetSettings()
if err == nil {
if parent.options.WebPreferences.DevTools != application.Disabled {
settings.PutAreDevToolsEnabled(true)
settings.PutAreDefaultContextMenusEnabled(true)
} else {
settings.PutAreDevToolsEnabled(false)
settings.PutAreDefaultContextMenusEnabled(false)
}
if parent.options.WebPreferences.Javascript != application.Disabled {
settings.PutIsScriptEnabled(true)
} else {
settings.PutIsScriptEnabled(false)
}
if parent.options.WebPreferences.ZoomFactor > 0 {
chromium.PutZoomFactor(parent.options.WebPreferences.ZoomFactor)
}
if parent.options.WebPreferences.UserAgent != "" {
settings.PutUserAgent(parent.options.WebPreferences.UserAgent)
}
}
return result
}
func (w *windowsWebContentsView) setBounds(bounds application.Rect) {
if w.chromium != nil {
edgeBounds := edge.Rect{
Left: int32(bounds.X),
Top: int32(bounds.Y),
Right: int32(bounds.X + bounds.Width),
Bottom: int32(bounds.Y + bounds.Height),
}
w.chromium.ResizeWithBounds(&edgeBounds)
}
}
func (w *windowsWebContentsView) setURL(url string) {
if w.chromium != nil {
w.chromium.Navigate(url)
}
}
func (w *windowsWebContentsView) goBack() {
if w.chromium != nil {
// w.chromium.GoBack() // Requires wrapping edge GoBack if available, or just execJS
w.execJS("window.history.back();")
}
}
func (w *windowsWebContentsView) getURL() string {
// Synchronous URL reading isn't trivial without a dedicated edge mapping,
// returning last known or empty for now.
return w.parent.options.URL
}
func (w *windowsWebContentsView) execJS(js string) {
if w.chromium != nil {
w.chromium.Eval(js)
}
}
func (w *windowsWebContentsView) takeSnapshot() string {
return ""
}
func (w *windowsWebContentsView) attach(window application.Window) {
if window.NativeWindow() != nil {
w.hwnd = w32.HWND(window.NativeWindow())
w.chromium.Embed(w.hwnd)
w.chromium.Resize()
w.chromium.Show()
if w.parent.options.URL != "" {
w.chromium.Navigate(w.parent.options.URL)
}
}
}
func (w *windowsWebContentsView) detach() {
if w.chromium != nil {
w.chromium.Hide()
}
}
func (w *windowsWebContentsView) nativeView() unsafe.Pointer {
return unsafe.Pointer(w.chromium)
}

View file

@ -0,0 +1,51 @@
package webcontentsview
import "github.com/leaanthony/u"
// WebPreferences closely mirrors Electron's webPreferences for WebContentsView.
type WebPreferences struct {
// DevTools enables or disables the developer tools. Default is true.
DevTools u.Bool
// Javascript enables or disables javascript execution. Default is true.
Javascript u.Bool
// WebSecurity enables or disables web security (CORS, etc.). Default is true.
WebSecurity u.Bool
// AllowRunningInsecureContent allows an https page to run http code. Default is false.
AllowRunningInsecureContent u.Bool
// Images enables or disables image loading. Default is true.
Images u.Bool
// TextAreasAreResizable controls whether text areas can be resized. Default is true.
TextAreasAreResizable u.Bool
// WebGL enables or disables WebGL. Default is true.
WebGL u.Bool
// Plugins enables or disables plugins. Default is false.
Plugins u.Bool
// ZoomFactor sets the default zoom factor of the page. Default is 1.0.
ZoomFactor float64
// NavigateOnDragDrop controls whether dropping files triggers navigation. Default is false.
NavigateOnDragDrop u.Bool
// DefaultFontSize sets the default font size. Default is 16.
DefaultFontSize int
// DefaultMonospaceFontSize sets the default monospace font size. Default is 13.
DefaultMonospaceFontSize int
// MinimumFontSize sets the minimum font size. Default is 0.
MinimumFontSize int
// DefaultEncoding sets the default character encoding. Default is "UTF-8".
DefaultEncoding string
// UserAgent sets a custom user agent for the webview.
UserAgent string
}