cause?messagename
diff --git a/v3/internal/runtime/desktop/@wailsio/runtime/docs/classes/CancelError.html b/v3/internal/runtime/desktop/@wailsio/runtime/docs/classes/CancelError.html
index 1d49a3101..26a7c6a14 100644
--- a/v3/internal/runtime/desktop/@wailsio/runtime/docs/classes/CancelError.html
+++ b/v3/internal/runtime/desktop/@wailsio/runtime/docs/classes/CancelError.html
@@ -2,7 +2,7 @@
in case a CancellablePromise is cancelled successfully.
The value of the name property is the string "CancelError".
The value of the cause property is the cause passed to the cancel method, if any.
cause?messagename
diff --git a/v3/internal/runtime/desktop/@wailsio/runtime/docs/classes/CancellablePromise.html b/v3/internal/runtime/desktop/@wailsio/runtime/docs/classes/CancellablePromise.html
index fcc068fc8..d0f67dd10 100644
--- a/v3/internal/runtime/desktop/@wailsio/runtime/docs/classes/CancellablePromise.html
+++ b/v3/internal/runtime/desktop/@wailsio/runtime/docs/classes/CancellablePromise.html
@@ -35,7 +35,7 @@ This might be reconsidered in case the proposal is retired.
and is compliant with the Promises/A+ specification
(it passes the compliance suite)
if so is the underlying implementation.
-
cancelcancelOn
diff --git a/v3/internal/runtime/desktop/@wailsio/runtime/docs/classes/CancelledRejectionError.html b/v3/internal/runtime/desktop/@wailsio/runtime/docs/classes/CancelledRejectionError.html
index 48f30a330..2445bf276 100644
--- a/v3/internal/runtime/desktop/@wailsio/runtime/docs/classes/CancelledRejectionError.html
+++ b/v3/internal/runtime/desktop/@wailsio/runtime/docs/classes/CancelledRejectionError.html
@@ -6,7 +6,7 @@ The value of the promise property holds a reference to the original promise.
-
Loads a script from the given URL if it exists.
+Uses HEAD request to check existence, then injects a script tag.
+Silently ignores if the script doesn't exist.
diff --git a/v3/internal/runtime/desktop/@wailsio/runtime/package-lock.json b/v3/internal/runtime/desktop/@wailsio/runtime/package-lock.json
index decb9300b..6935b38ac 100644
--- a/v3/internal/runtime/desktop/@wailsio/runtime/package-lock.json
+++ b/v3/internal/runtime/desktop/@wailsio/runtime/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@wailsio/runtime",
- "version": "3.0.0-alpha.78",
+ "version": "3.0.0-alpha.79",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@wailsio/runtime",
- "version": "3.0.0-alpha.78",
+ "version": "3.0.0-alpha.79",
"license": "MIT",
"devDependencies": {
"happy-dom": "^17.1.1",
diff --git a/v3/internal/runtime/desktop/@wailsio/runtime/package.json b/v3/internal/runtime/desktop/@wailsio/runtime/package.json
index bbbc94976..5e5dcb262 100644
--- a/v3/internal/runtime/desktop/@wailsio/runtime/package.json
+++ b/v3/internal/runtime/desktop/@wailsio/runtime/package.json
@@ -1,7 +1,7 @@
{
"name": "@wailsio/runtime",
"type": "module",
- "version": "3.0.0-alpha.78",
+ "version": "3.0.0-alpha.79",
"description": "Wails Runtime",
"types": "types/index.d.ts",
"exports": {
diff --git a/v3/internal/runtime/desktop/@wailsio/runtime/src/index.ts b/v3/internal/runtime/desktop/@wailsio/runtime/src/index.ts
index 1ea862e31..1cdac37c4 100644
--- a/v3/internal/runtime/desktop/@wailsio/runtime/src/index.ts
+++ b/v3/internal/runtime/desktop/@wailsio/runtime/src/index.ts
@@ -62,8 +62,11 @@ export {
clientId,
} from "./runtime.js";
+import { clientId } from "./runtime.js";
+
// Notify backend
window._wails.invoke = System.invoke;
+window._wails.clientId = clientId;
// Register platform handlers (internal API)
// Note: Window is the thisWindow instance (default export from window.ts)
@@ -76,3 +79,23 @@ window._wails.handleDragLeave = handleDragLeave;
window._wails.handleDragOver = handleDragOver;
System.invoke("wails:runtime:ready");
+
+/**
+ * Loads a script from the given URL if it exists.
+ * Uses HEAD request to check existence, then injects a script tag.
+ * Silently ignores if the script doesn't exist.
+ */
+export function loadOptionalScript(url: string): Promise {
+ return fetch(url, { method: 'HEAD' })
+ .then(response => {
+ if (response.ok) {
+ const script = document.createElement('script');
+ script.src = url;
+ document.head.appendChild(script);
+ }
+ })
+ .catch(() => {}); // Silently ignore - script is optional
+}
+
+// Load custom.js if available (used by server mode for WebSocket events, etc.)
+loadOptionalScript('/wails/custom.js');
diff --git a/v3/internal/templates/_common/Taskfile.tmpl.yml b/v3/internal/templates/_common/Taskfile.tmpl.yml
index 01ac3fb63..acf87581f 100644
--- a/v3/internal/templates/_common/Taskfile.tmpl.yml
+++ b/v3/internal/templates/_common/Taskfile.tmpl.yml
@@ -38,3 +38,23 @@ tasks:
summary: Builds Docker image for cross-compilation (~800MB download)
cmds:
- task: common:setup:docker
+
+ build:server:
+ summary: Builds the application in server mode (no GUI, HTTP server only)
+ cmds:
+ - task: common:build:server
+
+ run:server:
+ summary: Runs the application in server mode
+ cmds:
+ - task: common:run:server
+
+ build:docker:
+ summary: Builds a Docker image for server mode deployment
+ cmds:
+ - task: common:build:docker
+
+ run:docker:
+ summary: Builds and runs the Docker image
+ cmds:
+ - task: common:run:docker
diff --git a/v3/internal/version/version.txt b/v3/internal/version/version.txt
index 9b52e2a36..43725a380 100644
--- a/v3/internal/version/version.txt
+++ b/v3/internal/version/version.txt
@@ -1 +1 @@
-v3.0.0-alpha.61
\ No newline at end of file
+v3.0.0-alpha.66
\ No newline at end of file
diff --git a/v3/pkg/application/application.go b/v3/pkg/application/application.go
index 8483f8166..e31245abf 100644
--- a/v3/pkg/application/application.go
+++ b/v3/pkg/application/application.go
@@ -501,16 +501,6 @@ func (a *App) Capabilities() capabilities.Capabilities {
return a.capabilities
}
-//func (a *App) RegisterListener(listener WailsEventListener) {
-// a.wailsEventListenerLock.Lock()
-// a.wailsEventListeners = append(a.wailsEventListeners, listener)
-// a.wailsEventListenerLock.Unlock()
-//}
-//
-//func (a *App) RegisterServiceHandler(prefix string, handler http.Handler) {
-// a.assets.AttachServiceHandler(prefix, handler)
-//}
-
func (a *App) GetPID() int {
return a.pid
}
diff --git a/v3/pkg/application/application_android.go b/v3/pkg/application/application_android.go
index 87bb1b2f4..ce620a603 100644
--- a/v3/pkg/application/application_android.go
+++ b/v3/pkg/application/application_android.go
@@ -1,4 +1,4 @@
-//go:build android && cgo
+//go:build android && cgo && !server
package application
diff --git a/v3/pkg/application/application_android_nocgo.go b/v3/pkg/application/application_android_nocgo.go
index ea769f3e1..045c5ae3f 100644
--- a/v3/pkg/application/application_android_nocgo.go
+++ b/v3/pkg/application/application_android_nocgo.go
@@ -1,4 +1,4 @@
-//go:build android && !cgo
+//go:build android && !cgo && !server
package application
diff --git a/v3/pkg/application/application_darwin.go b/v3/pkg/application/application_darwin.go
index f5738f93f..287b66e84 100644
--- a/v3/pkg/application/application_darwin.go
+++ b/v3/pkg/application/application_darwin.go
@@ -1,4 +1,4 @@
-//go:build darwin && !ios
+//go:build darwin && !ios && !server
package application
@@ -146,10 +146,31 @@ static char* getAppName(void) {
// get the current window ID
static unsigned int getCurrentWindowID(void) {
- NSWindow *window = [NSApp keyWindow];
- // Get the window delegate
- WebviewWindowDelegate *delegate = (WebviewWindowDelegate*)[window delegate];
- return delegate.windowId;
+ // AppKit must be accessed on the main thread. This function may be called
+ // from arbitrary Go goroutines, so we hop to the main queue when needed.
+ __block unsigned int result = 0;
+ if (NSApp == nil) {
+ return result;
+ }
+ void (^resolve)(void) = ^{
+ NSWindow *window = [NSApp keyWindow];
+ if (window == nil) {
+ window = [NSApp mainWindow];
+ }
+ if (window == nil) {
+ return;
+ }
+ WebviewWindowDelegate *delegate = (WebviewWindowDelegate*)[window delegate];
+ if (delegate != nil) {
+ result = delegate.windowId;
+ }
+ };
+ if ([NSThread isMainThread]) {
+ resolve();
+ } else {
+ dispatch_sync(dispatch_get_main_queue(), resolve);
+ }
+ return result;
}
// Set the application icon
diff --git a/v3/pkg/application/application_ios.go b/v3/pkg/application/application_ios.go
index 90e896b07..6af32bf5e 100644
--- a/v3/pkg/application/application_ios.go
+++ b/v3/pkg/application/application_ios.go
@@ -1,4 +1,4 @@
-//go:build ios
+//go:build ios && !server
package application
diff --git a/v3/pkg/application/application_linux.go b/v3/pkg/application/application_linux.go
index 04924a6c9..ab9c51db6 100644
--- a/v3/pkg/application/application_linux.go
+++ b/v3/pkg/application/application_linux.go
@@ -1,4 +1,4 @@
-//go:build linux && !android
+//go:build linux && !android && !server
package application
diff --git a/v3/pkg/application/application_options.go b/v3/pkg/application/application_options.go
index 5e9eb57e2..946b8733b 100644
--- a/v3/pkg/application/application_options.go
+++ b/v3/pkg/application/application_options.go
@@ -4,6 +4,7 @@ import (
"io/fs"
"log/slog"
"net/http"
+ "time"
"github.com/wailsapp/wails/v3/internal/assetserver"
)
@@ -120,6 +121,56 @@ type Options struct {
//
// Example use case: Implementing WebSocket-based or PostMessage IPC.
Transport Transport
+
+ // Server configures the HTTP server for server mode.
+ // Server mode is enabled by building with the "server" build tag:
+ // go build -tags server
+ //
+ // In server mode, the application runs as an HTTP server without a native window.
+ // This enables deploying the same Wails application as a web server for:
+ // - Docker/container deployments
+ // - Server-side rendering
+ // - Web-only access without desktop dependencies
+ Server ServerOptions
+}
+
+// ServerOptions configures the HTTP server for headless mode.
+type ServerOptions struct {
+ // Host is the address to bind to.
+ // Default: "localhost" for security. Use "0.0.0.0" for all interfaces.
+ Host string
+
+ // Port is the port to listen on.
+ // Default: 8080
+ Port int
+
+ // ReadTimeout is the maximum duration for reading the entire request.
+ // Default: 30 seconds
+ ReadTimeout time.Duration
+
+ // WriteTimeout is the maximum duration before timing out writes of the response.
+ // Default: 30 seconds
+ WriteTimeout time.Duration
+
+ // IdleTimeout is the maximum duration to wait for the next request.
+ // Default: 120 seconds
+ IdleTimeout time.Duration
+
+ // ShutdownTimeout is the maximum duration to wait for active connections to close.
+ // Default: 30 seconds
+ ShutdownTimeout time.Duration
+
+ // TLS configures HTTPS. If nil, HTTP is used.
+ TLS *TLSOptions
+}
+
+// TLSOptions configures HTTPS for the headless server.
+type TLSOptions struct {
+ // CertFile is the path to the TLS certificate file.
+ CertFile string
+
+ // KeyFile is the path to the TLS private key file.
+ KeyFile string
}
// AssetOptions defines the configuration of the AssetServer.
@@ -222,6 +273,14 @@ type WindowsOptions struct {
// Path to the directory with WebView2 executables. If empty WebView2 installed in the system will be used.
WebviewBrowserPath string
+
+ // EnabledFeatures, DisabledFeatures and AdditionalBrowserArgs configure the WebView2 browser.
+ // These apply globally to ALL windows because WebView2 shares a single browser environment.
+ // See: https://learn.microsoft.com/en-us/microsoft-edge/webview2/concepts/webview-features-flags
+ // AdditionalBrowserArgs must include the "--" prefix, e.g. "--remote-debugging-port=9222"
+ EnabledFeatures []string
+ DisabledFeatures []string
+ AdditionalBrowserArgs []string
}
/********* Linux Options *********/
diff --git a/v3/pkg/application/application_server.go b/v3/pkg/application/application_server.go
new file mode 100644
index 000000000..41059242f
--- /dev/null
+++ b/v3/pkg/application/application_server.go
@@ -0,0 +1,554 @@
+//go:build server
+
+package application
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net"
+ "net/http"
+ "os"
+ "os/signal"
+ "strconv"
+ "syscall"
+ "time"
+ "unsafe"
+)
+
+// serverApp implements platformApp for server mode.
+// It provides a minimal implementation that runs an HTTP server
+// without any GUI components.
+//
+// Server mode is enabled by building with the "server" build tag:
+//
+// go build -tags server
+type serverApp struct {
+ app *App
+ server *http.Server
+ listener net.Listener
+ broadcaster *WebSocketBroadcaster
+}
+
+// newPlatformApp creates a new server-mode platform app.
+// This function is only compiled when building with the "server" tag.
+func newPlatformApp(app *App) *serverApp {
+ app.info("Server mode enabled (built with -tags server)")
+ return &serverApp{
+ app: app,
+ }
+}
+
+// parsePort parses a port string into an integer.
+func parsePort(s string) (int, error) {
+ p, err := strconv.Atoi(s)
+ if err != nil {
+ return 0, err
+ }
+ if p < 1 || p > 65535 {
+ return 0, errors.New("port out of range")
+ }
+ return p, nil
+}
+
+// run starts the HTTP server and blocks until shutdown.
+func (h *serverApp) run() error {
+ // Set up common events
+ h.setupCommonEvents()
+
+ // Create WebSocket broadcaster for events
+ h.broadcaster = NewWebSocketBroadcaster(h.app)
+ globalBroadcaster = h.broadcaster // Set global reference for browser ID lookups
+ h.app.wailsEventListenerLock.Lock()
+ h.app.wailsEventListeners = append(h.app.wailsEventListeners, h.broadcaster)
+ h.app.wailsEventListenerLock.Unlock()
+
+ opts := h.app.options.Server
+
+ // Environment variables override config (useful for Docker/containers)
+ host := os.Getenv("WAILS_SERVER_HOST")
+ if host == "" {
+ host = opts.Host
+ }
+ if host == "" {
+ host = "localhost"
+ }
+
+ port := opts.Port
+ if envPort := os.Getenv("WAILS_SERVER_PORT"); envPort != "" {
+ if p, err := parsePort(envPort); err == nil {
+ port = p
+ }
+ }
+ if port == 0 {
+ port = 8080
+ }
+
+ readTimeout := opts.ReadTimeout
+ if readTimeout == 0 {
+ readTimeout = 30 * time.Second
+ }
+
+ writeTimeout := opts.WriteTimeout
+ if writeTimeout == 0 {
+ writeTimeout = 30 * time.Second
+ }
+
+ idleTimeout := opts.IdleTimeout
+ if idleTimeout == 0 {
+ idleTimeout = 120 * time.Second
+ }
+
+ shutdownTimeout := opts.ShutdownTimeout
+ if shutdownTimeout == 0 {
+ shutdownTimeout = 30 * time.Second
+ }
+
+ addr := fmt.Sprintf("%s:%d", host, port)
+
+ // Create HTTP handler from asset server
+ handler := h.createHandler()
+
+ h.server = &http.Server{
+ Addr: addr,
+ Handler: handler,
+ ReadTimeout: readTimeout,
+ WriteTimeout: writeTimeout,
+ IdleTimeout: idleTimeout,
+ }
+
+ // Create listener
+ var err error
+ h.listener, err = net.Listen("tcp", addr)
+ if err != nil {
+ return fmt.Errorf("failed to listen on %s: %w", addr, err)
+ }
+
+ h.app.info("Server mode starting", "address", addr)
+
+ // Start server in goroutine
+ errCh := make(chan error, 1)
+ go func() {
+ if opts.TLS != nil {
+ errCh <- h.server.ServeTLS(h.listener, opts.TLS.CertFile, opts.TLS.KeyFile)
+ } else {
+ errCh <- h.server.Serve(h.listener)
+ }
+ }()
+
+ // Wait for shutdown signal or error
+ quit := make(chan os.Signal, 1)
+ signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
+
+ select {
+ case err := <-errCh:
+ if err != nil && !errors.Is(err, http.ErrServerClosed) {
+ return err
+ }
+ case <-quit:
+ h.app.info("Shutdown signal received")
+ case <-h.app.ctx.Done():
+ h.app.info("Application context cancelled")
+ }
+
+ // Graceful shutdown
+ ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
+ defer cancel()
+
+ if err := h.server.Shutdown(ctx); err != nil {
+ return fmt.Errorf("server shutdown error: %w", err)
+ }
+
+ h.app.info("Server stopped")
+ return nil
+}
+
+// customJS is the JavaScript that sets up WebSocket event connection for server mode.
+// Events FROM frontend TO backend use the existing HTTP transport.
+// This WebSocket is only for receiving broadcast events FROM backend TO all frontends.
+const customJS = `(function() {
+ var protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
+ var clientId = window._wails && window._wails.clientId ? window._wails.clientId : '';
+ var wsUrl = protocol + '//' + location.host + '/wails/events' + (clientId ? '?clientId=' + encodeURIComponent(clientId) : '');
+ var ws;
+
+ function connect() {
+ ws = new WebSocket(wsUrl);
+ ws.onopen = function() {
+ console.log('[Wails] Event WebSocket connected');
+ };
+ ws.onmessage = function(e) {
+ try {
+ var event = JSON.parse(e.data);
+ if (window._wails && window._wails.dispatchWailsEvent) {
+ window._wails.dispatchWailsEvent(event);
+ }
+ } catch (err) {
+ console.error('[Wails] Failed to parse event:', err);
+ }
+ };
+ ws.onclose = function() {
+ console.log('[Wails] Event WebSocket disconnected, reconnecting...');
+ setTimeout(connect, 1000);
+ };
+ ws.onerror = function() {
+ ws.close();
+ };
+ }
+
+ connect();
+})();`
+
+// createHandler creates the HTTP handler for server mode.
+func (h *serverApp) createHandler() http.Handler {
+ mux := http.NewServeMux()
+
+ // Health check endpoint
+ mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(`{"status":"ok"}`))
+ })
+
+ // Serve custom.js for server mode (WebSocket event connection)
+ mux.HandleFunc("/wails/custom.js", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/javascript")
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(customJS))
+ })
+
+ // WebSocket endpoint for events
+ mux.Handle("/wails/events", h.broadcaster)
+
+ // Serve all other requests through the asset server
+ mux.Handle("/", h.app.assets)
+
+ return mux
+}
+
+// destroy stops the server and cleans up.
+func (h *serverApp) destroy() {
+ if h.server != nil {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ h.server.Shutdown(ctx)
+ }
+ h.app.cleanup()
+}
+
+// setApplicationMenu is a no-op in server mode.
+func (h *serverApp) setApplicationMenu(menu *Menu) {
+ // No-op: server mode has no GUI
+}
+
+// name returns the application name.
+func (h *serverApp) name() string {
+ return h.app.options.Name
+}
+
+// getCurrentWindowID returns 0 in server mode (no windows).
+func (h *serverApp) getCurrentWindowID() uint {
+ return 0
+}
+
+// showAboutDialog is a no-op in server mode.
+func (h *serverApp) showAboutDialog(name string, description string, icon []byte) {
+ // No-op: server mode has no GUI
+ h.app.warning("showAboutDialog called in server mode - operation ignored")
+}
+
+// setIcon is a no-op in server mode.
+func (h *serverApp) setIcon(icon []byte) {
+ // No-op: server mode has no GUI
+}
+
+// on is a no-op in server mode.
+func (h *serverApp) on(id uint) {
+ // No-op: server mode has no platform-specific event handling
+}
+
+// dispatchOnMainThread executes the function directly in server mode.
+func (h *serverApp) dispatchOnMainThread(id uint) {
+ // In server mode, there's no "main thread" concept from GUI frameworks
+ // Execute the function directly
+ mainThreadFunctionStoreLock.Lock()
+ fn, ok := mainThreadFunctionStore[id]
+ if ok {
+ delete(mainThreadFunctionStore, id)
+ }
+ mainThreadFunctionStoreLock.Unlock()
+
+ if ok && fn != nil {
+ fn()
+ }
+}
+
+// hide is a no-op in server mode.
+func (h *serverApp) hide() {
+ // No-op: server mode has no GUI
+}
+
+// show is a no-op in server mode.
+func (h *serverApp) show() {
+ // No-op: server mode has no GUI
+}
+
+// getPrimaryScreen returns nil in server mode.
+func (h *serverApp) getPrimaryScreen() (*Screen, error) {
+ return nil, errors.New("screen information not available in server mode")
+}
+
+// getScreens returns an error in server mode (screen info unavailable).
+func (h *serverApp) getScreens() ([]*Screen, error) {
+ return nil, errors.New("screen information not available in server mode")
+}
+
+// GetFlags returns the application flags for server mode.
+func (h *serverApp) GetFlags(options Options) map[string]any {
+ flags := make(map[string]any)
+ flags["server"] = true
+ if options.Flags != nil {
+ for k, v := range options.Flags {
+ flags[k] = v
+ }
+ }
+ return flags
+}
+
+// isOnMainThread always returns true in server mode.
+func (h *serverApp) isOnMainThread() bool {
+ // In server mode, there's no main thread concept
+ return true
+}
+
+// isDarkMode returns false in server mode.
+func (h *serverApp) isDarkMode() bool {
+ return false
+}
+
+// getAccentColor returns empty string in server mode.
+func (h *serverApp) getAccentColor() string {
+ return ""
+}
+
+// logPlatformInfo logs platform info for server mode.
+func (a *App) logPlatformInfo() {
+ a.info("Platform Info:", "mode", "server")
+}
+
+// platformEnvironment returns environment info for server mode.
+func (a *App) platformEnvironment() map[string]any {
+ return map[string]any{
+ "mode": "server",
+ }
+}
+
+// fatalHandler sets up fatal error handling for server mode.
+func fatalHandler(errFunc func(error)) {
+ // In server mode, fatal errors are handled via standard mechanisms
+}
+
+// newClipboardImpl creates a clipboard implementation for server mode.
+func newClipboardImpl() clipboardImpl {
+ return &serverClipboard{}
+}
+
+// serverClipboard is a no-op clipboard for server mode.
+type serverClipboard struct{}
+
+func (c *serverClipboard) setText(text string) bool {
+ return false
+}
+
+func (c *serverClipboard) text() (string, bool) {
+ return "", false
+}
+
+// newDialogImpl creates a dialog implementation for server mode.
+func newDialogImpl(d *MessageDialog) messageDialogImpl {
+ return &serverDialog{}
+}
+
+// serverDialog is a no-op dialog for server mode.
+type serverDialog struct{}
+
+func (d *serverDialog) show() {
+ // No-op in server mode
+}
+
+// newOpenFileDialogImpl creates an open file dialog implementation for server mode.
+func newOpenFileDialogImpl(d *OpenFileDialogStruct) openFileDialogImpl {
+ return &serverOpenFileDialog{}
+}
+
+// serverOpenFileDialog is a no-op open file dialog for server mode.
+type serverOpenFileDialog struct{}
+
+func (d *serverOpenFileDialog) show() (chan string, error) {
+ ch := make(chan string, 1)
+ close(ch)
+ return ch, errors.New("file dialogs not available in server mode")
+}
+
+// newSaveFileDialogImpl creates a save file dialog implementation for server mode.
+func newSaveFileDialogImpl(d *SaveFileDialogStruct) saveFileDialogImpl {
+ return &serverSaveFileDialog{}
+}
+
+// serverSaveFileDialog is a no-op save file dialog for server mode.
+type serverSaveFileDialog struct{}
+
+func (d *serverSaveFileDialog) show() (chan string, error) {
+ ch := make(chan string, 1)
+ close(ch)
+ return ch, errors.New("file dialogs not available in server mode")
+}
+
+// newMenuImpl creates a menu implementation for server mode.
+func newMenuImpl(menu *Menu) menuImpl {
+ return &serverMenu{}
+}
+
+// serverMenu is a no-op menu for server mode.
+type serverMenu struct{}
+
+func (m *serverMenu) update() {
+ // No-op in server mode
+}
+
+// newPlatformLock creates a platform-specific single instance lock for server mode.
+func newPlatformLock(manager *singleInstanceManager) (platformLock, error) {
+ return &serverLock{}, nil
+}
+
+// serverLock is a basic lock for server mode.
+type serverLock struct{}
+
+func (l *serverLock) acquire(uniqueID string) error {
+ return nil
+}
+
+func (l *serverLock) release() {
+ // No-op in server mode
+}
+
+func (l *serverLock) notify(data string) error {
+ return errors.New("single instance not supported in server mode")
+}
+
+// newSystemTrayImpl creates a system tray implementation for server mode.
+func newSystemTrayImpl(s *SystemTray) systemTrayImpl {
+ return &serverSystemTray{parent: s}
+}
+
+// serverSystemTray is a no-op system tray for server mode.
+type serverSystemTray struct {
+ parent *SystemTray
+}
+
+func (t *serverSystemTray) setLabel(label string) {}
+func (t *serverSystemTray) setTooltip(tooltip string) {}
+func (t *serverSystemTray) run() {}
+func (t *serverSystemTray) setIcon(icon []byte) {}
+func (t *serverSystemTray) setMenu(menu *Menu) {}
+func (t *serverSystemTray) setIconPosition(pos IconPosition) {}
+func (t *serverSystemTray) setTemplateIcon(icon []byte) {}
+func (t *serverSystemTray) destroy() {}
+func (t *serverSystemTray) setDarkModeIcon(icon []byte) {}
+func (t *serverSystemTray) bounds() (*Rect, error) { return nil, errors.New("system tray not available in server mode") }
+func (t *serverSystemTray) getScreen() (*Screen, error) { return nil, errors.New("system tray not available in server mode") }
+func (t *serverSystemTray) positionWindow(w Window, o int) error { return errors.New("system tray not available in server mode") }
+func (t *serverSystemTray) openMenu() {}
+func (t *serverSystemTray) Show() {}
+func (t *serverSystemTray) Hide() {}
+
+// newWindowImpl creates a webview window implementation for server mode.
+func newWindowImpl(parent *WebviewWindow) *serverWebviewWindow {
+ return &serverWebviewWindow{parent: parent}
+}
+
+// serverWebviewWindow is a no-op webview window for server mode.
+type serverWebviewWindow struct {
+ parent *WebviewWindow
+}
+
+// All webviewWindowImpl methods as no-ops for server mode
+func (w *serverWebviewWindow) setTitle(title string) {}
+func (w *serverWebviewWindow) setSize(width, height int) {}
+func (w *serverWebviewWindow) setAlwaysOnTop(alwaysOnTop bool) {}
+func (w *serverWebviewWindow) setURL(url string) {}
+func (w *serverWebviewWindow) setResizable(resizable bool) {}
+func (w *serverWebviewWindow) setMinSize(width, height int) {}
+func (w *serverWebviewWindow) setMaxSize(width, height int) {}
+func (w *serverWebviewWindow) execJS(js string) {}
+func (w *serverWebviewWindow) setBackgroundColour(color RGBA) {}
+func (w *serverWebviewWindow) run() {}
+func (w *serverWebviewWindow) center() {}
+func (w *serverWebviewWindow) size() (int, int) { return 0, 0 }
+func (w *serverWebviewWindow) width() int { return 0 }
+func (w *serverWebviewWindow) height() int { return 0 }
+func (w *serverWebviewWindow) destroy() {}
+func (w *serverWebviewWindow) reload() {}
+func (w *serverWebviewWindow) forceReload() {}
+func (w *serverWebviewWindow) openDevTools() {}
+func (w *serverWebviewWindow) zoomReset() {}
+func (w *serverWebviewWindow) zoomIn() {}
+func (w *serverWebviewWindow) zoomOut() {}
+func (w *serverWebviewWindow) getZoom() float64 { return 1.0 }
+func (w *serverWebviewWindow) setZoom(zoom float64) {}
+func (w *serverWebviewWindow) close() {}
+func (w *serverWebviewWindow) zoom() {}
+func (w *serverWebviewWindow) setHTML(html string) {}
+func (w *serverWebviewWindow) on(eventID uint) {}
+func (w *serverWebviewWindow) minimise() {}
+func (w *serverWebviewWindow) unminimise() {}
+func (w *serverWebviewWindow) maximise() {}
+func (w *serverWebviewWindow) unmaximise() {}
+func (w *serverWebviewWindow) fullscreen() {}
+func (w *serverWebviewWindow) unfullscreen() {}
+func (w *serverWebviewWindow) isMinimised() bool { return false }
+func (w *serverWebviewWindow) isMaximised() bool { return false }
+func (w *serverWebviewWindow) isFullscreen() bool { return false }
+func (w *serverWebviewWindow) isNormal() bool { return true }
+func (w *serverWebviewWindow) isVisible() bool { return false }
+func (w *serverWebviewWindow) isFocused() bool { return false }
+func (w *serverWebviewWindow) focus() {}
+func (w *serverWebviewWindow) show() {}
+func (w *serverWebviewWindow) hide() {}
+func (w *serverWebviewWindow) getScreen() (*Screen, error) { return nil, errors.New("screens not available in server mode") }
+func (w *serverWebviewWindow) setFrameless(frameless bool) {}
+func (w *serverWebviewWindow) openContextMenu(menu *Menu, data *ContextMenuData) {}
+func (w *serverWebviewWindow) nativeWindow() unsafe.Pointer { return nil }
+func (w *serverWebviewWindow) startDrag() error { return errors.New("drag not available in server mode") }
+func (w *serverWebviewWindow) startResize(border string) error { return errors.New("resize not available in server mode") }
+func (w *serverWebviewWindow) print() error { return errors.New("print not available in server mode") }
+func (w *serverWebviewWindow) setEnabled(enabled bool) {}
+func (w *serverWebviewWindow) physicalBounds() Rect { return Rect{} }
+func (w *serverWebviewWindow) setPhysicalBounds(bounds Rect) {}
+func (w *serverWebviewWindow) bounds() Rect { return Rect{} }
+func (w *serverWebviewWindow) setBounds(bounds Rect) {}
+func (w *serverWebviewWindow) position() (int, int) { return 0, 0 }
+func (w *serverWebviewWindow) setPosition(x int, y int) {}
+func (w *serverWebviewWindow) relativePosition() (int, int) { return 0, 0 }
+func (w *serverWebviewWindow) setRelativePosition(x int, y int) {}
+func (w *serverWebviewWindow) flash(enabled bool) {}
+func (w *serverWebviewWindow) handleKeyEvent(acceleratorString string) {}
+func (w *serverWebviewWindow) getBorderSizes() *LRTB { return &LRTB{} }
+func (w *serverWebviewWindow) setMinimiseButtonState(state ButtonState) {}
+func (w *serverWebviewWindow) setMaximiseButtonState(state ButtonState) {}
+func (w *serverWebviewWindow) setCloseButtonState(state ButtonState) {}
+func (w *serverWebviewWindow) isIgnoreMouseEvents() bool { return false }
+func (w *serverWebviewWindow) setIgnoreMouseEvents(ignore bool) {}
+func (w *serverWebviewWindow) cut() {}
+func (w *serverWebviewWindow) copy() {}
+func (w *serverWebviewWindow) paste() {}
+func (w *serverWebviewWindow) undo() {}
+func (w *serverWebviewWindow) delete() {}
+func (w *serverWebviewWindow) selectAll() {}
+func (w *serverWebviewWindow) redo() {}
+func (w *serverWebviewWindow) showMenuBar() {}
+func (w *serverWebviewWindow) hideMenuBar() {}
+func (w *serverWebviewWindow) toggleMenuBar() {}
+func (w *serverWebviewWindow) setMenu(menu *Menu) {}
+func (w *serverWebviewWindow) snapAssist() {}
+func (w *serverWebviewWindow) setContentProtection(enabled bool) {}
diff --git a/v3/pkg/application/application_server_test.go b/v3/pkg/application/application_server_test.go
new file mode 100644
index 000000000..0bb4780dd
--- /dev/null
+++ b/v3/pkg/application/application_server_test.go
@@ -0,0 +1,168 @@
+//go:build server
+
+package application
+
+import (
+ "context"
+ "net/http"
+ "sync"
+ "testing"
+ "time"
+)
+
+// resetGlobalApp resets the global application state for testing
+func resetGlobalApp() {
+ globalApplication = nil
+}
+
+func TestServerMode_HealthEndpoint(t *testing.T) {
+ resetGlobalApp()
+
+ // Create a server mode app (server mode is enabled via build tag)
+ app := New(Options{
+ Name: "Test Server",
+ Server: ServerOptions{
+ Host: "127.0.0.1",
+ Port: 18081, // Use specific port for this test
+ },
+ Assets: AssetOptions{
+ Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("OK"))
+ }),
+ },
+ })
+
+ // Start app in background
+ var wg sync.WaitGroup
+ wg.Add(1)
+ errCh := make(chan error, 1)
+ go func() {
+ defer wg.Done()
+ errCh <- app.Run()
+ }()
+
+ // Wait for server to start
+ time.Sleep(200 * time.Millisecond)
+
+ // Test health endpoint
+ resp, err := http.Get("http://127.0.0.1:18081/health")
+ if err != nil {
+ t.Fatalf("health check failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("expected status 200, got %d", resp.StatusCode)
+ }
+
+ // Shutdown
+ app.Quit()
+
+ // Wait for shutdown
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+
+ select {
+ case err := <-errCh:
+ if err != nil {
+ t.Errorf("app.Run() returned error: %v", err)
+ }
+ case <-ctx.Done():
+ t.Error("timeout waiting for app shutdown")
+ }
+}
+
+func TestServerMode_AssetServing(t *testing.T) {
+ resetGlobalApp()
+
+ testContent := "Hello from server mode!"
+
+ app := New(Options{
+ Name: "Test Assets",
+ Server: ServerOptions{
+ Host: "127.0.0.1",
+ Port: 18082, // Use specific port for this test
+ },
+ Assets: AssetOptions{
+ Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(testContent))
+ }),
+ },
+ })
+
+ errCh := make(chan error, 1)
+ go func() {
+ errCh <- app.Run()
+ }()
+
+ time.Sleep(200 * time.Millisecond)
+
+ // Test asset serving
+ resp, err := http.Get("http://127.0.0.1:18082/")
+ if err != nil {
+ t.Fatalf("request failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("expected status 200, got %d", resp.StatusCode)
+ }
+
+ app.Quit()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+
+ select {
+ case <-errCh:
+ case <-ctx.Done():
+ t.Error("timeout waiting for app shutdown")
+ }
+}
+
+func TestServerMode_Defaults(t *testing.T) {
+ resetGlobalApp()
+
+ app := New(Options{
+ Name: "Test Defaults",
+ Server: ServerOptions{
+ Port: 18083, // Use specific port to avoid conflicts
+ },
+ Assets: AssetOptions{
+ Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }),
+ },
+ })
+
+ errCh := make(chan error, 1)
+ go func() {
+ errCh <- app.Run()
+ }()
+
+ time.Sleep(200 * time.Millisecond)
+
+ // Should be listening on localhost:18083
+ resp, err := http.Get("http://localhost:18083/health")
+ if err != nil {
+ t.Fatalf("request to address failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("expected status 200, got %d", resp.StatusCode)
+ }
+
+ app.Quit()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+
+ select {
+ case <-errCh:
+ case <-ctx.Done():
+ t.Error("timeout waiting for app shutdown")
+ }
+}
diff --git a/v3/pkg/application/application_windows.go b/v3/pkg/application/application_windows.go
index 09dad6acf..3b93b9a9c 100644
--- a/v3/pkg/application/application_windows.go
+++ b/v3/pkg/application/application_windows.go
@@ -1,4 +1,4 @@
-//go:build windows
+//go:build windows && !server
package application
diff --git a/v3/pkg/application/browser_window.go b/v3/pkg/application/browser_window.go
new file mode 100644
index 000000000..5d3749db0
--- /dev/null
+++ b/v3/pkg/application/browser_window.go
@@ -0,0 +1,149 @@
+//go:build server
+
+package application
+
+import (
+ "fmt"
+ "unsafe"
+
+ "github.com/wailsapp/wails/v3/pkg/events"
+)
+
+// BrowserWindow represents a browser client connection in server mode.
+// It implements the Window interface so browser clients can be treated
+// uniformly with native windows throughout the codebase.
+type BrowserWindow struct {
+ id uint
+ name string
+ clientID string // The runtime's nanoid for this client
+}
+
+// NewBrowserWindow creates a new browser window with the given ID.
+func NewBrowserWindow(id uint, clientID string) *BrowserWindow {
+ return &BrowserWindow{
+ id: id,
+ name: fmt.Sprintf("browser-%d", id),
+ clientID: clientID,
+ }
+}
+
+// Core identification methods
+
+func (b *BrowserWindow) ID() uint { return b.id }
+func (b *BrowserWindow) Name() string { return b.name }
+func (b *BrowserWindow) ClientID() string { return b.clientID }
+
+// Event methods - these are meaningful for browser windows
+
+func (b *BrowserWindow) DispatchWailsEvent(event *CustomEvent) {
+ // Events are dispatched via WebSocket broadcast, not per-window
+}
+
+func (b *BrowserWindow) EmitEvent(name string, data ...any) bool {
+ return globalApplication.Event.Emit(name, data...)
+}
+
+// Logging methods
+
+func (b *BrowserWindow) Error(message string, args ...any) {
+ globalApplication.error(message, args...)
+}
+
+func (b *BrowserWindow) Info(message string, args ...any) {
+ globalApplication.info(message, args...)
+}
+
+// No-op methods - these don't apply to browser windows
+
+func (b *BrowserWindow) Center() {}
+func (b *BrowserWindow) Close() {}
+func (b *BrowserWindow) DisableSizeConstraints() {}
+func (b *BrowserWindow) EnableSizeConstraints() {}
+func (b *BrowserWindow) ExecJS(js string) {}
+func (b *BrowserWindow) Focus() {}
+func (b *BrowserWindow) ForceReload() {}
+func (b *BrowserWindow) Fullscreen() Window { return b }
+func (b *BrowserWindow) GetBorderSizes() *LRTB { return nil }
+func (b *BrowserWindow) GetScreen() (*Screen, error) { return nil, nil }
+func (b *BrowserWindow) GetZoom() float64 { return 1.0 }
+func (b *BrowserWindow) handleDragAndDropMessage(filenames []string, dropTarget *DropTargetDetails) {}
+func (b *BrowserWindow) InitiateFrontendDropProcessing(filenames []string, x int, y int) {}
+func (b *BrowserWindow) HandleMessage(message string) {}
+func (b *BrowserWindow) HandleWindowEvent(id uint) {}
+func (b *BrowserWindow) Height() int { return 0 }
+func (b *BrowserWindow) Hide() Window { return b }
+func (b *BrowserWindow) HideMenuBar() {}
+func (b *BrowserWindow) IsFocused() bool { return false }
+func (b *BrowserWindow) IsFullscreen() bool { return false }
+func (b *BrowserWindow) IsIgnoreMouseEvents() bool { return false }
+func (b *BrowserWindow) IsMaximised() bool { return false }
+func (b *BrowserWindow) IsMinimised() bool { return false }
+func (b *BrowserWindow) HandleKeyEvent(acceleratorString string) {}
+func (b *BrowserWindow) Maximise() Window { return b }
+func (b *BrowserWindow) Minimise() Window { return b }
+func (b *BrowserWindow) OnWindowEvent(eventType events.WindowEventType, callback func(event *WindowEvent)) func() {
+ return func() {}
+}
+func (b *BrowserWindow) OpenContextMenu(data *ContextMenuData) {}
+func (b *BrowserWindow) Position() (int, int) { return 0, 0 }
+func (b *BrowserWindow) RelativePosition() (int, int) { return 0, 0 }
+func (b *BrowserWindow) Reload() {}
+func (b *BrowserWindow) Resizable() bool { return false }
+func (b *BrowserWindow) Restore() {}
+func (b *BrowserWindow) Run() {}
+func (b *BrowserWindow) SetPosition(x, y int) {}
+func (b *BrowserWindow) SetAlwaysOnTop(b2 bool) Window { return b }
+func (b *BrowserWindow) SetBackgroundColour(colour RGBA) Window { return b }
+func (b *BrowserWindow) SetFrameless(frameless bool) Window { return b }
+func (b *BrowserWindow) SetHTML(html string) Window { return b }
+func (b *BrowserWindow) SetMinimiseButtonState(state ButtonState) Window { return b }
+func (b *BrowserWindow) SetMaximiseButtonState(state ButtonState) Window { return b }
+func (b *BrowserWindow) SetCloseButtonState(state ButtonState) Window { return b }
+func (b *BrowserWindow) SetMaxSize(maxWidth, maxHeight int) Window { return b }
+func (b *BrowserWindow) SetMinSize(minWidth, minHeight int) Window { return b }
+func (b *BrowserWindow) SetRelativePosition(x, y int) Window { return b }
+func (b *BrowserWindow) SetResizable(b2 bool) Window { return b }
+func (b *BrowserWindow) SetIgnoreMouseEvents(ignore bool) Window { return b }
+func (b *BrowserWindow) SetSize(width, height int) Window { return b }
+func (b *BrowserWindow) SetTitle(title string) Window { return b }
+func (b *BrowserWindow) SetURL(s string) Window { return b }
+func (b *BrowserWindow) SetZoom(magnification float64) Window { return b }
+func (b *BrowserWindow) Show() Window { return b }
+func (b *BrowserWindow) ShowMenuBar() {}
+func (b *BrowserWindow) Size() (width int, height int) { return 0, 0 }
+func (b *BrowserWindow) OpenDevTools() {}
+func (b *BrowserWindow) ToggleFullscreen() {}
+func (b *BrowserWindow) ToggleMaximise() {}
+func (b *BrowserWindow) ToggleMenuBar() {}
+func (b *BrowserWindow) ToggleFrameless() {}
+func (b *BrowserWindow) UnFullscreen() {}
+func (b *BrowserWindow) UnMaximise() {}
+func (b *BrowserWindow) UnMinimise() {}
+func (b *BrowserWindow) Width() int { return 0 }
+func (b *BrowserWindow) IsVisible() bool { return true }
+func (b *BrowserWindow) Bounds() Rect { return Rect{} }
+func (b *BrowserWindow) SetBounds(bounds Rect) {}
+func (b *BrowserWindow) Zoom() {}
+func (b *BrowserWindow) ZoomIn() {}
+func (b *BrowserWindow) ZoomOut() {}
+func (b *BrowserWindow) ZoomReset() Window { return b }
+func (b *BrowserWindow) SetMenu(menu *Menu) {}
+func (b *BrowserWindow) SnapAssist() {}
+func (b *BrowserWindow) SetContentProtection(protection bool) Window { return b }
+func (b *BrowserWindow) NativeWindow() unsafe.Pointer { return nil }
+func (b *BrowserWindow) SetEnabled(enabled bool) {}
+func (b *BrowserWindow) Flash(enabled bool) {}
+func (b *BrowserWindow) Print() error { return nil }
+func (b *BrowserWindow) RegisterHook(eventType events.WindowEventType, callback func(event *WindowEvent)) func() {
+ return func() {}
+}
+func (b *BrowserWindow) shouldUnconditionallyClose() bool { return true }
+
+// Editing methods
+func (b *BrowserWindow) cut() {}
+func (b *BrowserWindow) copy() {}
+func (b *BrowserWindow) paste() {}
+func (b *BrowserWindow) undo() {}
+func (b *BrowserWindow) redo() {}
+func (b *BrowserWindow) delete() {}
+func (b *BrowserWindow) selectAll() {}
diff --git a/v3/pkg/application/clipboard_linux.go b/v3/pkg/application/clipboard_linux.go
index 97085260b..cf1366605 100644
--- a/v3/pkg/application/clipboard_linux.go
+++ b/v3/pkg/application/clipboard_linux.go
@@ -1,4 +1,4 @@
-//go:build linux && !android
+//go:build linux && !android && !server
package application
diff --git a/v3/pkg/application/dialogs_darwin.go b/v3/pkg/application/dialogs_darwin.go
index 282f4a41e..5ed6f4ab6 100644
--- a/v3/pkg/application/dialogs_darwin.go
+++ b/v3/pkg/application/dialogs_darwin.go
@@ -392,13 +392,13 @@ func (m *macosDialog) show() {
}
var iconData unsafe.Pointer
var iconLength C.int
- if m.dialog.Icon != nil {
+ if len(m.dialog.Icon) > 0 {
iconData = unsafe.Pointer(&m.dialog.Icon[0])
iconLength = C.int(len(m.dialog.Icon))
} else {
// if it's an error, use the application Icon
if m.dialog.DialogType == ErrorDialogType {
- if globalApplication.options.Icon != nil {
+ if len(globalApplication.options.Icon) > 0 {
iconData = unsafe.Pointer(&globalApplication.options.Icon[0])
iconLength = C.int(len(globalApplication.options.Icon))
}
diff --git a/v3/pkg/application/dialogs_linux.go b/v3/pkg/application/dialogs_linux.go
index a64c3f83f..1b1c8e4b4 100644
--- a/v3/pkg/application/dialogs_linux.go
+++ b/v3/pkg/application/dialogs_linux.go
@@ -1,4 +1,4 @@
-//go:build linux && !android
+//go:build linux && !android && !server
package application
diff --git a/v3/pkg/application/events.go b/v3/pkg/application/events.go
index dbe6d20a2..577001dbe 100644
--- a/v3/pkg/application/events.go
+++ b/v3/pkg/application/events.go
@@ -76,8 +76,8 @@ func (e *CustomEvent) ToJSON() string {
return string(marshal)
}
-// WailsEventListener is an interface that can be implemented to listen for Wails events
-// It is used by the RegisterListener method of the Application.
+// WailsEventListener is an interface for receiving all emitted Wails events.
+// Used by transport layers (IPC, WebSocket) to broadcast events.
type WailsEventListener interface {
DispatchWailsEvent(event *CustomEvent)
}
diff --git a/v3/pkg/application/events_common_linux.go b/v3/pkg/application/events_common_linux.go
index 8fbbbaa57..ddff94b7f 100644
--- a/v3/pkg/application/events_common_linux.go
+++ b/v3/pkg/application/events_common_linux.go
@@ -1,4 +1,4 @@
-//go:build linux && !android
+//go:build linux && !android && !server
package application
diff --git a/v3/pkg/application/events_common_server.go b/v3/pkg/application/events_common_server.go
new file mode 100644
index 000000000..bde9941eb
--- /dev/null
+++ b/v3/pkg/application/events_common_server.go
@@ -0,0 +1,10 @@
+//go:build server
+
+package application
+
+// setupCommonEvents sets up common application events for server mode.
+// In server mode, there are no platform-specific events to map,
+// so this is a no-op.
+func (h *serverApp) setupCommonEvents() {
+ // No-op: server mode has no platform-specific events to map
+}
diff --git a/v3/pkg/application/keys_linux.go b/v3/pkg/application/keys_linux.go
index 9ae8dd8bf..542fba95f 100644
--- a/v3/pkg/application/keys_linux.go
+++ b/v3/pkg/application/keys_linux.go
@@ -1,4 +1,4 @@
-//go:build linux && !android
+//go:build linux && !android && !server
package application
diff --git a/v3/pkg/application/linux_cgo.go b/v3/pkg/application/linux_cgo.go
index 4d5ce4c71..ee5df0af3 100644
--- a/v3/pkg/application/linux_cgo.go
+++ b/v3/pkg/application/linux_cgo.go
@@ -1,4 +1,4 @@
-//go:build linux && cgo && !android
+//go:build linux && cgo && !android && !server
package application
@@ -663,8 +663,15 @@ func (a *linuxApp) showAllWindows() {
}
func (a *linuxApp) setIcon(icon []byte) {
- gbytes := C.g_bytes_new_static(C.gconstpointer(unsafe.Pointer(&icon[0])), C.ulong(len(icon)))
+ if len(icon) == 0 {
+ return
+ }
+ // Use g_bytes_new instead of g_bytes_new_static because Go memory can be
+ // moved or freed by the GC. g_bytes_new copies the data to C-owned memory.
+ gbytes := C.g_bytes_new(C.gconstpointer(unsafe.Pointer(&icon[0])), C.ulong(len(icon)))
+ defer C.g_bytes_unref(gbytes)
stream := C.g_memory_input_stream_new_from_bytes(gbytes)
+ defer C.g_object_unref(C.gpointer(stream))
var gerror *C.GError
pixbuf := C.gdk_pixbuf_new_from_stream(stream, nil, &gerror)
if gerror != nil {
@@ -831,8 +838,10 @@ func menuItemAddProperties(menuItem *C.GtkWidget, label string, bitmap []byte) p
(*C.GtkWidget)(unsafe.Pointer(menuItem)))
box := C.gtk_box_new(C.GTK_ORIENTATION_HORIZONTAL, 6)
- if img, err := pngToImage(bitmap); err == nil {
- gbytes := C.g_bytes_new_static(C.gconstpointer(unsafe.Pointer(&img.Pix[0])),
+ if img, err := pngToImage(bitmap); err == nil && len(img.Pix) > 0 {
+ // Use g_bytes_new instead of g_bytes_new_static because Go memory can be
+ // moved or freed by the GC. g_bytes_new copies the data to C-owned memory.
+ gbytes := C.g_bytes_new(C.gconstpointer(unsafe.Pointer(&img.Pix[0])),
C.ulong(len(img.Pix)))
defer C.g_bytes_unref(gbytes)
pixBuf := C.gdk_pixbuf_new_from_bytes(
@@ -911,8 +920,10 @@ func menuItemRemoveBitmap(widget pointer) {
func menuItemSetBitmap(widget pointer, bitmap []byte) {
menuItemRemoveBitmap(widget)
box := C.gtk_bin_get_child((*C.GtkBin)(widget))
- if img, err := pngToImage(bitmap); err == nil {
- gbytes := C.g_bytes_new_static(C.gconstpointer(unsafe.Pointer(&img.Pix[0])),
+ if img, err := pngToImage(bitmap); err == nil && len(img.Pix) > 0 {
+ // Use g_bytes_new instead of g_bytes_new_static because Go memory can be
+ // moved or freed by the GC. g_bytes_new copies the data to C-owned memory.
+ gbytes := C.g_bytes_new(C.gconstpointer(unsafe.Pointer(&img.Pix[0])),
C.ulong(len(img.Pix)))
defer C.g_bytes_unref(gbytes)
pixBuf := C.gdk_pixbuf_new_from_bytes(
@@ -2106,25 +2117,27 @@ func runChooserDialog(window pointer, allowMultiple, createFolders, showHidden b
// run this on the gtk thread
InvokeAsync(func() {
response := C.gtk_dialog_run((*C.GtkDialog)(fc))
+ // Extract results on GTK thread BEFORE destroying widget
+ var results []string
+ if response == C.GTK_RESPONSE_ACCEPT {
+ // No artificial limit - consistent with Windows/macOS behavior
+ filenames := C.gtk_file_chooser_get_filenames((*C.GtkFileChooser)(fc))
+ for iter := filenames; iter != nil; iter = iter.next {
+ results = append(results, buildStringAndFree(C.gpointer(iter.data)))
+ }
+ C.g_slist_free(filenames)
+ }
+ // Destroy widget after extracting results (on GTK thread)
+ C.gtk_widget_destroy((*C.GtkWidget)(unsafe.Pointer(fc)))
+ // Send results from goroutine (safe - no GTK calls)
go func() {
defer handlePanic()
- if response == C.GTK_RESPONSE_ACCEPT {
- filenames := C.gtk_file_chooser_get_filenames((*C.GtkFileChooser)(fc))
- iter := filenames
- count := 0
- for {
- selections <- buildStringAndFree(C.gpointer(iter.data))
- iter = iter.next
- if iter == nil || count == 1024 {
- break
- }
- count++
- }
+ for _, result := range results {
+ selections <- result
}
close(selections)
}()
})
- C.gtk_widget_destroy((*C.GtkWidget)(unsafe.Pointer(fc)))
return selections, nil
}
@@ -2191,8 +2204,10 @@ func runQuestionDialog(parent pointer, options *MessageDialog) int {
cTitle)
}
- if img, err := pngToImage(options.Icon); err == nil {
- gbytes := C.g_bytes_new_static(
+ if img, err := pngToImage(options.Icon); err == nil && len(img.Pix) > 0 {
+ // Use g_bytes_new instead of g_bytes_new_static because Go memory can be
+ // moved or freed by the GC. g_bytes_new copies the data to C-owned memory.
+ gbytes := C.g_bytes_new(
C.gconstpointer(unsafe.Pointer(&img.Pix[0])),
C.ulong(len(img.Pix)))
defer C.g_bytes_unref(gbytes)
diff --git a/v3/pkg/application/linux_purego.go b/v3/pkg/application/linux_purego.go
index d59d74ac9..7b2e17235 100644
--- a/v3/pkg/application/linux_purego.go
+++ b/v3/pkg/application/linux_purego.go
@@ -101,6 +101,7 @@ var (
gApplicationName func() string
gApplicationRelease func(pointer)
gApplicationRun func(pointer, int, []string) int
+ gBytesNew func(uintptr, int) uintptr
gBytesNewStatic func(uintptr, int) uintptr
gBytesUnref func(uintptr)
gFree func(pointer)
@@ -259,6 +260,7 @@ func init() {
purego.RegisterLibFunc(&gApplicationQuit, gtk, "g_application_quit")
purego.RegisterLibFunc(&gApplicationRelease, gtk, "g_application_release")
purego.RegisterLibFunc(&gApplicationRun, gtk, "g_application_run")
+ purego.RegisterLibFunc(&gBytesNew, gtk, "g_bytes_new")
purego.RegisterLibFunc(&gBytesNewStatic, gtk, "g_bytes_new_static")
purego.RegisterLibFunc(&gBytesUnref, gtk, "g_bytes_unref")
purego.RegisterLibFunc(&gFree, gtk, "g_free")
@@ -1191,8 +1193,10 @@ func runQuestionDialog(parent pointer, options *MessageDialog) int {
GdkColorspaceRGB := 0
- if img, err := pngToImage(options.Icon); err == nil {
- gbytes := gBytesNewStatic(uintptr(unsafe.Pointer(&img.Pix[0])), len(img.Pix))
+ if img, err := pngToImage(options.Icon); err == nil && len(img.Pix) > 0 {
+ // Use gBytesNew instead of gBytesNewStatic because Go memory can be
+ // moved or freed by the GC. gBytesNew copies the data to C-owned memory.
+ gbytes := gBytesNew(uintptr(unsafe.Pointer(&img.Pix[0])), len(img.Pix))
defer gBytesUnref(gbytes)
pixBuf := gdkPixbufNewFromBytes(
diff --git a/v3/pkg/application/mainthread_linux.go b/v3/pkg/application/mainthread_linux.go
index 27873f840..5154109d3 100644
--- a/v3/pkg/application/mainthread_linux.go
+++ b/v3/pkg/application/mainthread_linux.go
@@ -1,4 +1,4 @@
-//go:build linux && !android
+//go:build linux && !android && !server
package application
diff --git a/v3/pkg/application/menu_darwin.go b/v3/pkg/application/menu_darwin.go
index d1af28382..1a6e4a8d6 100644
--- a/v3/pkg/application/menu_darwin.go
+++ b/v3/pkg/application/menu_darwin.go
@@ -1,4 +1,4 @@
-//go:build darwin && !ios
+//go:build darwin && !ios && !server
package application
diff --git a/v3/pkg/application/menu_linux.go b/v3/pkg/application/menu_linux.go
index 0288000cb..1d8d681af 100644
--- a/v3/pkg/application/menu_linux.go
+++ b/v3/pkg/application/menu_linux.go
@@ -1,4 +1,4 @@
-//go:build linux && !android
+//go:build linux && !android && !server
package application
diff --git a/v3/pkg/application/menu_windows.go b/v3/pkg/application/menu_windows.go
index 210d22c0d..758fae985 100644
--- a/v3/pkg/application/menu_windows.go
+++ b/v3/pkg/application/menu_windows.go
@@ -1,4 +1,4 @@
-//go:build windows
+//go:build windows && !server
package application
diff --git a/v3/pkg/application/menuitem_linux.go b/v3/pkg/application/menuitem_linux.go
index deaf5f973..9aa256c8e 100644
--- a/v3/pkg/application/menuitem_linux.go
+++ b/v3/pkg/application/menuitem_linux.go
@@ -1,4 +1,4 @@
-//go:build linux && !android
+//go:build linux && !android && !server
package application
diff --git a/v3/pkg/application/messageprocessor.go b/v3/pkg/application/messageprocessor.go
index 4829d3dbf..a8b845470 100644
--- a/v3/pkg/application/messageprocessor.go
+++ b/v3/pkg/application/messageprocessor.go
@@ -133,6 +133,12 @@ func (m *MessageProcessor) HandleRuntimeCallWithIDs(ctx context.Context, req *Ru
}
func (m *MessageProcessor) getTargetWindow(req *RuntimeRequest) (Window, string) {
+ // Check for browser window first (server mode)
+ if req.ClientID != "" {
+ if browserWindow := GetBrowserWindow(req.ClientID); browserWindow != nil {
+ return browserWindow, browserWindow.Name()
+ }
+ }
if req.WebviewWindowName != "" {
window, _ := globalApplication.Window.GetByName(req.WebviewWindowName)
return window, req.WebviewWindowName
diff --git a/v3/pkg/application/messageprocessor_events.go b/v3/pkg/application/messageprocessor_events.go
index 9de3ae136..ea4e5882e 100644
--- a/v3/pkg/application/messageprocessor_events.go
+++ b/v3/pkg/application/messageprocessor_events.go
@@ -38,7 +38,9 @@ func (m *MessageProcessor) processEventsMethod(req *RuntimeRequest, window Windo
event.Name = *options.Name
event.Data = data
- event.Sender = window.Name()
+ if window != nil {
+ event.Sender = window.Name()
+ }
globalApplication.Event.EmitEvent(&event)
return event.IsCancelled(), nil
diff --git a/v3/pkg/application/screen_linux.go b/v3/pkg/application/screen_linux.go
index 250da29a5..60507f554 100644
--- a/v3/pkg/application/screen_linux.go
+++ b/v3/pkg/application/screen_linux.go
@@ -1,4 +1,4 @@
-//go:build linux && !android
+//go:build linux && !android && !server
package application
diff --git a/v3/pkg/application/single_instance_linux.go b/v3/pkg/application/single_instance_linux.go
index 7078b9ced..1d2a55c18 100644
--- a/v3/pkg/application/single_instance_linux.go
+++ b/v3/pkg/application/single_instance_linux.go
@@ -1,4 +1,4 @@
-//go:build linux && !android
+//go:build linux && !android && !server
package application
diff --git a/v3/pkg/application/systemtray_linux.go b/v3/pkg/application/systemtray_linux.go
index d1319f4bd..df44da586 100644
--- a/v3/pkg/application/systemtray_linux.go
+++ b/v3/pkg/application/systemtray_linux.go
@@ -1,4 +1,4 @@
-//go:build linux && !android
+//go:build linux && !android && !server
/*
Portions of this code are derived from the project:
diff --git a/v3/pkg/application/websocket_server.go b/v3/pkg/application/websocket_server.go
new file mode 100644
index 000000000..2660338b6
--- /dev/null
+++ b/v3/pkg/application/websocket_server.go
@@ -0,0 +1,137 @@
+//go:build server
+
+package application
+
+import (
+ "net/http"
+ "sync"
+ "sync/atomic"
+
+ "github.com/coder/websocket"
+ "github.com/coder/websocket/wsjson"
+)
+
+// globalBroadcaster holds a reference to the WebSocket broadcaster for server mode.
+// Used by HTTP handler to look up BrowserWindows from runtime client IDs.
+var globalBroadcaster *WebSocketBroadcaster
+
+// GetBrowserWindow returns the BrowserWindow for a given runtime clientId.
+// Only available in server mode. Returns nil if not found.
+func GetBrowserWindow(clientId string) *BrowserWindow {
+ if globalBroadcaster == nil {
+ return nil
+ }
+ return globalBroadcaster.GetBrowserWindow(clientId)
+}
+
+// clientInfo holds information about a connected WebSocket client.
+type clientInfo struct {
+ conn *websocket.Conn
+ window *BrowserWindow
+}
+
+// WebSocketBroadcaster manages WebSocket connections and broadcasts events to all connected clients.
+// It implements WailsEventListener to receive events from the application.
+type WebSocketBroadcaster struct {
+ clients map[*websocket.Conn]*clientInfo
+ windows map[string]*BrowserWindow // maps runtime clientId (nanoid) to BrowserWindow
+ mu sync.RWMutex
+ app *App
+ nextID atomic.Uint64
+}
+
+// NewWebSocketBroadcaster creates a new WebSocket broadcaster.
+func NewWebSocketBroadcaster(app *App) *WebSocketBroadcaster {
+ return &WebSocketBroadcaster{
+ clients: make(map[*websocket.Conn]*clientInfo),
+ windows: make(map[string]*BrowserWindow),
+ app: app,
+ }
+}
+
+// GetBrowserWindow returns the BrowserWindow for a given runtime clientId.
+// Returns nil if not found.
+func (b *WebSocketBroadcaster) GetBrowserWindow(clientId string) *BrowserWindow {
+ b.mu.RLock()
+ defer b.mu.RUnlock()
+ return b.windows[clientId]
+}
+
+// ServeHTTP handles WebSocket upgrade requests.
+func (b *WebSocketBroadcaster) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
+ // Allow connections from any origin in server mode
+ InsecureSkipVerify: true,
+ })
+ if err != nil {
+ b.app.error("WebSocket accept error", "error", err)
+ return
+ }
+
+ // Get runtime's clientId from query parameter
+ runtimeClientID := r.URL.Query().Get("clientId")
+
+ // Create BrowserWindow for this connection
+ browserWindow := NewBrowserWindow(uint(b.nextID.Add(1)), runtimeClientID)
+
+ // Store mapping if runtime clientId was provided
+ if runtimeClientID != "" {
+ b.mu.Lock()
+ b.windows[runtimeClientID] = browserWindow
+ b.mu.Unlock()
+ }
+
+ b.register(conn, browserWindow)
+ defer b.unregister(conn, runtimeClientID)
+
+ // Keep connection alive - read loop for ping/pong and detecting disconnects
+ // Events from client to server are sent via HTTP, not WebSocket
+ for {
+ _, _, err := conn.Read(r.Context())
+ if err != nil {
+ break
+ }
+ }
+}
+
+// register adds a client connection with its BrowserWindow.
+func (b *WebSocketBroadcaster) register(conn *websocket.Conn, window *BrowserWindow) {
+ client := &clientInfo{
+ conn: conn,
+ window: window,
+ }
+ b.mu.Lock()
+ b.clients[conn] = client
+ b.mu.Unlock()
+ b.app.info("WebSocket client connected", "id", window.Name(), "clients", len(b.clients))
+}
+
+// unregister removes a client connection and its BrowserWindow.
+func (b *WebSocketBroadcaster) unregister(conn *websocket.Conn, runtimeClientID string) {
+ b.mu.Lock()
+ client := b.clients[conn]
+ delete(b.clients, conn)
+ if runtimeClientID != "" {
+ delete(b.windows, runtimeClientID)
+ }
+ b.mu.Unlock()
+ conn.Close(websocket.StatusNormalClosure, "")
+ if client != nil {
+ b.app.info("WebSocket client disconnected", "id", client.window.Name(), "clients", len(b.clients))
+ }
+}
+
+// DispatchWailsEvent implements WailsEventListener interface.
+// It broadcasts the event to all connected WebSocket clients.
+func (b *WebSocketBroadcaster) DispatchWailsEvent(event *CustomEvent) {
+ b.mu.RLock()
+ defer b.mu.RUnlock()
+
+ for _, client := range b.clients {
+ go func(c *clientInfo) {
+ if err := wsjson.Write(b.app.ctx, c.conn, event); err != nil {
+ b.app.debug("WebSocket write error", "error", err, "client", c.window.Name())
+ }
+ }(client)
+ }
+}
diff --git a/v3/pkg/application/websocket_stub.go b/v3/pkg/application/websocket_stub.go
new file mode 100644
index 000000000..d7ad175bc
--- /dev/null
+++ b/v3/pkg/application/websocket_stub.go
@@ -0,0 +1,9 @@
+//go:build !server
+
+package application
+
+// GetBrowserWindow is a stub for non-server builds.
+// Returns nil as browser windows are only available in server mode.
+func GetBrowserWindow(clientId string) Window {
+ return nil
+}
diff --git a/v3/pkg/application/webview_window.go b/v3/pkg/application/webview_window.go
index dbf7eebcc..9309e9bb0 100644
--- a/v3/pkg/application/webview_window.go
+++ b/v3/pkg/application/webview_window.go
@@ -1180,7 +1180,10 @@ func (w *WebviewWindow) SetFrameless(frameless bool) Window {
}
func (w *WebviewWindow) DispatchWailsEvent(event *CustomEvent) {
- msg := fmt.Sprintf("window._wails.dispatchWailsEvent(%s);", event.ToJSON())
+ // Guard against race condition where event fires before runtime is initialized
+ // This can happen during page reload when WindowLoadFinished fires before
+ // the JavaScript runtime has mounted dispatchWailsEvent on window._wails
+ msg := fmt.Sprintf("if(window._wails&&window._wails.dispatchWailsEvent){window._wails.dispatchWailsEvent(%s);}", event.ToJSON())
w.ExecJS(msg)
}
@@ -1253,6 +1256,9 @@ func (w *WebviewWindow) shouldUnconditionallyClose() bool {
}
func (w *WebviewWindow) Focus() {
+ if w.impl == nil || w.isDestroyed() {
+ return
+ }
InvokeSync(w.impl.focus)
}
@@ -1444,7 +1450,7 @@ func (w *WebviewWindow) InitiateFrontendDropProcessing(filenames []string, x int
}
jsCall := fmt.Sprintf(
- "window.wails.Window.HandlePlatformFileDrop(%s, %d, %d);",
+ "window._wails.handlePlatformFileDrop(%s, %d, %d);",
string(filenamesJSON),
x,
y,
diff --git a/v3/pkg/application/webview_window_darwin.go b/v3/pkg/application/webview_window_darwin.go
index baf40556c..4a53d7337 100644
--- a/v3/pkg/application/webview_window_darwin.go
+++ b/v3/pkg/application/webview_window_darwin.go
@@ -111,6 +111,7 @@ void* windowNew(unsigned int id, int width, int height, bool fraudulentWebsiteWa
// support webview events
[webView setNavigationDelegate:delegate];
+ [webView setUIDelegate:delegate];
// Ensure webview resizes with the window
[webView setAutoresizingMask:NSViewWidthSizable | NSViewHeightSizable];
@@ -590,11 +591,19 @@ void windowGetRelativePosition(void* nsWindow, int* x, int* y) {
*y = screenFrame.size.height - frame.origin.y - frame.size.height;
}
-// Get absolute window position
+// Get absolute window position (in screen coordinates with Y=0 at top, scaled for DPI)
void windowGetPosition(void* nsWindow, int* x, int* y) {
- NSRect frame = [(WebviewWindow*)nsWindow frame];
- *x = frame.origin.x;
- *y = frame.origin.y;
+ WebviewWindow* window = (WebviewWindow*)nsWindow;
+ NSScreen* screen = [window screen];
+ if (screen == NULL) {
+ screen = [NSScreen mainScreen];
+ }
+ CGFloat scale = [screen backingScaleFactor];
+ NSRect frame = [window frame];
+ NSRect screenFrame = [screen frame];
+ // Convert to top-origin coordinates and apply scale (matching windowSetPosition)
+ *x = frame.origin.x * scale;
+ *y = (screenFrame.size.height - frame.origin.y - frame.size.height) * scale;
}
void windowSetPosition(void* nsWindow, int x, int y) {
diff --git a/v3/pkg/application/webview_window_darwin.h b/v3/pkg/application/webview_window_darwin.h
index 11fe1ab3a..489ff582e 100644
--- a/v3/pkg/application/webview_window_darwin.h
+++ b/v3/pkg/application/webview_window_darwin.h
@@ -18,7 +18,7 @@
@end
-@interface WebviewWindowDelegate : NSObject
+@interface WebviewWindowDelegate : NSObject
@property unsigned int windowId;
@property (retain) NSEvent* leftMouseEvent;
diff --git a/v3/pkg/application/webview_window_darwin.m b/v3/pkg/application/webview_window_darwin.m
index 7b47ffbbd..b508820c7 100644
--- a/v3/pkg/application/webview_window_darwin.m
+++ b/v3/pkg/application/webview_window_darwin.m
@@ -780,6 +780,23 @@ typedef NS_ENUM(NSInteger, MacLiquidGlassStyle) {
}
}
// GENERATED EVENTS END
+
+// WKUIDelegate - Handle file input element clicks
+- (void)webView:(WKWebView *)webView runOpenPanelWithParameters:(WKOpenPanelParameters *)parameters
+ initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(NSArray * URLs))completionHandler {
+ NSOpenPanel *openPanel = [NSOpenPanel openPanel];
+ openPanel.allowsMultipleSelection = parameters.allowsMultipleSelection;
+ if (@available(macOS 10.14, *)) {
+ openPanel.canChooseDirectories = parameters.allowsDirectories;
+ }
+ [openPanel beginSheetModalForWindow:webView.window
+ completionHandler:^(NSInteger result) {
+ if (result == NSModalResponseOK)
+ completionHandler(openPanel.URLs);
+ else
+ completionHandler(nil);
+ }];
+}
@end
void windowSetScreen(void* window, void* screen, int yOffset) {
WebviewWindow* nsWindow = (WebviewWindow*)window;
diff --git a/v3/pkg/application/webview_window_linux.go b/v3/pkg/application/webview_window_linux.go
index 0400bc726..ed664b49f 100644
--- a/v3/pkg/application/webview_window_linux.go
+++ b/v3/pkg/application/webview_window_linux.go
@@ -1,4 +1,4 @@
-//go:build linux && !android
+//go:build linux && !android && !server
package application
@@ -273,10 +273,17 @@ func (w *linuxWebviewWindow) run() {
var menu = w.parent.options.Linux.Menu
if menu != nil {
+ // Explicit window menu takes priority
InvokeSync(func() {
menu.Update()
})
w.gtkmenu = (menu.impl).(*linuxMenu).native
+ } else if w.parent.options.UseApplicationMenu && globalApplication.applicationMenu != nil {
+ // Use the global application menu if opted in
+ InvokeSync(func() {
+ globalApplication.applicationMenu.Update()
+ })
+ w.gtkmenu = (globalApplication.applicationMenu.impl).(*linuxMenu).native
}
w.window, w.webview, w.vbox = windowNew(app.application, w.gtkmenu, w.parent.id, w.parent.options.Linux.WebviewGpuPolicy)
diff --git a/v3/pkg/application/webview_window_linux_dev.go b/v3/pkg/application/webview_window_linux_dev.go
index 65ba83132..997126d1f 100644
--- a/v3/pkg/application/webview_window_linux_dev.go
+++ b/v3/pkg/application/webview_window_linux_dev.go
@@ -1,4 +1,4 @@
-//go:build linux && !production && !android
+//go:build linux && !production && !android && !server
package application
diff --git a/v3/pkg/application/webview_window_linux_production.go b/v3/pkg/application/webview_window_linux_production.go
index 5011ad035..97ab2d627 100644
--- a/v3/pkg/application/webview_window_linux_production.go
+++ b/v3/pkg/application/webview_window_linux_production.go
@@ -1,4 +1,4 @@
-//go:build linux && production && !devtools && !android
+//go:build linux && production && !devtools && !android && !server
package application
diff --git a/v3/pkg/application/webview_window_options.go b/v3/pkg/application/webview_window_options.go
index 3ae49157d..d74451564 100644
--- a/v3/pkg/application/webview_window_options.go
+++ b/v3/pkg/application/webview_window_options.go
@@ -142,6 +142,13 @@ type WebviewWindowOptions struct {
// Effective on Windows and macOS only; no-op on Linux.
// Best-effort protection with platform-specific caveats (see docs).
ContentProtectionEnabled bool
+
+ // UseApplicationMenu indicates this window should use the application menu
+ // set via app.Menu.Set() instead of requiring a window-specific menu.
+ // On macOS this has no effect as the application menu is always global.
+ // On Windows/Linux, if true and no explicit window menu is set, the window
+ // will use the application menu. Defaults to false for backwards compatibility.
+ UseApplicationMenu bool
}
type RGBA struct {
@@ -280,16 +287,6 @@ type WindowsWindow struct {
// PasswordAutosaveEnabled enables autosaving passwords
PasswordAutosaveEnabled bool
-
- // EnabledFeatures, DisabledFeatures and AdditionalLaunchArgs are used to enable or disable specific features in the WebView2 browser.
- // Available flags: https://learn.microsoft.com/en-us/microsoft-edge/webview2/concepts/webview-features-flags?tabs=dotnetcsharp#available-webview2-browser-flags
- // WARNING: Apps in production shouldn't use WebView2 browser flags,
- // because these flags might be removed or altered at any time,
- // and aren't necessarily supported long-term.
- // AdditionalLaunchArgs should always be preceded by "--"
- EnabledFeatures []string
- DisabledFeatures []string
- AdditionalLaunchArgs []string
}
type Theme int
diff --git a/v3/pkg/application/webview_window_windows.go b/v3/pkg/application/webview_window_windows.go
index b2bb3c04d..16ba08f0b 100644
--- a/v3/pkg/application/webview_window_windows.go
+++ b/v3/pkg/application/webview_window_windows.go
@@ -362,10 +362,17 @@ func (w *windowsWebviewWindow) run() {
if !options.Frameless {
userMenu := w.parent.options.Windows.Menu
if userMenu != nil {
+ // Explicit window menu takes priority
userMenu.Update()
w.menu = NewApplicationMenu(w, userMenu)
w.menu.parentWindow = w
appMenu = w.menu.menu
+ } else if options.UseApplicationMenu && globalApplication.applicationMenu != nil {
+ // Use the global application menu if opted in
+ globalApplication.applicationMenu.Update()
+ w.menu = NewApplicationMenu(w, globalApplication.applicationMenu)
+ w.menu.parentWindow = w
+ appMenu = w.menu.menu
}
}
@@ -1918,23 +1925,27 @@ func (w *windowsWebviewWindow) setupChromium() {
}
globalApplication.capabilities = capabilities.NewCapabilities(webview2version)
+ // Browser flags apply globally to the shared WebView2 environment
+ // Use application-level options, not per-window options
+ appOpts := globalApplication.options.Windows
+
// We disable this by default. Can be overridden with the `EnableFraudulentWebsiteWarnings` option
- opts.DisabledFeatures = append(opts.DisabledFeatures, "msSmartScreenProtection")
+ disabledFeatures := append([]string{"msSmartScreenProtection"}, appOpts.DisabledFeatures...)
- if len(opts.DisabledFeatures) > 0 {
- opts.DisabledFeatures = sliceutil.Unique(opts.DisabledFeatures)
- arg := fmt.Sprintf("--disable-features=%s", strings.Join(opts.DisabledFeatures, ","))
+ if len(disabledFeatures) > 0 {
+ disabledFeatures = sliceutil.Unique(disabledFeatures)
+ arg := fmt.Sprintf("--disable-features=%s", strings.Join(disabledFeatures, ","))
chromium.AdditionalBrowserArgs = append(chromium.AdditionalBrowserArgs, arg)
}
- if len(opts.EnabledFeatures) > 0 {
- opts.EnabledFeatures = sliceutil.Unique(opts.EnabledFeatures)
- arg := fmt.Sprintf("--enable-features=%s", strings.Join(opts.EnabledFeatures, ","))
+ if len(appOpts.EnabledFeatures) > 0 {
+ enabledFeatures := sliceutil.Unique(appOpts.EnabledFeatures)
+ arg := fmt.Sprintf("--enable-features=%s", strings.Join(enabledFeatures, ","))
chromium.AdditionalBrowserArgs = append(chromium.AdditionalBrowserArgs, arg)
}
- if len(opts.AdditionalLaunchArgs) > 0 {
- chromium.AdditionalBrowserArgs = append(chromium.AdditionalBrowserArgs, opts.AdditionalLaunchArgs...)
+ if len(appOpts.AdditionalBrowserArgs) > 0 {
+ chromium.AdditionalBrowserArgs = append(chromium.AdditionalBrowserArgs, appOpts.AdditionalBrowserArgs...)
}
chromium.DataPath = globalApplication.options.Windows.WebviewUserDataPath
diff --git a/v3/test/dnd-npm-runtime/README.md b/v3/test/dnd-npm-runtime/README.md
new file mode 100644
index 000000000..f9c62ff2f
--- /dev/null
+++ b/v3/test/dnd-npm-runtime/README.md
@@ -0,0 +1,49 @@
+# DND NPM Runtime Test
+
+This test verifies that drag-and-drop functionality works correctly when using the `@wailsio/runtime` npm module instead of the bundled `/wails/runtime.js`.
+
+## Background
+
+There was a bug where the Go backend called `window.wails.Window.HandlePlatformFileDrop()` for native file drops (macOS/Linux), but the npm module only registers the handler at `window._wails.handlePlatformFileDrop`.
+
+The bundled runtime sets `window.wails = Runtime`, so the call worked. But with the npm module, `window.wails` is an empty object.
+
+## The Fix
+
+Changed `v3/pkg/application/webview_window.go` to call the internal path that both runtimes set up:
+
+```go
+// Before (only worked with bundled runtime):
+"window.wails.Window.HandlePlatformFileDrop(%s, %d, %d);"
+
+// After (works with both):
+"window._wails.handlePlatformFileDrop(%s, %d, %d);"
+```
+
+## Running the Test
+
+The frontend is pre-built, so you can run directly:
+
+```bash
+go run .
+```
+
+Then drag files from Finder/Explorer onto the drop zone. Files should be categorized and displayed.
+
+### Rebuilding the Frontend (optional)
+
+Only needed if you modify the frontend code:
+
+```bash
+cd frontend
+npm install
+npm run build
+```
+
+## What This Tests
+
+1. `@wailsio/runtime` npm module initialization
+2. Event system (`Events.On('files-dropped', ...)`)
+3. Native file drop handling on macOS/Linux via `window._wails.handlePlatformFileDrop`
+4. Drop target detection with `data-file-drop-target` attribute
+5. Visual feedback with `.file-drop-target-active` class
diff --git a/v3/test/dnd-npm-runtime/frontend/dist/assets/index-sgSTKtcv.js b/v3/test/dnd-npm-runtime/frontend/dist/assets/index-sgSTKtcv.js
new file mode 100644
index 000000000..822301da7
--- /dev/null
+++ b/v3/test/dnd-npm-runtime/frontend/dist/assets/index-sgSTKtcv.js
@@ -0,0 +1,6 @@
+(function(){const e=document.createElement("link").relList;if(e&&e.supports&&e.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))o(i);new MutationObserver(i=>{for(const r of i)if(r.type==="childList")for(const a of r.addedNodes)a.tagName==="LINK"&&a.rel==="modulepreload"&&o(a)}).observe(document,{childList:!0,subtree:!0});function n(i){const r={};return i.integrity&&(r.integrity=i.integrity),i.referrerPolicy&&(r.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?r.credentials="include":i.crossOrigin==="anonymous"?r.credentials="omit":r.credentials="same-origin",r}function o(i){if(i.ep)return;i.ep=!0;const r=n(i);fetch(i.href,r)}})();const V="useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict";function $(t=21){let e="",n=t|0;for(;n--;)e+=V[Math.random()*64|0];return e}const q=window.location.origin+"/wails/runtime",x=Object.freeze({Call:0,Clipboard:1,Application:2,Events:3,ContextMenu:4,Dialog:5,Window:6,Screens:7,System:8,Browser:9,CancelCall:10,IOS:11});let U=$();function D(t,e=""){return function(n,o=null){return J(t,n,e,o)}}async function J(t,e,n,o){var i,r;let a=new URL(q),l={object:t,method:e};o!=null&&(l.args=o);let c={"x-wails-client-id":U,"Content-Type":"application/json"};n&&(c["x-wails-window-name"]=n);let f=await fetch(a,{method:"POST",headers:c,body:JSON.stringify(l)});if(!f.ok)throw new Error(await f.text());return((r=(i=f.headers.get("Content-Type"))===null||i===void 0?void 0:i.indexOf("application/json"))!==null&&r!==void 0?r:-1)!==-1?f.json():f.text()}D(x.System);const z=function(){var t,e,n,o,i,r;try{if(!((e=(t=window.chrome)===null||t===void 0?void 0:t.webview)===null||e===void 0)&&e.postMessage)return window.chrome.webview.postMessage.bind(window.chrome.webview);if(!((i=(o=(n=window.webkit)===null||n===void 0?void 0:n.messageHandlers)===null||o===void 0?void 0:o.external)===null||i===void 0)&&i.postMessage)return window.webkit.messageHandlers.external.postMessage.bind(window.webkit.messageHandlers.external);if(!((r=window.wails)===null||r===void 0)&&r.invoke)return a=>window.wails.invoke(typeof a=="string"?a:JSON.stringify(a))}catch{}return console.warn(`
+%c⚠️ Browser Environment Detected %c
+
+%cOnly UI previews are available in the browser. For full functionality, please run the application in desktop mode.
+More information at: https://v3.wails.io/learn/build/#using-a-browser-for-development
+`,"background: #ffffff; color: #000000; font-weight: bold; padding: 4px 8px; border-radius: 4px; border: 2px solid #000000;","background: transparent;","color: #ffffff; font-style: italic; font-weight: bold;"),null}();function _(t){z==null||z(t)}function Z(){var t,e;return((e=(t=window._wails)===null||t===void 0?void 0:t.environment)===null||e===void 0?void 0:e.OS)==="windows"}function K(){var t,e;return!!(!((e=(t=window._wails)===null||t===void 0?void 0:t.environment)===null||e===void 0)&&e.Debug)}function Q(){return new MouseEvent("mousedown").buttons===0}function X(t){var e;return t.target instanceof HTMLElement?t.target:!(t.target instanceof HTMLElement)&&t.target instanceof Node&&(e=t.target.parentElement)!==null&&e!==void 0?e:document.body}document.addEventListener("DOMContentLoaded",()=>{});window.addEventListener("contextmenu",oe);const ee=D(x.ContextMenu),te=0;function ne(t,e,n,o){ee(te,{id:t,x:e,y:n,data:o})}function oe(t){const e=X(t),n=window.getComputedStyle(e).getPropertyValue("--custom-contextmenu").trim();if(n){t.preventDefault();const o=window.getComputedStyle(e).getPropertyValue("--custom-contextmenu-data");ne(n,t.clientX,t.clientY,o)}else ie(t,e)}function ie(t,e){if(K())return;switch(window.getComputedStyle(e).getPropertyValue("--default-contextmenu").trim()){case"show":return;case"hide":t.preventDefault();return}if(e.isContentEditable)return;const n=window.getSelection(),o=n&&n.toString().length>0;if(o)for(let i=0;i{R=t,R||(b=M=!1,u())};let k=!1;function se(){var t,e;const n=(e=(t=window._wails)===null||t===void 0?void 0:t.environment)===null||e===void 0?void 0:e.OS;if(n==="ios"||n==="android")return!0;const o=navigator.userAgent||navigator.vendor||window.opera||"";return/android|iphone|ipad|ipod|iemobile|wpdesktop/i.test(o)}function H(){if(!k&&!se()){window.addEventListener("mousedown",P,{capture:!0}),window.addEventListener("mousemove",P,{capture:!0}),window.addEventListener("mouseup",P,{capture:!0});for(const t of["click","contextmenu","dblclick"])window.addEventListener(t,le,{capture:!0});k=!0}}H();document.addEventListener("DOMContentLoaded",H,{once:!0});let re=0;const W=window.setInterval(()=>{if(k){window.clearInterval(W);return}H(),++re>100&&window.clearInterval(W)},50);function le(t){(E||M)&&(t.stopImmediatePropagation(),t.stopPropagation(),t.preventDefault())}const L=0,ae=1,O=2;function P(t){let e,n=t.buttons;switch(t.type){case"mousedown":e=L,C||(n=v|1<n!==t),e.length===0?h.delete(t.eventName):h.set(t.eventName,e))}window._wails=window._wails||{};window._wails.dispatchWailsEvent=pe;D(x.Events);class me{constructor(e,n){this.name=e,this.data=n??null}}function pe(t){let e=h.get(t.name);if(!e)return;let n=new me(t.name,t.name in j?j[t.name](t.data):t.data);"sender"in t&&(n.sender=t.sender),e=e.filter(o=>!o.dispatch(n)),e.length===0?h.delete(t.name):h.set(t.name,e)}function ge(t,e,n){let o=h.get(t)||[];const i=new we(t,e,n);return o.push(i),h.set(t,o),()=>he(i)}function ve(t,e){return ge(t,e,-1)}const be="data-file-drop-target",w="file-drop-target-active";let d=null;const Me=0,ye=1,Ee=2,_e=3,Se=4,Te=5,xe=6,De=7,ze=8,Fe=9,Ce=10,Le=11,Oe=12,Pe=13,Ie=14,Re=15,ke=16,He=17,Ae=18,Be=19,We=20,je=21,Ne=22,Ue=23,Ze=24,Xe=25,Ye=26,Ge=27,Ve=28,$e=29,qe=30,Je=31,Ke=32,Qe=33,et=34,tt=35,nt=36,ot=37,it=38,st=39,rt=40,lt=41,at=42,dt=43,ct=44,ut=45,ft=46,wt=47,ht=48,mt=49,pt=50,gt=51;function S(t){return t?t.closest(`[${be}]`):null}function vt(){var t,e,n,o;return((e=(t=window.chrome)===null||t===void 0?void 0:t.webview)===null||e===void 0?void 0:e.postMessageWithAdditionalObjects)==null?!1:((o=(n=window._wails)===null||n===void 0?void 0:n.flags)===null||o===void 0?void 0:o.enableFileDrop)===!0}function bt(t,e,n){var o,i;!((i=(o=window.chrome)===null||o===void 0?void 0:o.webview)===null||i===void 0)&&i.postMessageWithAdditionalObjects&&window.chrome.webview.postMessageWithAdditionalObjects(`file:drop:${t}:${e}`,n)}let A=!1;function Y(){A=!1,d&&(d.classList.remove(w),d=null)}function Mt(){var t,e;((e=(t=window._wails)===null||t===void 0?void 0:t.flags)===null||e===void 0?void 0:e.enableFileDrop)!==!1&&(A=!0)}function yt(){Y()}function Et(t,e){var n,o;if(!A||((o=(n=window._wails)===null||n===void 0?void 0:n.flags)===null||o===void 0?void 0:o.enableFileDrop)===!1)return;const i=document.elementFromPoint(t,e),r=S(i);d&&d!==r&&d.classList.remove(w),r?(r.classList.add(w),d=r):d=null}const s=Symbol("caller");class T{constructor(e=""){this[s]=D(x.Window,e);for(const n of Object.getOwnPropertyNames(T.prototype))n!=="constructor"&&typeof this[n]=="function"&&(this[n]=this[n].bind(this))}Get(e){return new T(e)}Position(){return this[s](Me)}Center(){return this[s](ye)}Close(){return this[s](Ee)}DisableSizeConstraints(){return this[s](_e)}EnableSizeConstraints(){return this[s](Se)}Focus(){return this[s](Te)}ForceReload(){return this[s](xe)}Fullscreen(){return this[s](De)}GetScreen(){return this[s](ze)}GetZoom(){return this[s](Fe)}Height(){return this[s](Ce)}Hide(){return this[s](Le)}IsFocused(){return this[s](Oe)}IsFullscreen(){return this[s](Pe)}IsMaximised(){return this[s](Ie)}IsMinimised(){return this[s](Re)}Maximise(){return this[s](ke)}Minimise(){return this[s](He)}Name(){return this[s](Ae)}OpenDevTools(){return this[s](Be)}RelativePosition(){return this[s](We)}Reload(){return this[s](je)}Resizable(){return this[s](Ne)}Restore(){return this[s](Ue)}SetPosition(e,n){return this[s](Ze,{x:e,y:n})}SetAlwaysOnTop(e){return this[s](Xe,{alwaysOnTop:e})}SetBackgroundColour(e,n,o,i){return this[s](Ye,{r:e,g:n,b:o,a:i})}SetFrameless(e){return this[s](Ge,{frameless:e})}SetFullscreenButtonEnabled(e){return this[s](Ve,{enabled:e})}SetMaxSize(e,n){return this[s]($e,{width:e,height:n})}SetMinSize(e,n){return this[s](qe,{width:e,height:n})}SetRelativePosition(e,n){return this[s](Je,{x:e,y:n})}SetResizable(e){return this[s](Ke,{resizable:e})}SetSize(e,n){return this[s](Qe,{width:e,height:n})}SetTitle(e){return this[s](et,{title:e})}SetZoom(e){return this[s](tt,{zoom:e})}Show(){return this[s](nt)}Size(){return this[s](ot)}ToggleFullscreen(){return this[s](it)}ToggleMaximise(){return this[s](st)}ToggleFrameless(){return this[s](rt)}UnFullscreen(){return this[s](lt)}UnMaximise(){return this[s](at)}UnMinimise(){return this[s](dt)}Width(){return this[s](ct)}Zoom(){return this[s](ut)}ZoomIn(){return this[s](ft)}ZoomOut(){return this[s](wt)}ZoomReset(){return this[s](ht)}HandlePlatformFileDrop(e,n,o){var i,r;if(((r=(i=window._wails)===null||i===void 0?void 0:i.flags)===null||r===void 0?void 0:r.enableFileDrop)===!1)return;const a=document.elementFromPoint(n,o),l=S(a);if(!l)return;const c={id:l.id,classList:Array.from(l.classList),attributes:{}};for(let m=0;m{var o,i,r;if(!(!((o=n.dataTransfer)===null||o===void 0)&&o.types.includes("Files")))return;if(n.preventDefault(),((r=(i=window._wails)===null||i===void 0?void 0:i.flags)===null||r===void 0?void 0:r.enableFileDrop)===!1){n.dataTransfer.dropEffect="none";return}e++;const a=document.elementFromPoint(n.clientX,n.clientY),l=S(a);d&&d!==l&&d.classList.remove(w),l?(l.classList.add(w),n.dataTransfer.dropEffect="copy",d=l):(n.dataTransfer.dropEffect="none",d=null)},!1),t.addEventListener("dragover",n=>{var o,i,r;if(!(!((o=n.dataTransfer)===null||o===void 0)&&o.types.includes("Files")))return;if(n.preventDefault(),((r=(i=window._wails)===null||i===void 0?void 0:i.flags)===null||r===void 0?void 0:r.enableFileDrop)===!1){n.dataTransfer.dropEffect="none";return}const a=document.elementFromPoint(n.clientX,n.clientY),l=S(a);d&&d!==l&&d.classList.remove(w),l?(l.classList.contains(w)||l.classList.add(w),n.dataTransfer.dropEffect="copy",d=l):(n.dataTransfer.dropEffect="none",d=null)},!1),t.addEventListener("dragleave",n=>{var o,i,r;!((o=n.dataTransfer)===null||o===void 0)&&o.types.includes("Files")&&(n.preventDefault(),((r=(i=window._wails)===null||i===void 0?void 0:i.flags)===null||r===void 0?void 0:r.enableFileDrop)!==!1&&n.relatedTarget!==null&&(e--,(e===0||d&&!d.contains(n.relatedTarget))&&(d&&(d.classList.remove(w),d=null),e=0)))},!1),t.addEventListener("drop",n=>{var o,i,r;if(!((o=n.dataTransfer)===null||o===void 0)&&o.types.includes("Files")&&(n.preventDefault(),((r=(i=window._wails)===null||i===void 0?void 0:i.flags)===null||r===void 0?void 0:r.enableFileDrop)!==!1&&(e=0,d&&(d.classList.remove(w),d=null),vt()))){const a=[];if(n.dataTransfer.items){for(const l of n.dataTransfer.items)if(l.kind==="file"){const c=l.getAsFile();c&&a.push(c)}}else if(n.dataTransfer.files)for(const l of n.dataTransfer.files)a.push(l);a.length>0&&bt(n.clientX,n.clientY,a)}},!1)}typeof window<"u"&&typeof document<"u"&&_t();window._wails=window._wails||{};window._wails.invoke=_;window._wails.clientId=U;window._wails.handlePlatformFileDrop=N.HandlePlatformFileDrop.bind(N);window._wails.handleDragEnter=Mt;window._wails.handleDragLeave=yt;window._wails.handleDragOver=Et;_("wails:runtime:ready");function St(t){return fetch(t,{method:"HEAD"}).then(e=>{if(e.ok){const n=document.createElement("script");n.src=t,document.head.appendChild(n)}}).catch(()=>{})}St("/wails/custom.js");const Tt=document.getElementById("documents-list"),xt=document.getElementById("images-list"),Dt=document.getElementById("other-list"),zt=document.getElementById("drop-details"),Ft=[".png",".jpg",".jpeg",".gif",".bmp",".svg",".webp",".ico",".tiff",".tif"],Ct=[".pdf",".doc",".docx",".txt",".rtf",".odt",".xls",".xlsx",".ppt",".pptx",".md",".csv",".json",".xml",".html",".htm"];function G(t){return t.split(/[/\\]/).pop()}function Lt(t){const e=G(t),n=e.lastIndexOf(".");return n>0?e.substring(n).toLowerCase():""}function Ot(t){const e=Lt(t);return Ft.includes(e)?"images":Ct.includes(e)?"documents":"other"}function I(t,e){const n=t.querySelector(".empty");n&&n.remove();const o=document.createElement("li");o.textContent=e,t.appendChild(o)}ve("files-dropped",t=>{const{files:e,details:n}=t.data;e.forEach(i=>{const r=G(i);switch(Ot(i)){case"documents":I(Tt,r);break;case"images":I(xt,r);break;default:I(Dt,r)}});let o=`External: ${e.length} file(s) dropped`;n&&(o+=` at (${n.x}, ${n.y})`),zt.textContent=o});console.log("[DND NPM Test] Initialized with @wailsio/runtime");
diff --git a/v3/test/dnd-npm-runtime/frontend/dist/index.html b/v3/test/dnd-npm-runtime/frontend/dist/index.html
new file mode 100644
index 000000000..cf57d6b3c
--- /dev/null
+++ b/v3/test/dnd-npm-runtime/frontend/dist/index.html
@@ -0,0 +1,185 @@
+
+
+
+
+
+ DND NPM Runtime Test
+
+
+
+
+
DND NPM Runtime Test @wailsio/runtime
+
+
+
External File Drop
+
+
+ This test uses the @wailsio/runtime npm module instead of the bundled /wails/runtime.js.
+ Drop files from your operating system to verify the fix works.
+
+ This test uses the @wailsio/runtime npm module instead of the bundled /wails/runtime.js.
+ Drop files from your operating system to verify the fix works.
+
Exception class that will be thrown in case the bound method returns an error. The value of the RuntimeError#name property is "RuntimeError".
-